diff --git a/.editorconfig b/.editorconfig index 37cfad78c270b..1a9acd92fc0fc 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,3 +10,9 @@ trim_trailing_whitespace = true [*.md] trim_trailing_whitespace = false + +[*.{yml,yaml,json}] +indent_size = 2 + +[{composer, auth}.json] +indent_size = 4 diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 0000000000000..0b9283fde06c7 --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,68 @@ +# Configuration for probot-stale - https://github.com/probot/stale + +# Number of days of inactivity before an Issue or Pull Request becomes stale +daysUntilStale: 76 + +# Number of days of inactivity before an Issue or Pull Request with the stale label is closed. +# Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale. +daysUntilClose: 14 + +# Only issues or pull requests with all of these labels are check if stale. Defaults to `[]` (disabled) +onlyLabels: [] + +# Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable +exemptLabels: + - "Priority: P0" + - "Priority: P1" + - "Priority: P2" + - "Progress: dev in progress" + - "Progress: PR in progress" + - "Progress: done" + - "B2B: GraphQL" + - "Progress: PR Created" + - "PAP" + - "Project: Login as Customer" + - "Project: GraphQL" + +# Set to true to ignore issues in a project (defaults to false) +exemptProjects: false + +# Set to true to ignore issues in a milestone (defaults to false) +exemptMilestones: false + +# Set to true to ignore issues with an assignee (defaults to false) +exemptAssignees: false + +# Label to use when marking as stale +staleLabel: "stale issue" + +# Comment to post when marking as stale. Set to `false` to disable +markComment: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed after 14 days if no further activity occurs. Thank you + for your contributions. +# Comment to post when removing the stale label. +# unmarkComment: > +# Your comment here. + +# Comment to post when closing a stale Issue or Pull Request. +# closeComment: > +# Your comment here. + +# Limit the number of actions per hour, from 1-30. Default is 30 +limitPerRun: 30 + +# Limit to only `issues` or `pulls` +only: issues + +# Optionally, specify configuration settings that are specific to just 'issues' or 'pulls': +# pulls: +# daysUntilStale: 30 +# markComment: > +# This pull request has been automatically marked as stale because it has not had +# recent activity. It will be closed if no further activity occurs. Thank you +# for your contributions. + +# issues: +# exemptLabels: +# - confirmed diff --git a/.gitignore b/.gitignore index 8ec1104f25535..7092a568ba2a2 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,8 @@ atlassian* /pub/media/tmp/* !/pub/media/tmp/.htaccess /pub/media/captcha/* +/pub/media/sitemap/* +!/pub/media/sitemap/.htaccess /pub/static/* !/pub/static/.htaccess diff --git a/.htaccess b/.htaccess index c5f3bf034d2fb..ae929f8bc6467 100644 --- a/.htaccess +++ b/.htaccess @@ -1,393 +1,7 @@ -############################################ -## overrides deployment configuration mode value -## use command bin/magento deploy:mode:set to switch modes - -# SetEnv MAGE_MODE developer - -############################################ -## uncomment these lines for CGI mode -## make sure to specify the correct cgi php binary file name -## it might be /cgi-bin/php-cgi - -# Action php5-cgi /cgi-bin/php5-cgi -# AddHandler php5-cgi .php - -############################################ -## GoDaddy specific options - -# Options -MultiViews - -## you might also need to add this line to php.ini -## cgi.fix_pathinfo = 1 -## if it still doesn't work, rename php.ini to php5.ini - -############################################ -## this line is specific for 1and1 hosting - - #AddType x-mapp-php5 .php - #AddHandler x-mapp-php5 .php - -############################################ -## enable usage of methods arguments in backtrace - - SetEnv MAGE_DEBUG_SHOW_ARGS 1 - -############################################ -## default index file - - DirectoryIndex index.php - - -############################################ -## adjust memory limit - - php_value memory_limit 756M - php_value max_execution_time 18000 - -############################################ -## disable automatic session start -## before autoload was initialized - - php_flag session.auto_start off - -############################################ -## enable resulting html compression - - #php_flag zlib.output_compression on - -########################################### -## disable user agent verification to not break multiple image upload - - php_flag suhosin.session.cryptua off - - -########################################### -## disable POST processing to not break multiple image upload - - SecFilterEngine Off - SecFilterScanPOST Off - - - - -############################################ -## enable apache served files compression -## http://developer.yahoo.com/performance/rules.html#gzip - - # Insert filter on all content - ###SetOutputFilter DEFLATE - # Insert filter on selected content types only - #AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css text/javascript application/javascript application/x-javascript application/json image/svg+xml - - # Netscape 4.x has some problems... - #BrowserMatch ^Mozilla/4 gzip-only-text/html - - # Netscape 4.06-4.08 have some more problems - #BrowserMatch ^Mozilla/4\.0[678] no-gzip - - # MSIE masquerades as Netscape, but it is fine - #BrowserMatch \bMSIE !no-gzip !gzip-only-text/html - - # Don't compress images - #SetEnvIfNoCase Request_URI \.(?:gif|jpe?g|png)$ no-gzip dont-vary - - # Make sure proxies don't deliver the wrong content - #Header append Vary User-Agent env=!dont-vary - - - - - -############################################ -## make HTTPS env vars available for CGI mode - - SSLOptions StdEnvVars - - - -############################################ -## workaround for Apache 2.4.6 CentOS build when working via ProxyPassMatch with HHVM (or any other) -## Please, set it on virtual host configuration level - -## SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1 -############################################ - - - -############################################ -## enable rewrites - - Options +FollowSymLinks - RewriteEngine on - -############################################ -## you can put here your magento root folder -## path relative to web root - - #RewriteBase /magento/ - -############################################ -## workaround for HTTP authorization -## in CGI environment - - RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] - -############################################ -## TRACE and TRACK HTTP methods disabled to prevent XSS attacks - - RewriteCond %{REQUEST_METHOD} ^TRAC[EK] - RewriteRule .* - [L,R=405] - -############################################ -## redirect for mobile user agents - - #RewriteCond %{REQUEST_URI} !^/mobiledirectoryhere/.*$ - #RewriteCond %{HTTP_USER_AGENT} "android|blackberry|ipad|iphone|ipod|iemobile|opera mobile|palmos|webos|googlebot-mobile" [NC] - #RewriteRule ^(.*)$ /mobiledirectoryhere/ [L,R=302] - -############################################ -## never rewrite for existing files, directories and links - - RewriteCond %{REQUEST_FILENAME} !-f - RewriteCond %{REQUEST_FILENAME} !-d - RewriteCond %{REQUEST_FILENAME} !-l - -############################################ -## rewrite everything else to index.php - - RewriteRule .* index.php [L] - - - - -############################################ -## Prevent character encoding issues from server overrides -## If you still have problems, use the second line instead - - AddDefaultCharset Off - #AddDefaultCharset UTF-8 - AddType 'text/html; charset=UTF-8' html - - - -############################################ -## Add default Expires header -## http://developer.yahoo.com/performance/rules.html#expires - - ExpiresDefault "access plus 1 year" - ExpiresByType text/html A0 - ExpiresByType text/plain A0 - - - -########################################### -## Deny access to root files to hide sensitive application information - RedirectMatch 403 /\.git - - - - order allow,deny - deny from all - - = 2.4> - Require all denied - - - - - order allow,deny - deny from all - - = 2.4> - Require all denied - - - - - order allow,deny - deny from all - - = 2.4> - Require all denied - - - - - order allow,deny - deny from all - - = 2.4> - Require all denied - - - - - order allow,deny - deny from all - - = 2.4> - Require all denied - - - - - order allow,deny - deny from all - - = 2.4> - Require all denied - - - - - order allow,deny - deny from all - - = 2.4> - Require all denied - - - - - order allow,deny - deny from all - - = 2.4> - Require all denied - - - - - order allow,deny - deny from all - - = 2.4> - Require all denied - - - - - order allow,deny - deny from all - - = 2.4> - Require all denied - - - - - order allow,deny - deny from all - - = 2.4> - Require all denied - - - - - order allow,deny - deny from all - - = 2.4> - Require all denied - - - - - order allow,deny - deny from all - - = 2.4> - Require all denied - - - - - order allow,deny - deny from all - - = 2.4> - Require all denied - - - - - order allow,deny - deny from all - - = 2.4> - Require all denied - - - - - order allow,deny - deny from all - - = 2.4> - Require all denied - - - - - order allow,deny - deny from all - - = 2.4> - Require all denied - - - - - order allow,deny - deny from all - - = 2.4> - Require all denied - - - -# For 404s and 403s that aren't handled by the application, show plain 404 response -ErrorDocument 404 /pub/errors/404.php -ErrorDocument 403 /pub/errors/404.php - -################################ -## If running in cluster environment, uncomment this -## http://developer.yahoo.com/performance/rules.html#etags - - #FileETag none - -# ###################################################################### -# # INTERNET EXPLORER # -# ###################################################################### - -# ---------------------------------------------------------------------- -# | Document modes | -# ---------------------------------------------------------------------- - -# Force Internet Explorer 8/9/10 to render pages in the highest mode -# available in the various cases when it may not. -# -# https://hsivonen.fi/doctype/#ie8 -# -# (!) Starting with Internet Explorer 11, document modes are deprecated. -# If your business still relies on older web apps and services that were -# designed for older versions of Internet Explorer, you might want to -# consider enabling `Enterprise Mode` throughout your company. -# -# https://msdn.microsoft.com/en-us/library/ie/bg182625.aspx#docmode -# http://blogs.msdn.com/b/ie/archive/2014/04/02/stay-up-to-date-with-enterprise-mode-for-internet-explorer-11.aspx - - - - Header set X-UA-Compatible "IE=edge" - - # `mod_headers` cannot match based on the content-type, however, - # the `X-UA-Compatible` response header should be send only for - # HTML documents and not for the other resources. - - - Header unset X-UA-Compatible - - - +RewriteEngine on +RewriteCond %{REQUEST_URI} !^/pub/ +RewriteCond %{REQUEST_URI} !^/setup/ +RewriteCond %{REQUEST_URI} !^/update/ +RewriteCond %{REQUEST_URI} !^/dev/ +RewriteRule .* /pub/$0 [L] +DirectoryIndex index.php diff --git a/app/code/Magento/AdminNotification/Block/ToolbarEntry.php b/app/code/Magento/AdminNotification/Block/ToolbarEntry.php index c097edfd8af65..42ca68177cb83 100644 --- a/app/code/Magento/AdminNotification/Block/ToolbarEntry.php +++ b/app/code/Magento/AdminNotification/Block/ToolbarEntry.php @@ -10,7 +10,6 @@ * Toolbar entry that shows latest notifications * * @api - * @author Magento Core Team * @since 100.0.2 */ class ToolbarEntry extends \Magento\Backend\Block\Template diff --git a/app/code/Magento/AdminNotification/Model/Feed.php b/app/code/Magento/AdminNotification/Model/Feed.php index b99a8bbbc9031..ac1e631cc3f33 100644 --- a/app/code/Magento/AdminNotification/Model/Feed.php +++ b/app/code/Magento/AdminNotification/Model/Feed.php @@ -12,7 +12,6 @@ /** * AdminNotification Feed model * - * @author Magento Core Team * @SuppressWarnings(PHPMD.CouplingBetweenObjects) * @api * @since 100.0.2 diff --git a/app/code/Magento/AdminNotification/Model/InboxInterface.php b/app/code/Magento/AdminNotification/Model/InboxInterface.php index 4e87822763fc3..5e61c3dd680c9 100644 --- a/app/code/Magento/AdminNotification/Model/InboxInterface.php +++ b/app/code/Magento/AdminNotification/Model/InboxInterface.php @@ -8,7 +8,6 @@ /** * AdminNotification Inbox interface * - * @author Magento Core Team * @api * @since 100.0.2 */ diff --git a/app/code/Magento/AdminNotification/Model/NotificationService.php b/app/code/Magento/AdminNotification/Model/NotificationService.php index d44e98aaf2203..a13efe2136a6f 100644 --- a/app/code/Magento/AdminNotification/Model/NotificationService.php +++ b/app/code/Magento/AdminNotification/Model/NotificationService.php @@ -8,7 +8,6 @@ /** * Notification service model * - * @author Magento Core Team * @api * @since 100.0.2 */ diff --git a/app/code/Magento/AdminNotification/Model/ResourceModel/Grid/Collection.php b/app/code/Magento/AdminNotification/Model/ResourceModel/Grid/Collection.php index e12419155d52b..1a59d15e40c7a 100644 --- a/app/code/Magento/AdminNotification/Model/ResourceModel/Grid/Collection.php +++ b/app/code/Magento/AdminNotification/Model/ResourceModel/Grid/Collection.php @@ -6,8 +6,6 @@ /** * AdminNotification Inbox model - * - * @author Magento Core Team */ namespace Magento\AdminNotification\Model\ResourceModel\Grid; diff --git a/app/code/Magento/AdminNotification/Model/ResourceModel/Inbox/Collection.php b/app/code/Magento/AdminNotification/Model/ResourceModel/Inbox/Collection.php index 44ec765b9d0a2..bf4f91cc6ae80 100644 --- a/app/code/Magento/AdminNotification/Model/ResourceModel/Inbox/Collection.php +++ b/app/code/Magento/AdminNotification/Model/ResourceModel/Inbox/Collection.php @@ -9,8 +9,6 @@ * AdminNotification Inbox model * * @api - * @author Magento Core Team - * @api * @since 100.0.2 */ class Collection extends \Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection diff --git a/app/code/Magento/AdminNotification/Model/ResourceModel/Inbox/Collection/Unread.php b/app/code/Magento/AdminNotification/Model/ResourceModel/Inbox/Collection/Unread.php index b9e77f8a35295..9504c2f2d10f7 100644 --- a/app/code/Magento/AdminNotification/Model/ResourceModel/Inbox/Collection/Unread.php +++ b/app/code/Magento/AdminNotification/Model/ResourceModel/Inbox/Collection/Unread.php @@ -6,8 +6,6 @@ /** * Collection of unread notifications - * - * @author Magento Core Team */ namespace Magento\AdminNotification\Model\ResourceModel\Inbox\Collection; diff --git a/app/code/Magento/AdminNotification/Observer/PredispatchAdminActionControllerObserver.php b/app/code/Magento/AdminNotification/Observer/PredispatchAdminActionControllerObserver.php index 24ef712c0f61f..a244ad1fb9a0f 100644 --- a/app/code/Magento/AdminNotification/Observer/PredispatchAdminActionControllerObserver.php +++ b/app/code/Magento/AdminNotification/Observer/PredispatchAdminActionControllerObserver.php @@ -9,8 +9,7 @@ /** * AdminNotification observer - * - * @author Magento Core Team + * @SuppressWarnings(PHPMD.CookieAndSessionMisuse) */ class PredispatchAdminActionControllerObserver implements ObserverInterface { diff --git a/app/code/Magento/AdminNotification/Test/Mftf/Section/AdminSystemMessagesSection.xml b/app/code/Magento/AdminNotification/Test/Mftf/Section/AdminSystemMessagesSection.xml index e3b2ea7e24c83..53a73446a29d1 100644 --- a/app/code/Magento/AdminNotification/Test/Mftf/Section/AdminSystemMessagesSection.xml +++ b/app/code/Magento/AdminNotification/Test/Mftf/Section/AdminSystemMessagesSection.xml @@ -15,5 +15,7 @@ + + diff --git a/app/code/Magento/AdvancedPricingImportExport/Model/Export/AdvancedPricing.php b/app/code/Magento/AdvancedPricingImportExport/Model/Export/AdvancedPricing.php index 27e2713995653..af43562984134 100644 --- a/app/code/Magento/AdvancedPricingImportExport/Model/Export/AdvancedPricing.php +++ b/app/code/Magento/AdvancedPricingImportExport/Model/Export/AdvancedPricing.php @@ -158,6 +158,8 @@ public function __construct( protected function initTypeModels() { $productTypes = $this->_exportConfig->getEntityTypes(CatalogProduct::ENTITY); + $disabledAttrs = []; + $indexValueAttributes = []; foreach ($productTypes as $productTypeName => $productTypeConfig) { if (!($model = $this->_typeFactory->create($productTypeConfig['model']))) { throw new \Magento\Framework\Exception\LocalizedException( @@ -174,13 +176,8 @@ protected function initTypeModels() } if ($model->isSuitable()) { $this->_productTypeModels[$productTypeName] = $model; - // phpcs:ignore Magento2.Performance.ForeachArrayMerge - $this->_disabledAttrs = array_merge($this->_disabledAttrs, $model->getDisabledAttrs()); - // phpcs:ignore Magento2.Performance.ForeachArrayMerge - $this->_indexValueAttributes = array_merge( - $this->_indexValueAttributes, - $model->getIndexValueAttributes() - ); + $disabledAttrs[] = $model->getDisabledAttrs(); + $indexValueAttributes[] = $model->getIndexValueAttributes(); } } if (!$this->_productTypeModels) { @@ -188,7 +185,10 @@ protected function initTypeModels() __('There are no product types available for export') ); } - $this->_disabledAttrs = array_unique($this->_disabledAttrs); + $this->_disabledAttrs = array_unique(array_merge([], $this->_disabledAttrs, ...$disabledAttrs)); + $this->_indexValueAttributes = array_unique( + array_merge([], $this->_indexValueAttributes, ...$indexValueAttributes) + ); return $this; } @@ -518,6 +518,8 @@ protected function getTierPrices(array $listSku, $table) if (isset($this->_parameters[\Magento\ImportExport\Model\Export::FILTER_ELEMENT_GROUP])) { $exportFilter = $this->_parameters[\Magento\ImportExport\Model\Export::FILTER_ELEMENT_GROUP]; } + $selectFields = []; + $exportData = false; if ($table == ImportAdvancedPricing::TABLE_TIER_PRICE) { $selectFields = [ ImportAdvancedPricing::COL_SKU => 'cpe.sku', diff --git a/app/code/Magento/AdvancedSearch/Block/Adminhtml/Search/Edit.php b/app/code/Magento/AdvancedSearch/Block/Adminhtml/Search/Edit.php index 403a4d12cc17b..401e9d666103e 100644 --- a/app/code/Magento/AdvancedSearch/Block/Adminhtml/Search/Edit.php +++ b/app/code/Magento/AdvancedSearch/Block/Adminhtml/Search/Edit.php @@ -9,7 +9,6 @@ * Search queries relations grid container * * @api - * @author Magento Core Team * @since 100.0.2 */ class Edit extends \Magento\Backend\Block\Widget\Grid\Container diff --git a/app/code/Magento/AdvancedSearch/Block/Adminhtml/Search/Grid.php b/app/code/Magento/AdvancedSearch/Block/Adminhtml/Search/Grid.php index 6bdfd3b0dd143..add3e244be851 100644 --- a/app/code/Magento/AdvancedSearch/Block/Adminhtml/Search/Grid.php +++ b/app/code/Magento/AdvancedSearch/Block/Adminhtml/Search/Grid.php @@ -9,7 +9,6 @@ * Search query relations edit grid * * @api - * @author Magento Core Team * @since 100.0.2 */ class Grid extends \Magento\Backend\Block\Widget\Grid diff --git a/app/code/Magento/AdvancedSearch/Model/ResourceModel/Recommendations.php b/app/code/Magento/AdvancedSearch/Model/ResourceModel/Recommendations.php index c19c1d67d81f7..9be5d0c201841 100644 --- a/app/code/Magento/AdvancedSearch/Model/ResourceModel/Recommendations.php +++ b/app/code/Magento/AdvancedSearch/Model/ResourceModel/Recommendations.php @@ -8,7 +8,6 @@ /** * Catalog search recommendations resource model * - * @author Magento Core Team * @api * @since 100.0.2 */ diff --git a/app/code/Magento/Analytics/Model/ReportUrlProvider.php b/app/code/Magento/Analytics/Model/ReportUrlProvider.php index e7fdf6f9e8132..3c235f03ef929 100644 --- a/app/code/Magento/Analytics/Model/ReportUrlProvider.php +++ b/app/code/Magento/Analytics/Model/ReportUrlProvider.php @@ -47,6 +47,13 @@ class ReportUrlProvider */ private $urlReportConfigPath = 'analytics/url/report'; + /** + * Path to Advanced Reporting documentation URL. + * + * @var string + */ + private $urlReportDocConfigPath = 'analytics/url/documentation'; + /** * @param AnalyticsToken $analyticsToken * @param OTPRequest $otpRequest @@ -80,13 +87,15 @@ public function getUrl() )); } - $url = $this->config->getValue($this->urlReportConfigPath); if ($this->analyticsToken->isTokenExist()) { + $url = $this->config->getValue($this->urlReportConfigPath); $otp = $this->otpRequest->call(); if ($otp) { $query = http_build_query(['otp' => $otp], '', '&'); $url .= '?' . $query; } + } else { + $url = $this->config->getValue($this->urlReportDocConfigPath); } return $url; diff --git a/app/code/Magento/Analytics/Model/ReportWriter.php b/app/code/Magento/Analytics/Model/ReportWriter.php index d5bd36d068d20..c1df4e1af508b 100644 --- a/app/code/Magento/Analytics/Model/ReportWriter.php +++ b/app/code/Magento/Analytics/Model/ReportWriter.php @@ -103,14 +103,14 @@ public function write(WriteInterface $directory, $path) /** * Replace wrong symbols in row * + * Strip backslashes before double quotes so they will be properly escaped in the generated csv + * + * @see fputcsv() * @param array $row * @return array */ private function prepareRow(array $row): array { - $row = preg_replace('/(?resourceConnection = $resourceConnection; - $this->objectManager = $objectManager; + $this->resourceConfig = $resourceConfig; + $this->deploymentConfig = $deploymentConfig; + $this->connectionFactory = $connectionFactory; } /** * Creates one-time connection for export * - * @param string $connectionName + * @param string $resourceName * @return AdapterInterface */ - public function getConnection($connectionName) + public function getConnection($resourceName) { - $connection = $this->resourceConnection->getConnection($connectionName); - $connectionClassName = get_class($connection); - $configData = $connection->getConfig(); + $connectionName = $this->resourceConfig->getConnectionName($resourceName); + $configData = $this->deploymentConfig->get( + ConfigOptionsListConstants::CONFIG_PATH_DB_CONNECTIONS . '/' . $connectionName + ); $configData['use_buffered_query'] = false; unset($configData['persistent']); - return $this->objectManager->create( - $connectionClassName, - [ - 'config' => $configData - ] - ); + $connection = $this->connectionFactory->create($configData); + + return $connection; } } diff --git a/app/code/Magento/Analytics/Test/Mftf/ActionGroup/AssertAdminAdvancedReportingPageUrlActionGroup.xml b/app/code/Magento/Analytics/Test/Mftf/ActionGroup/AssertAdminAdvancedReportingPageUrlActionGroup.xml index 51d77228c8dcf..ac4fca843a36b 100644 --- a/app/code/Magento/Analytics/Test/Mftf/ActionGroup/AssertAdminAdvancedReportingPageUrlActionGroup.xml +++ b/app/code/Magento/Analytics/Test/Mftf/ActionGroup/AssertAdminAdvancedReportingPageUrlActionGroup.xml @@ -15,6 +15,6 @@ - + diff --git a/app/code/Magento/Analytics/Test/Unit/Model/LinkProviderTest.php b/app/code/Magento/Analytics/Test/Unit/Model/LinkProviderTest.php index e888c38c4e817..50081d6ae1f17 100644 --- a/app/code/Magento/Analytics/Test/Unit/Model/LinkProviderTest.php +++ b/app/code/Magento/Analytics/Test/Unit/Model/LinkProviderTest.php @@ -90,7 +90,7 @@ protected function setUp(): void public function testGet() { - $baseUrl = 'http://magento.local/pub/media/'; + $baseUrl = 'http://magento.local/media/'; $fileInfoPath = 'analytics/data.tgz'; $fileInitializationVector = 'er312esq23eqq'; $this->fileInfoManagerMock->expects($this->once()) diff --git a/app/code/Magento/Analytics/Test/Unit/Model/ReportUrlProviderTest.php b/app/code/Magento/Analytics/Test/Unit/Model/ReportUrlProviderTest.php index 60dcfde64f16d..fd13028b92d90 100644 --- a/app/code/Magento/Analytics/Test/Unit/Model/ReportUrlProviderTest.php +++ b/app/code/Magento/Analytics/Test/Unit/Model/ReportUrlProviderTest.php @@ -43,20 +43,10 @@ class ReportUrlProviderTest extends TestCase */ private $flagManagerMock; - /** - * @var ObjectManagerHelper - */ - private $objectManagerHelper; - /** * @var ReportUrlProvider */ - private $reportUrlProvider; - - /** - * @var string - */ - private $urlReportConfigPath = 'path/url/report'; + private $model; /** * @return void @@ -71,16 +61,15 @@ protected function setUp(): void $this->flagManagerMock = $this->createMock(FlagManager::class); - $this->objectManagerHelper = new ObjectManagerHelper($this); + $objectManagerHelper = new ObjectManagerHelper($this); - $this->reportUrlProvider = $this->objectManagerHelper->getObject( + $this->model = $objectManagerHelper->getObject( ReportUrlProvider::class, [ 'config' => $this->configMock, 'analyticsToken' => $this->analyticsTokenMock, 'otpRequest' => $this->otpRequestMock, 'flagManager' => $this->flagManagerMock, - 'urlReportConfigPath' => $this->urlReportConfigPath, ] ); } @@ -88,10 +77,12 @@ protected function setUp(): void /** * @param bool $isTokenExist * @param string|null $otp If null OTP was not received. + * @param string $configPath + * @return void * * @dataProvider getUrlDataProvider */ - public function testGetUrl($isTokenExist, $otp) + public function testGetUrl(bool $isTokenExist, ?string $otp, string $configPath): void { $reportUrl = 'https://example.com/report'; $url = ''; @@ -99,7 +90,7 @@ public function testGetUrl($isTokenExist, $otp) $this->configMock ->expects($this->once()) ->method('getValue') - ->with($this->urlReportConfigPath) + ->with($configPath) ->willReturn($reportUrl); $this->analyticsTokenMock ->expects($this->once()) @@ -114,18 +105,19 @@ public function testGetUrl($isTokenExist, $otp) if ($isTokenExist && $otp) { $url = $reportUrl . '?' . http_build_query(['otp' => $otp], '', '&'); } - $this->assertSame($url ?: $reportUrl, $this->reportUrlProvider->getUrl()); + + $this->assertSame($url ?: $reportUrl, $this->model->getUrl()); } /** * @return array */ - public function getUrlDataProvider() + public function getUrlDataProvider(): array { return [ - 'TokenDoesNotExist' => [false, null], - 'TokenExistAndOtpEmpty' => [true, null], - 'TokenExistAndOtpValid' => [true, '249e6b658877bde2a77bc4ab'], + 'TokenDoesNotExist' => [false, null, 'analytics/url/documentation'], + 'TokenExistAndOtpEmpty' => [true, null, 'analytics/url/report'], + 'TokenExistAndOtpValid' => [true, '249e6b658877bde2a77bc4ab', 'analytics/url/report'], ]; } @@ -140,6 +132,6 @@ public function testGetUrlWhenSubscriptionUpdateRunning() ->with(SubscriptionUpdateHandler::PREVIOUS_BASE_URL_FLAG_CODE) ->willReturn('http://store.com'); $this->expectException(SubscriptionUpdateException::class); - $this->reportUrlProvider->getUrl(); + $this->model->getUrl(); } } diff --git a/app/code/Magento/Analytics/Test/Unit/Model/ReportWriterTest.php b/app/code/Magento/Analytics/Test/Unit/Model/ReportWriterTest.php index 8fb135fb4d9ed..1cf69cd33db7b 100644 --- a/app/code/Magento/Analytics/Test/Unit/Model/ReportWriterTest.php +++ b/app/code/Magento/Analytics/Test/Unit/Model/ReportWriterTest.php @@ -103,7 +103,7 @@ protected function setUp(): void * @param array $expectedFileData * @return void * - * @dataProvider configDataProvider + * @dataProvider writeDataProvider */ public function testWrite(array $configData, array $fileData, array $expectedFileData): void { @@ -162,7 +162,7 @@ public function testWrite(array $configData, array $fileData, array $expectedFil * @param array $configData * @return void * - * @dataProvider configDataProvider + * @dataProvider writeErrorFileDataProvider */ public function testWriteErrorFile(array $configData): void { @@ -195,10 +195,75 @@ public function testWriteEmptyReports(): void /** * @return array */ - public function configDataProvider(): array + public function writeDataProvider(): array + { + $configData = [ + 'providers' => [ + [ + 'name' => $this->providerName, + 'class' => $this->providerClass, + 'parameters' => [ + 'name' => $this->reportName + ], + ] + ] + ]; + return [ + [ + 'configData' => $configData, + 'fileData' => [ + ['number' => 1, 'type' => 'Shoes\"" Usual\\\\"'] + ], + 'expectedFileData' => [ + ['number' => 1, 'type' => 'Shoes"" Usual"'] + ] + ], + [ + 'configData' => $configData, + 'fileData' => [ + ['number' => 1, 'type' => 'hello "World"'] + ], + 'expectedFileData' => [ + ['number' => 1, 'type' => 'hello "World"'] + ] + ], + [ + 'configData' => $configData, + 'fileData' => [ + ['number' => 1, 'type' => 'hello \"World\"'] + ], + 'expectedFileData' => [ + ['number' => 1, 'type' => 'hello "World"'] + ] + ], + [ + 'configData' => $configData, + 'fileData' => [ + ['number' => 1, 'type' => 'hello \\"World\\"'] + ], + 'expectedFileData' => [ + ['number' => 1, 'type' => 'hello "World"'] + ] + ], + [ + 'configData' => $configData, + 'fileData' => [ + ['number' => 1, 'type' => 'hello \\\"World\\\"'] + ], + 'expectedFileData' => [ + ['number' => 1, 'type' => 'hello "World"'] + ] + ], + ]; + } + + /** + * @return array + */ + public function writeErrorFileDataProvider(): array { return [ - 'reportProvider' => [ + [ 'configData' => [ 'providers' => [ [ @@ -210,12 +275,6 @@ public function configDataProvider(): array ] ] ], - 'fileData' => [ - ['number' => 1, 'type' => 'Shoes\"" Usual\\\\"'] - ], - 'expectedFileData' => [ - ['number' => 1, 'type' => 'Shoes\"\" Usual\\"'] - ] ], ]; } diff --git a/app/code/Magento/Analytics/Test/Unit/ReportXml/ConnectionFactoryTest.php b/app/code/Magento/Analytics/Test/Unit/ReportXml/ConnectionFactoryTest.php index 16caaa380067f..9be5bea8d7d05 100644 --- a/app/code/Magento/Analytics/Test/Unit/ReportXml/ConnectionFactoryTest.php +++ b/app/code/Magento/Analytics/Test/Unit/ReportXml/ConnectionFactoryTest.php @@ -8,40 +8,29 @@ namespace Magento\Analytics\Test\Unit\ReportXml; use Magento\Analytics\ReportXml\ConnectionFactory; -use Magento\Framework\App\ResourceConnection; +use Magento\Framework\App\DeploymentConfig; +use Magento\Framework\App\ResourceConnection\ConfigInterface as ResourceConfigInterface; use Magento\Framework\DB\Adapter\AdapterInterface; -use Magento\Framework\DB\Adapter\Pdo\Mysql as MysqlPdoAdapter; -use Magento\Framework\ObjectManagerInterface; -use Magento\Framework\TestFramework\Unit\Helper\ObjectManager as ObjectManagerHelper; +use Magento\Framework\Model\ResourceModel\Type\Db\ConnectionFactoryInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; class ConnectionFactoryTest extends TestCase { /** - * @var ResourceConnection|MockObject + * @var ResourceConfigInterface|MockObject */ - private $resourceConnectionMock; + private $resourceConfigMock; /** - * @var ObjectManagerInterface|MockObject + * @var DeploymentConfig|MockObject */ - private $objectManagerMock; + private $deploymentConfigMock; /** - * @var ConnectionFactory|MockObject + * @var ConnectionFactoryInterface|MockObject */ - private $connectionNewMock; - - /** - * @var AdapterInterface|MockObject - */ - private $connectionMock; - - /** - * @var ObjectManagerHelper - */ - private $objectManagerHelper; + private $connectionFactoryMock; /** * @var ConnectionFactory @@ -53,47 +42,36 @@ class ConnectionFactoryTest extends TestCase */ protected function setUp(): void { - $this->resourceConnectionMock = $this->createMock(ResourceConnection::class); - - $this->objectManagerMock = $this->getMockForAbstractClass(ObjectManagerInterface::class); - - $this->connectionMock = $this->createMock(MysqlPdoAdapter::class); - - $this->connectionNewMock = $this->createMock(MysqlPdoAdapter::class); - - $this->objectManagerHelper = new ObjectManagerHelper($this); - - $this->connectionFactory = $this->objectManagerHelper->getObject( - ConnectionFactory::class, - [ - 'resourceConnection' => $this->resourceConnectionMock, - 'objectManager' => $this->objectManagerMock, - ] + $this->resourceConfigMock = $this->createMock(ResourceConfigInterface::class); + $this->deploymentConfigMock = $this->createMock(DeploymentConfig::class); + $this->connectionFactoryMock = $this->createMock(ConnectionFactoryInterface::class); + + $this->connectionFactory = new ConnectionFactory( + $this->resourceConfigMock, + $this->deploymentConfigMock, + $this->connectionFactoryMock ); } public function testGetConnection() { - $connectionName = 'read'; - - $this->resourceConnectionMock - ->expects($this->once()) - ->method('getConnection') - ->with($connectionName) - ->willReturn($this->connectionMock); - - $this->connectionMock - ->expects($this->once()) - ->method('getConfig') - ->with() - ->willReturn(['persistent' => 1]); - - $this->objectManagerMock - ->expects($this->once()) + $resourceName = 'default'; + + $this->resourceConfigMock->expects($this->once()) + ->method('getConnectionName') + ->with($resourceName) + ->willReturn('default'); + $this->deploymentConfigMock->expects($this->once()) + ->method('get') + ->with('db/connection/default') + ->willReturn(['host' => 'localhost', 'port' => 3306, 'persistent' => true]); + $connectionMock = $this->createMock(AdapterInterface::class); + $this->connectionFactoryMock->expects($this->once()) ->method('create') - ->with(get_class($this->connectionMock), ['config' => ['use_buffered_query' => false]]) - ->willReturn($this->connectionNewMock); + ->with(['host' => 'localhost', 'port' => 3306, 'use_buffered_query' => false]) + ->willReturn($connectionMock); - $this->assertSame($this->connectionNewMock, $this->connectionFactory->getConnection($connectionName)); + $connection = $this->connectionFactory->getConnection($resourceName); + $this->assertSame($connectionMock, $connection); } } diff --git a/app/code/Magento/Analytics/etc/config.xml b/app/code/Magento/Analytics/etc/config.xml index b6194ba12993f..27d608ab46039 100644 --- a/app/code/Magento/Analytics/etc/config.xml +++ b/app/code/Magento/Analytics/etc/config.xml @@ -15,10 +15,12 @@ https://advancedreporting.rjmetrics.com/otp https://advancedreporting.rjmetrics.com/report https://advancedreporting.rjmetrics.com/report + https://docs.magento.com/user-guide/reports/advanced-reporting.html Magento Analytics user 02,00,00 + diff --git a/app/code/Magento/Analytics/etc/di.xml b/app/code/Magento/Analytics/etc/di.xml index 8d8ce4f83a605..0a57676b5fb8f 100644 --- a/app/code/Magento/Analytics/etc/di.xml +++ b/app/code/Magento/Analytics/etc/di.xml @@ -266,4 +266,9 @@ + + + Magento\Framework\Model\ResourceModel\Type\Db\ConnectionFactory + + diff --git a/app/code/Magento/AsynchronousOperations/Model/OperationProcessor.php b/app/code/Magento/AsynchronousOperations/Model/OperationProcessor.php index 5c5619a4b41d1..60b031c984e6a 100644 --- a/app/code/Magento/AsynchronousOperations/Model/OperationProcessor.php +++ b/app/code/Magento/AsynchronousOperations/Model/OperationProcessor.php @@ -117,6 +117,7 @@ public function process(string $encodedMessage) $status = OperationInterface::STATUS_TYPE_COMPLETE; $errorCode = null; $messages = []; + $entityParams = []; $topicName = $operation->getTopicName(); $handlers = $this->configuration->getHandlers($topicName); try { @@ -127,7 +128,7 @@ public function process(string $encodedMessage) $this->logger->error($e->getMessage()); $status = OperationInterface::STATUS_TYPE_NOT_RETRIABLY_FAILED; $errorCode = $e->getCode(); - $messages[] = $e->getMessage(); + $messages[] = [$e->getMessage()]; } $outputData = null; @@ -136,9 +137,7 @@ public function process(string $encodedMessage) $result = $this->executeHandler($callback, $entityParams); $status = $result['status']; $errorCode = $result['error_code']; - // phpcs:disable Magento2.Performance.ForeachArrayMerge - $messages = array_merge($messages, $result['messages']); - // phpcs:enable Magento2.Performance.ForeachArrayMerge + $messages[] = $result['messages']; $outputData = $result['output_data']; } } @@ -157,7 +156,7 @@ public function process(string $encodedMessage) ); $outputData = $this->jsonHelper->serialize($outputData); } catch (\Exception $e) { - $messages[] = $e->getMessage(); + $messages[] = [$e->getMessage()]; } } @@ -167,7 +166,7 @@ public function process(string $encodedMessage) $operation->getId(), $status, $errorCode, - implode('; ', $messages), + implode('; ', array_merge([], ...$messages)), $serializedData, $outputData ); diff --git a/app/code/Magento/AsynchronousOperations/Test/Mftf/Section/AdminBulkDetailsModalSection.xml b/app/code/Magento/AsynchronousOperations/Test/Mftf/Section/AdminBulkDetailsModalSection.xml new file mode 100644 index 0000000000000..0ef6a8981172b --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Test/Mftf/Section/AdminBulkDetailsModalSection.xml @@ -0,0 +1,16 @@ + + + + +
+ + + +
+
diff --git a/app/code/Magento/AsynchronousOperations/etc/db_schema.xml b/app/code/Magento/AsynchronousOperations/etc/db_schema.xml index 5d49d71ee46b0..ab482d2e2c761 100644 --- a/app/code/Magento/AsynchronousOperations/etc/db_schema.xml +++ b/app/code/Magento/AsynchronousOperations/etc/db_schema.xml @@ -34,7 +34,7 @@ - chosenUserContext === null) { + if (!$this->chosenUserContext) { /** @var UserContextInterface $userContext */ foreach ($this->userContexts as $userContext) { if ($userContext->getUserType() && $userContext->getUserId() !== null) { diff --git a/app/code/Magento/Authorization/Model/ResourceModel/Role.php b/app/code/Magento/Authorization/Model/ResourceModel/Role.php index 48fe65e7f8b92..d23d039b2433d 100644 --- a/app/code/Magento/Authorization/Model/ResourceModel/Role.php +++ b/app/code/Magento/Authorization/Model/ResourceModel/Role.php @@ -119,6 +119,8 @@ protected function _afterDelete(\Magento\Framework\Model\AbstractModel $role) $connection->delete($this->_ruleTable, ['role_id = ?' => (int)$role->getId()]); + $this->_cache->clean(\Zend_Cache::CLEANING_MODE_MATCHING_TAG, [\Magento\Backend\Block\Menu::CACHE_TAGS]); + return $this; } diff --git a/app/code/Magento/Authorization/Model/Role.php b/app/code/Magento/Authorization/Model/Role.php index fc32fbcaa2e98..96cf956afd1bc 100644 --- a/app/code/Magento/Authorization/Model/Role.php +++ b/app/code/Magento/Authorization/Model/Role.php @@ -33,6 +33,11 @@ class Role extends \Magento\Framework\Model\AbstractModel */ protected $_eventPrefix = 'authorization_roles'; + /** + * @var string + */ + protected $_cacheTag = 'user_assigned_role'; + /** * @param \Magento\Framework\Model\Context $context * @param \Magento\Framework\Registry $registry diff --git a/app/code/Magento/AwsS3/Driver/AwsS3.php b/app/code/Magento/AwsS3/Driver/AwsS3.php new file mode 100644 index 0000000000000..dcf52b3188404 --- /dev/null +++ b/app/code/Magento/AwsS3/Driver/AwsS3.php @@ -0,0 +1,880 @@ + 'private']; + + /** + * @var AdapterInterface + */ + private $adapter; + + /** + * @var LoggerInterface + */ + private $logger; + + /** + * @var array + */ + private $streams = []; + + /** + * @var string + */ + private $objectUrl; + + /** + * @param AdapterInterface $adapter + * @param LoggerInterface $logger + * @param string $objectUrl + */ + public function __construct( + AdapterInterface $adapter, + LoggerInterface $logger, + string $objectUrl + ) { + $this->adapter = $adapter; + $this->logger = $logger; + $this->objectUrl = $objectUrl; + } + + /** + * Destroy opened streams. + */ + public function __destruct() + { + try { + foreach ($this->streams as $stream) { + $this->fileClose($stream); + } + } catch (\Exception $e) { + // log exception as throwing an exception from a destructor causes a fatal error + $this->logger->critical($e); + } + } + + /** + * @inheritDoc + */ + public function test(): void + { + try { + $this->adapter->write(self::TEST_FLAG, '', new Config(self::CONFIG)); + } catch (Exception $exception) { + throw new DriverException(__($exception->getMessage()), $exception); + } + } + + /** + * @inheritDoc + */ + public function fileGetContents($path, $flag = null, $context = null): string + { + $path = $this->normalizeRelativePath($path); + + if (isset($this->streams[$path])) { + //phpcs:disable + return file_get_contents(stream_get_meta_data($this->streams[$path])['uri']); + //phpcs:enable + } + + return $this->adapter->read($path)['contents'] ?? ''; + } + + /** + * @inheritDoc + */ + public function isExists($path): bool + { + if ($path === '/') { + return true; + } + + $path = $this->normalizeRelativePath($path); + + if (!$path || $path === '/') { + return true; + } + + return $this->adapter->has($path); + } + + /** + * @inheritDoc + */ + public function isWritable($path): bool + { + return true; + } + + /** + * @inheritDoc + */ + public function createDirectory($path, $permissions = 0777): bool + { + if ($path === '/') { + return true; + } + + return $this->createDirectoryRecursively( + $this->normalizeRelativePath($path) + ); + } + + /** + * Created directory recursively. + * + * @param string $path + * @return bool + * @throws FileSystemException + */ + private function createDirectoryRecursively(string $path): bool + { + //phpcs:ignore Magento2.Functions.DiscouragedFunction + $parentDir = dirname($path); + + while (!$this->isDirectory($parentDir)) { + $this->createDirectoryRecursively($parentDir); + } + + return (bool)$this->adapter->createDir(rtrim($path, '/'), new Config(self::CONFIG)); + } + + /** + * @inheritDoc + */ + public function copy($source, $destination, DriverInterface $targetDriver = null): bool + { + return $this->adapter->copy( + $this->normalizeRelativePath($source), + $this->normalizeRelativePath($destination) + ); + } + + /** + * @inheritDoc + */ + public function deleteFile($path): bool + { + return $this->adapter->delete( + $this->normalizeRelativePath($path) + ); + } + + /** + * @inheritDoc + */ + public function deleteDirectory($path): bool + { + return $this->adapter->deleteDir( + $this->normalizeRelativePath($path) + ); + } + + /** + * @inheritDoc + */ + public function filePutContents($path, $content, $mode = null): int + { + $path = $this->normalizeRelativePath($path); + $config = self::CONFIG; + + if (false !== ($imageSize = @getimagesizefromstring($content))) { + $config['Metadata'] = [ + 'image-width' => $imageSize[0], + 'image-height' => $imageSize[1] + ]; + } + + return $this->adapter->write($path, $content, new Config($config))['size']; + } + + /** + * @inheritDoc + */ + public function readDirectoryRecursively($path = null): array + { + return $this->readPath($path, true); + } + + /** + * @inheritDoc + */ + public function readDirectory($path): array + { + return $this->readPath($path, false); + } + + /** + * @inheritDoc + */ + public function getRealPathSafety($path) + { + if (strpos($path, '/.') === false) { + return $path; + } + + $isAbsolute = strpos($path, $this->normalizeAbsolutePath()) === 0; + $path = $this->normalizeRelativePath($path); + + //Removing redundant directory separators. + $path = preg_replace( + '/\\/\\/+/', + '/', + $path + ); + $pathParts = explode('/', $path); + if (end($pathParts) === '.') { + $pathParts[count($pathParts) - 1] = ''; + } + $realPath = []; + foreach ($pathParts as $pathPart) { + if ($pathPart === '.') { + continue; + } + if ($pathPart === '..') { + array_pop($realPath); + continue; + } + $realPath[] = $pathPart; + } + + if ($isAbsolute) { + return $this->normalizeAbsolutePath(implode('/', $realPath)); + } + + return implode('/', $realPath); + } + + /** + * @inheritDoc + */ + public function getAbsolutePath($basePath, $path, $scheme = null) + { + $basePath = $this->normalizeRelativePath((string)$basePath); + $path = $this->normalizeRelativePath((string)$path); + if ($basePath && $path && 0 === strpos($path, $basePath)) { + return $this->normalizeAbsolutePath($path); + } + + if ($basePath && $basePath !== '/') { + $path = $basePath . ltrim((string)$path, '/'); + } + + return $this->normalizeAbsolutePath($path); + } + + /** + * Resolves absolute path. + * + * @param string $path Relative path + * @return string Absolute path + */ + private function normalizeAbsolutePath(string $path = '/'): string + { + $path = ltrim($path, '/'); + $path = str_replace($this->getObjectUrl(''), '', $path); + + if (!$path) { + $path = '/'; + } + + return $this->getObjectUrl($path); + } + + /** + * Retrieves object URL from cache. + * + * @param string $path + * @return string + */ + private function getObjectUrl(string $path): string + { + return $this->objectUrl . ltrim($path, '/'); + } + + /** + * Resolves relative path. + * + * @param string $path Absolute path + * @return string Relative path + */ + private function normalizeRelativePath(string $path): string + { + return str_replace($this->normalizeAbsolutePath(), '', $path); + } + + /** + * @inheritDoc + */ + public function isReadable($path): bool + { + return $this->isExists($path); + } + + /** + * @inheritDoc + */ + public function isFile($path): bool + { + if (!$path || $path === '/') { + return false; + } + + $path = $this->normalizeRelativePath($path); + $path = rtrim($path, '/'); + + if ($this->adapter->has($path) && ($meta = $this->adapter->getMetadata($path))) { + return ($meta['type'] ?? null) === self::TYPE_FILE; + } + + return false; + } + + /** + * @inheritDoc + */ + public function isDirectory($path): bool + { + if (in_array($path, ['.', '/'], true)) { + return true; + } + + $path = $this->normalizeRelativePath($path); + + if (!$path || $path === '/') { + return true; + } + + $path = rtrim($path, '/') . '/'; + + if ($this->adapter->has($path) && ($meta = $this->adapter->getMetadata($path))) { + return ($meta['type'] ?? null) === self::TYPE_DIR; + } + + return false; + } + + /** + * @inheritDoc + */ + public function getRelativePath($basePath, $path = null): string + { + $basePath = $this->normalizeAbsolutePath($basePath); + $absolutePath = $this->normalizeAbsolutePath((string)$path); + + if ($basePath === $absolutePath . '/' || strpos($absolutePath, $basePath) === 0) { + return ltrim(substr($absolutePath, strlen($basePath)), '/'); + } + + return ltrim($path, '/'); + } + + /** + * @inheritDoc + */ + public function getParentDirectory($path): string + { + //phpcs:ignore Magento2.Functions.DiscouragedFunction + return rtrim(dirname($this->normalizeAbsolutePath($path)), '/') . '/'; + } + + /** + * @inheritDoc + */ + public function getRealPath($path) + { + return $this->normalizeAbsolutePath($path); + } + + /** + * @inheritDoc + */ + public function rename($oldPath, $newPath, DriverInterface $targetDriver = null): bool + { + return $this->adapter->rename( + $this->normalizeRelativePath($oldPath), + $this->normalizeRelativePath($newPath) + ); + } + + /** + * @inheritDoc + */ + public function stat($path): array + { + $path = $this->normalizeRelativePath($path); + $metaInfo = $this->adapter->getMetadata($path); + + if (!$metaInfo) { + throw new FileSystemException(__('Cannot gather stats! %1', [$this->getWarningMessage()])); + } + + return [ + 'dev' => 0, + 'ino' => 0, + 'mode' => 0, + 'nlink' => 0, + 'uid' => 0, + 'gid' => 0, + 'rdev' => 0, + 'atime' => 0, + 'ctime' => 0, + 'blksize' => 0, + 'blocks' => 0, + 'size' => $metaInfo['size'] ?? 0, + 'type' => $metaInfo['type'] ?? '', + 'mtime' => $metaInfo['timestamp'] ?? 0, + 'disposition' => null + ]; + } + + /** + * @inheritDoc + */ + public function getMetadata(string $path): array + { + $path = $this->normalizeRelativePath($path); + $metaInfo = $this->adapter->getMetadata($path); + + if (!$metaInfo) { + throw new FileSystemException(__('Cannot gather meta info! %1', [$this->getWarningMessage()])); + } + + return [ + 'path' => $metaInfo['path'], + 'dirname' => $metaInfo['dirname'], + 'basename' => $metaInfo['basename'], + 'extension' => $metaInfo['extension'], + 'filename' => $metaInfo['filename'], + 'timestamp' => $metaInfo['timestamp'], + 'size' => $metaInfo['size'], + 'mimetype' => $metaInfo['mimetype'], + 'extra' => [ + 'image-width' => $metaInfo['metadata']['image-width'] ?? 0, + 'image-height' => $metaInfo['metadata']['image-height'] ?? 0 + ] + ]; + } + + /** + * @inheritDoc + */ + public function search($pattern, $path): array + { + return iterator_to_array( + $this->glob(rtrim($path, '/') . '/' . ltrim($pattern, '/')), + false + ); + } + + /** + * Emulate php glob function for AWS S3 storage + * + * @param string $pattern + * @return Generator + * @throws FileSystemException + */ + private function glob(string $pattern): Generator + { + $patternFound = preg_match('(\*|\?|\[.+\])', $pattern, $parentPattern, PREG_OFFSET_CAPTURE); + + if ($patternFound) { + // phpcs:ignore Magento2.Functions.DiscouragedFunction + $parentDirectory = \dirname(substr($pattern, 0, $parentPattern[0][1] + 1)); + $leftover = substr($pattern, $parentPattern[0][1]); + $index = strpos($leftover, '/'); + $searchPattern = $this->getSearchPattern($pattern, $parentPattern, $parentDirectory, $index); + + if ($this->isDirectory($parentDirectory . '/')) { + yield from $this->getDirectoryContent($parentDirectory, $searchPattern, $leftover, $index); + } + } elseif ($this->isDirectory($pattern) || $this->isFile($pattern)) { + yield $pattern; + } + } + + /** + * @inheritDoc + */ + public function symlink($source, $destination, DriverInterface $targetDriver = null): bool + { + return $this->copy($source, $destination, $targetDriver); + } + + /** + * @inheritDoc + */ + public function changePermissions($path, $permissions): bool + { + return true; + } + + /** + * @inheritDoc + */ + public function changePermissionsRecursively($path, $dirPermissions, $filePermissions): bool + { + return true; + } + + /** + * @inheritDoc + */ + public function touch($path, $modificationTime = null) + { + $path = $this->normalizeRelativePath($path); + + $content = $this->adapter->has($path) ? + $this->adapter->read($path)['contents'] + : ''; + + return (bool)$this->adapter->write($path, $content, new Config([])); + } + + /** + * @inheritDoc + */ + public function fileReadLine($resource, $length, $ending = null): string + { + // phpcs:disable + $result = @stream_get_line($resource, $length, $ending); + // phpcs:enable + if (false === $result) { + throw new FileSystemException( + new Phrase('File cannot be read %1', [$this->getWarningMessage()]) + ); + } + + return $result; + } + + /** + * @inheritDoc + */ + public function fileRead($resource, $length): string + { + //phpcs:ignore Magento2.Functions.DiscouragedFunction + $result = fread($resource, $length); + if ($result === false) { + throw new FileSystemException(__('File cannot be read %1', [$this->getWarningMessage()])); + } + + return $result; + } + + /** + * @inheritDoc + */ + public function fileGetCsv($resource, $length = 0, $delimiter = ',', $enclosure = '"', $escape = '\\') + { + //phpcs:ignore Magento2.Functions.DiscouragedFunction + $result = fgetcsv($resource, $length, $delimiter, $enclosure, $escape); + if ($result === null) { + throw new FileSystemException( + new Phrase( + 'The "%1" CSV handle is incorrect. Verify the handle and try again.', + [$this->getWarningMessage()] + ) + ); + } + return $result; + } + + /** + * @inheritDoc + */ + public function fileTell($resource): int + { + $result = @ftell($resource); + if ($result === null) { + throw new FileSystemException( + new Phrase('An error occurred during "%1" execution.', [$this->getWarningMessage()]) + ); + } + return $result; + } + + /** + * @inheritDoc + */ + public function fileSeek($resource, $offset, $whence = SEEK_SET): int + { + $result = @fseek($resource, $offset, $whence); + if ($result === -1) { + throw new FileSystemException( + new Phrase( + 'An error occurred during "%1" fileSeek execution.', + [$this->getWarningMessage()] + ) + ); + } + return $result; + } + + /** + * @inheritDoc + */ + public function endOfFile($resource): bool + { + return feof($resource); + } + + /** + * @inheritDoc + */ + public function filePutCsv($resource, array $data, $delimiter = ',', $enclosure = '"') + { + //phpcs:ignore Magento2.Functions.DiscouragedFunction + return fputcsv($resource, $data, $delimiter, $enclosure); + } + + /** + * @inheritDoc + */ + public function fileFlush($resource): bool + { + $result = @fflush($resource); + if (!$result) { + throw new FileSystemException( + new Phrase( + 'An error occurred during "%1" fileFlush execution.', + [$this->getWarningMessage()] + ) + ); + } + return $result; + } + + /** + * @inheritDoc + */ + public function fileLock($resource, $lockMode = LOCK_EX): bool + { + $result = @flock($resource, $lockMode); + if (!$result) { + throw new FileSystemException( + new Phrase( + 'An error occurred during "%1" fileLock execution.', + [$this->getWarningMessage()] + ) + ); + } + return $result; + } + + /** + * @inheritDoc + */ + public function fileUnlock($resource): bool + { + $result = @flock($resource, LOCK_UN); + if (!$result) { + throw new FileSystemException( + new Phrase( + 'An error occurred during "%1" fileUnlock execution.', + [$this->getWarningMessage()] + ) + ); + } + return $result; + } + + /** + * @inheritDoc + */ + public function fileWrite($resource, $data) + { + //phpcs:disable + $resourcePath = stream_get_meta_data($resource)['uri']; + //phpcs:enable + + foreach ($this->streams as $stream) { + //phpcs:disable + if (stream_get_meta_data($stream)['uri'] === $resourcePath) { + return fwrite($stream, $data); + } + //phpcs:enable + } + + return false; + } + + /** + * @inheritDoc + */ + public function fileClose($resource): bool + { + //phpcs:disable + $resourcePath = stream_get_meta_data($resource)['uri']; + //phpcs:enable + + foreach ($this->streams as $path => $stream) { + //phpcs:disable + if (stream_get_meta_data($stream)['uri'] === $resourcePath) { + $this->adapter->writeStream($path, $resource, new Config(self::CONFIG)); + + // Remove path from streams after + unset($this->streams[$path]); + + return fclose($stream); + } + } + + return false; + } + + /** + * @inheritDoc + */ + public function fileOpen($path, $mode) + { + $path = $this->normalizeRelativePath($path); + + if (!isset($this->streams[$path])) { + $this->streams[$path] = tmpfile(); + if ($this->adapter->has($path)) { + //phpcs:ignore Magento2.Functions.DiscouragedFunction + fwrite($this->streams[$path], $this->adapter->read($path)['contents']); + //phpcs:ignore Magento2.Functions.DiscouragedFunction + rewind($this->streams[$path]); + } + } + + return $this->streams[$path]; + } + + /** + * Returns last warning message string + * + * @return string|null + */ + private function getWarningMessage(): ?string + { + $warning = error_get_last(); + if ($warning && $warning['type'] === E_WARNING) { + return 'Warning!' . $warning['message']; + } + + return null; + } + + /** + * Read directory by path and is recursive flag + * + * @param string $path + * @param bool $isRecursive + * @return array + */ + private function readPath(string $path, $isRecursive = false): array + { + $relativePath = $this->normalizeRelativePath($path); + $contentsList = $this->adapter->listContents( + $relativePath, + $isRecursive + ); + $itemsList = []; + foreach ($contentsList as $item) { + if (isset($item['path']) + && $item['path'] !== $relativePath + && strpos($item['path'], $relativePath) === 0) { + $itemsList[] = $item['path']; + } + } + + return $itemsList; + } + + /** + * Get search pattern for directory + * + * @param string $pattern + * @param array $parentPattern + * @param string $parentDirectory + * @param int|bool $index + * @return string + */ + private function getSearchPattern(string $pattern, array $parentPattern, string $parentDirectory, $index): string + { + $parentLength = \strlen($parentDirectory); + if ($index !== false) { + $searchPattern = substr( + $pattern, + $parentLength + 1, + $parentPattern[0][1] - $parentLength + $index - 1 + ); + } else { + $searchPattern = substr($pattern, $parentLength + 1); + } + + $replacement = [ + '/\*/' => '.*', + '/\?/' => '.', + '/\//' => '\/' + ]; + + return preg_replace(array_keys($replacement), array_values($replacement), $searchPattern); + } + + /** + * Get directory content by given search pattern + * + * @param string $parentDirectory + * @param string $searchPattern + * @param string $leftover + * @param int|bool $index + * @return Generator + * @throws FileSystemException + */ + private function getDirectoryContent( + string $parentDirectory, + string $searchPattern, + string $leftover, + $index + ): Generator { + $items = $this->readDirectory($parentDirectory . '/'); + $directoryContent = []; + foreach ($items as $item) { + if (preg_match('/' . $searchPattern . '$/', $item) + // phpcs:ignore Magento2.Functions.DiscouragedFunction + && strpos(basename($item), '.') !== 0) { + if ($index === false || \strlen($leftover) === $index + 1) { + yield $this->isDirectory($item) ? rtrim($item, '/') . '/' : $item; + } elseif (strlen($leftover) > $index + 1) { + yield from $this->glob("{$parentDirectory}/{$item}" . substr($leftover, $index)); + } + } + } + + return $directoryContent; + } +} diff --git a/app/code/Magento/AwsS3/Driver/AwsS3Factory.php b/app/code/Magento/AwsS3/Driver/AwsS3Factory.php new file mode 100644 index 0000000000000..87efd7c13f398 --- /dev/null +++ b/app/code/Magento/AwsS3/Driver/AwsS3Factory.php @@ -0,0 +1,61 @@ +objectManager = $objectManager; + } + + /** + * @inheritDoc + */ + public function create(array $config, string $prefix): RemoteDriverInterface + { + $config['version'] = 'latest'; + + if (empty($config['credentials']['key']) || empty($config['credentials']['secret'])) { + unset($config['credentials']); + } + + if (empty($config['bucket']) || empty($config['region'])) { + throw new DriverException(__('Bucket and region are required values')); + } + + $client = new S3Client($config); + $adapter = new AwsS3Adapter($client, $config['bucket'], $prefix); + + return $this->objectManager->create( + AwsS3::class, + [ + 'adapter' => $adapter, + 'objectUrl' => $client->getObjectUrl($adapter->getBucket(), $adapter->applyPathPrefix('.')) + ] + ); + } +} diff --git a/app/code/Magento/AwsS3/LICENSE.txt b/app/code/Magento/AwsS3/LICENSE.txt new file mode 100644 index 0000000000000..49525fd99da9c --- /dev/null +++ b/app/code/Magento/AwsS3/LICENSE.txt @@ -0,0 +1,48 @@ + +Open Software License ("OSL") v. 3.0 + +This Open Software License (the "License") applies to any original work of authorship (the "Original Work") whose owner (the "Licensor") has placed the following licensing notice adjacent to the copyright notice for the Original Work: + +Licensed under the Open Software License version 3.0 + + 1. Grant of Copyright License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, for the duration of the copyright, to do the following: + + 1. to reproduce the Original Work in copies, either alone or as part of a collective work; + + 2. to translate, adapt, alter, transform, modify, or arrange the Original Work, thereby creating derivative works ("Derivative Works") based upon the Original Work; + + 3. to distribute or communicate copies of the Original Work and Derivative Works to the public, with the proviso that copies of Original Work or Derivative Works that You distribute or communicate shall be licensed under this Open Software License; + + 4. to perform the Original Work publicly; and + + 5. to display the Original Work publicly. + + 2. Grant of Patent License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, under patent claims owned or controlled by the Licensor that are embodied in the Original Work as furnished by the Licensor, for the duration of the patents, to make, use, sell, offer for sale, have made, and import the Original Work and Derivative Works. + + 3. Grant of Source Code License. The term "Source Code" means the preferred form of the Original Work for making modifications to it and all available documentation describing how to modify the Original Work. Licensor agrees to provide a machine-readable copy of the Source Code of the Original Work along with each copy of the Original Work that Licensor distributes. Licensor reserves the right to satisfy this obligation by placing a machine-readable copy of the Source Code in an information repository reasonably calculated to permit inexpensive and convenient access by You for as long as Licensor continues to distribute the Original Work. + + 4. Exclusions From License Grant. Neither the names of Licensor, nor the names of any contributors to the Original Work, nor any of their trademarks or service marks, may be used to endorse or promote products derived from this Original Work without express prior permission of the Licensor. Except as expressly stated herein, nothing in this License grants any license to Licensor's trademarks, copyrights, patents, trade secrets or any other intellectual property. No patent license is granted to make, use, sell, offer for sale, have made, or import embodiments of any patent claims other than the licensed claims defined in Section 2. No license is granted to the trademarks of Licensor even if such marks are included in the Original Work. Nothing in this License shall be interpreted to prohibit Licensor from licensing under terms different from this License any Original Work that Licensor otherwise would have a right to license. + + 5. External Deployment. The term "External Deployment" means the use, distribution, or communication of the Original Work or Derivative Works in any way such that the Original Work or Derivative Works may be used by anyone other than You, whether those works are distributed or communicated to those persons or made available as an application intended for use over a network. As an express condition for the grants of license hereunder, You must treat any External Deployment by You of the Original Work or a Derivative Work as a distribution under section 1(c). + + 6. Attribution Rights. You must retain, in the Source Code of any Derivative Works that You create, all copyright, patent, or trademark notices from the Source Code of the Original Work, as well as any notices of licensing and any descriptive text identified therein as an "Attribution Notice." You must cause the Source Code for any Derivative Works that You create to carry a prominent Attribution Notice reasonably calculated to inform recipients that You have modified the Original Work. + + 7. Warranty of Provenance and Disclaimer of Warranty. Licensor warrants that the copyright in and to the Original Work and the patent rights granted herein by Licensor are owned by the Licensor or are sublicensed to You under the terms of this License with the permission of the contributor(s) of those copyrights and patent rights. Except as expressly stated in the immediately preceding sentence, the Original Work is provided under this License on an "AS IS" BASIS and WITHOUT WARRANTY, either express or implied, including, without limitation, the warranties of non-infringement, merchantability or fitness for a particular purpose. THE ENTIRE RISK AS TO THE QUALITY OF THE ORIGINAL WORK IS WITH YOU. This DISCLAIMER OF WARRANTY constitutes an essential part of this License. No license to the Original Work is granted by this License except under this disclaimer. + + 8. Limitation of Liability. Under no circumstances and under no legal theory, whether in tort (including negligence), contract, or otherwise, shall the Licensor be liable to anyone for any indirect, special, incidental, or consequential damages of any character arising as a result of this License or the use of the Original Work including, without limitation, damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses. This limitation of liability shall not apply to the extent applicable law prohibits such limitation. + + 9. Acceptance and Termination. If, at any time, You expressly assented to this License, that assent indicates your clear and irrevocable acceptance of this License and all of its terms and conditions. If You distribute or communicate copies of the Original Work or a Derivative Work, You must make a reasonable effort under the circumstances to obtain the express assent of recipients to the terms of this License. This License conditions your rights to undertake the activities listed in Section 1, including your right to create Derivative Works based upon the Original Work, and doing so without honoring these terms and conditions is prohibited by copyright law and international treaty. Nothing in this License is intended to affect copyright exceptions and limitations (including 'fair use' or 'fair dealing'). This License shall terminate immediately and You may no longer exercise any of the rights granted to You by this License upon your failure to honor the conditions in Section 1(c). + + 10. Termination for Patent Action. This License shall terminate automatically and You may no longer exercise any of the rights granted to You by this License as of the date You commence an action, including a cross-claim or counterclaim, against Licensor or any licensee alleging that the Original Work infringes a patent. This termination provision shall not apply for an action alleging patent infringement by combinations of the Original Work with other software or hardware. + + 11. Jurisdiction, Venue and Governing Law. Any action or suit relating to this License may be brought only in the courts of a jurisdiction wherein the Licensor resides or in which Licensor conducts its primary business, and under the laws of that jurisdiction excluding its conflict-of-law provisions. The application of the United Nations Convention on Contracts for the International Sale of Goods is expressly excluded. Any use of the Original Work outside the scope of this License or after its termination shall be subject to the requirements and penalties of copyright or patent law in the appropriate jurisdiction. This section shall survive the termination of this License. + + 12. Attorneys' Fees. In any action to enforce the terms of this License or seeking damages relating thereto, the prevailing party shall be entitled to recover its costs and expenses, including, without limitation, reasonable attorneys' fees and costs incurred in connection with such action, including any appeal of such action. This section shall survive the termination of this License. + + 13. Miscellaneous. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. + + 14. Definition of "You" in This License. "You" throughout this License, whether in upper or lower case, means an individual or a legal entity exercising rights under, and complying with all of the terms of, this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with you. For purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + + 15. Right to Use. You may use the Original Work in all ways not otherwise restricted or conditioned by this License or by law, and Licensor promises not to interfere with or be responsible for such uses by You. + + 16. Modification of This License. This License is Copyright (C) 2005 Lawrence Rosen. Permission is granted to copy, distribute, or communicate this License without modification. Nothing in this License permits You to modify this License as applied to the Original Work or to Derivative Works. However, You may modify the text of this License and copy, distribute or communicate your modified version (the "Modified License") and apply it to other original works of authorship subject to the following conditions: (i) You may not indicate in any way that your Modified License is the "Open Software License" or "OSL" and you may not use those names in the name of your Modified License; (ii) You must replace the notice specified in the first paragraph above with the notice "Licensed under " or with a notice of your own that is not confusingly similar to the notice in this License; and (iii) You may not claim that your original works are open source software unless your Modified License has been approved by Open Source Initiative (OSI) and You comply with its license review and certification process. \ No newline at end of file diff --git a/app/code/Magento/AwsS3/LICENSE_AFL.txt b/app/code/Magento/AwsS3/LICENSE_AFL.txt new file mode 100644 index 0000000000000..f39d641b18a19 --- /dev/null +++ b/app/code/Magento/AwsS3/LICENSE_AFL.txt @@ -0,0 +1,48 @@ + +Academic Free License ("AFL") v. 3.0 + +This Academic Free License (the "License") applies to any original work of authorship (the "Original Work") whose owner (the "Licensor") has placed the following licensing notice adjacent to the copyright notice for the Original Work: + +Licensed under the Academic Free License version 3.0 + + 1. Grant of Copyright License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, for the duration of the copyright, to do the following: + + 1. to reproduce the Original Work in copies, either alone or as part of a collective work; + + 2. to translate, adapt, alter, transform, modify, or arrange the Original Work, thereby creating derivative works ("Derivative Works") based upon the Original Work; + + 3. to distribute or communicate copies of the Original Work and Derivative Works to the public, under any license of your choice that does not contradict the terms and conditions, including Licensor's reserved rights and remedies, in this Academic Free License; + + 4. to perform the Original Work publicly; and + + 5. to display the Original Work publicly. + + 2. Grant of Patent License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, under patent claims owned or controlled by the Licensor that are embodied in the Original Work as furnished by the Licensor, for the duration of the patents, to make, use, sell, offer for sale, have made, and import the Original Work and Derivative Works. + + 3. Grant of Source Code License. The term "Source Code" means the preferred form of the Original Work for making modifications to it and all available documentation describing how to modify the Original Work. Licensor agrees to provide a machine-readable copy of the Source Code of the Original Work along with each copy of the Original Work that Licensor distributes. Licensor reserves the right to satisfy this obligation by placing a machine-readable copy of the Source Code in an information repository reasonably calculated to permit inexpensive and convenient access by You for as long as Licensor continues to distribute the Original Work. + + 4. Exclusions From License Grant. Neither the names of Licensor, nor the names of any contributors to the Original Work, nor any of their trademarks or service marks, may be used to endorse or promote products derived from this Original Work without express prior permission of the Licensor. Except as expressly stated herein, nothing in this License grants any license to Licensor's trademarks, copyrights, patents, trade secrets or any other intellectual property. No patent license is granted to make, use, sell, offer for sale, have made, or import embodiments of any patent claims other than the licensed claims defined in Section 2. No license is granted to the trademarks of Licensor even if such marks are included in the Original Work. Nothing in this License shall be interpreted to prohibit Licensor from licensing under terms different from this License any Original Work that Licensor otherwise would have a right to license. + + 5. External Deployment. The term "External Deployment" means the use, distribution, or communication of the Original Work or Derivative Works in any way such that the Original Work or Derivative Works may be used by anyone other than You, whether those works are distributed or communicated to those persons or made available as an application intended for use over a network. As an express condition for the grants of license hereunder, You must treat any External Deployment by You of the Original Work or a Derivative Work as a distribution under section 1(c). + + 6. Attribution Rights. You must retain, in the Source Code of any Derivative Works that You create, all copyright, patent, or trademark notices from the Source Code of the Original Work, as well as any notices of licensing and any descriptive text identified therein as an "Attribution Notice." You must cause the Source Code for any Derivative Works that You create to carry a prominent Attribution Notice reasonably calculated to inform recipients that You have modified the Original Work. + + 7. Warranty of Provenance and Disclaimer of Warranty. Licensor warrants that the copyright in and to the Original Work and the patent rights granted herein by Licensor are owned by the Licensor or are sublicensed to You under the terms of this License with the permission of the contributor(s) of those copyrights and patent rights. Except as expressly stated in the immediately preceding sentence, the Original Work is provided under this License on an "AS IS" BASIS and WITHOUT WARRANTY, either express or implied, including, without limitation, the warranties of non-infringement, merchantability or fitness for a particular purpose. THE ENTIRE RISK AS TO THE QUALITY OF THE ORIGINAL WORK IS WITH YOU. This DISCLAIMER OF WARRANTY constitutes an essential part of this License. No license to the Original Work is granted by this License except under this disclaimer. + + 8. Limitation of Liability. Under no circumstances and under no legal theory, whether in tort (including negligence), contract, or otherwise, shall the Licensor be liable to anyone for any indirect, special, incidental, or consequential damages of any character arising as a result of this License or the use of the Original Work including, without limitation, damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses. This limitation of liability shall not apply to the extent applicable law prohibits such limitation. + + 9. Acceptance and Termination. If, at any time, You expressly assented to this License, that assent indicates your clear and irrevocable acceptance of this License and all of its terms and conditions. If You distribute or communicate copies of the Original Work or a Derivative Work, You must make a reasonable effort under the circumstances to obtain the express assent of recipients to the terms of this License. This License conditions your rights to undertake the activities listed in Section 1, including your right to create Derivative Works based upon the Original Work, and doing so without honoring these terms and conditions is prohibited by copyright law and international treaty. Nothing in this License is intended to affect copyright exceptions and limitations (including "fair use" or "fair dealing"). This License shall terminate immediately and You may no longer exercise any of the rights granted to You by this License upon your failure to honor the conditions in Section 1(c). + + 10. Termination for Patent Action. This License shall terminate automatically and You may no longer exercise any of the rights granted to You by this License as of the date You commence an action, including a cross-claim or counterclaim, against Licensor or any licensee alleging that the Original Work infringes a patent. This termination provision shall not apply for an action alleging patent infringement by combinations of the Original Work with other software or hardware. + + 11. Jurisdiction, Venue and Governing Law. Any action or suit relating to this License may be brought only in the courts of a jurisdiction wherein the Licensor resides or in which Licensor conducts its primary business, and under the laws of that jurisdiction excluding its conflict-of-law provisions. The application of the United Nations Convention on Contracts for the International Sale of Goods is expressly excluded. Any use of the Original Work outside the scope of this License or after its termination shall be subject to the requirements and penalties of copyright or patent law in the appropriate jurisdiction. This section shall survive the termination of this License. + + 12. Attorneys' Fees. In any action to enforce the terms of this License or seeking damages relating thereto, the prevailing party shall be entitled to recover its costs and expenses, including, without limitation, reasonable attorneys' fees and costs incurred in connection with such action, including any appeal of such action. This section shall survive the termination of this License. + + 13. Miscellaneous. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. + + 14. Definition of "You" in This License. "You" throughout this License, whether in upper or lower case, means an individual or a legal entity exercising rights under, and complying with all of the terms of, this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with you. For purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + + 15. Right to Use. You may use the Original Work in all ways not otherwise restricted or conditioned by this License or by law, and Licensor promises not to interfere with or be responsible for such uses by You. + + 16. Modification of This License. This License is Copyright © 2005 Lawrence Rosen. Permission is granted to copy, distribute, or communicate this License without modification. Nothing in this License permits You to modify this License as applied to the Original Work or to Derivative Works. However, You may modify the text of this License and copy, distribute or communicate your modified version (the "Modified License") and apply it to other original works of authorship subject to the following conditions: (i) You may not indicate in any way that your Modified License is the "Academic Free License" or "AFL" and you may not use those names in the name of your Modified License; (ii) You must replace the notice specified in the first paragraph above with the notice "Licensed under " or with a notice of your own that is not confusingly similar to the notice in this License; and (iii) You may not claim that your original works are open source software unless your Modified License has been approved by Open Source Initiative (OSI) and You comply with its license review and certification process. diff --git a/app/code/Magento/AwsS3/Model/Config.php b/app/code/Magento/AwsS3/Model/Config.php new file mode 100644 index 0000000000000..f4e19edd4eec3 --- /dev/null +++ b/app/code/Magento/AwsS3/Model/Config.php @@ -0,0 +1,85 @@ +config = $config; + } + + /** + * Retrieves region. + * + * @return string + */ + public function getRegion(): string + { + return (string)$this->config->get(self::PATH_REGION); + } + + /** + * Retrieves bucket. + * + * @return string + */ + public function getBucket(): string + { + return (string)$this->config->get(self::PATH_BUCKET); + } + + /** + * Retrieves access key. + * + * @return string + */ + public function getAccessKey(): string + { + return (string)$this->config->get(self::PATH_ACCESS_KEY); + } + + /** + * Retrieves secret key. + * + * @return string + */ + public function getSecretKey(): string + { + return (string)$this->config->get(self::PATH_SECRET_KEY); + } + + /** + * Retrieves prefix. + * + * @return string + */ + public function getPrefix(): string + { + return (string)$this->config->get(self::PATH_PREFIX, ''); + } +} diff --git a/app/code/Magento/AwsS3/README.md b/app/code/Magento/AwsS3/README.md new file mode 100644 index 0000000000000..fc07df1717136 --- /dev/null +++ b/app/code/Magento/AwsS3/README.md @@ -0,0 +1,3 @@ +# Magento_AwsS3 module + +The Magento_AwsS3 module integrates your Magento with the [AWS S3](https://aws.amazon.com/s3) storage. diff --git a/app/code/Magento/AwsS3/Test/Mftf/Data/ConfigData.xml b/app/code/Magento/AwsS3/Test/Mftf/Data/ConfigData.xml new file mode 100644 index 0000000000000..23be7918106ee --- /dev/null +++ b/app/code/Magento/AwsS3/Test/Mftf/Data/ConfigData.xml @@ -0,0 +1,19 @@ + + + + + {{_ENV.REMOTE_STORAGE_AWSS3_DRIVER}} + {{_ENV.REMOTE_STORAGE_AWSS3_REGION}} + {{_ENV.REMOTE_STORAGE_AWSS3_PREFIX}} + {{_ENV.REMOTE_STORAGE_AWSS3_BUCKET}} + {{_ENV.REMOTE_STORAGE_AWSS3_ACCESS_KEY}} + {{_ENV.REMOTE_STORAGE_AWSS3_SECRET_KEY}} + --remote-storage-driver={{_ENV.REMOTE_STORAGE_AWSS3_DRIVER}} --remote-storage-bucket={{_ENV.REMOTE_STORAGE_AWSS3_BUCKET}} --remote-storage-region={{_ENV.REMOTE_STORAGE_AWSS3_REGION}} --remote-storage-prefix={{_ENV.REMOTE_STORAGE_AWSS3_PREFIX}} --remote-storage-key={{_ENV.REMOTE_STORAGE_AWSS3_ACCESS_KEY}} --remote-storage-secret={{_ENV.REMOTE_STORAGE_AWSS3_SECRET_KEY}} -n + --remote-storage-driver=file -n + + diff --git a/app/code/Magento/AwsS3/Test/Mftf/Test/AwsS3AdminAddImageForCategoryTest.xml b/app/code/Magento/AwsS3/Test/Mftf/Test/AwsS3AdminAddImageForCategoryTest.xml new file mode 100644 index 0000000000000..a8f0d4da9e338 --- /dev/null +++ b/app/code/Magento/AwsS3/Test/Mftf/Test/AwsS3AdminAddImageForCategoryTest.xml @@ -0,0 +1,27 @@ + + + + + + + + <stories value="Add/remove images and videos for all product types and category"/> + <description value="Admin should be able to add image to a Category"/> + <severity value="CRITICAL"/> + <testCaseId value="MC-38688"/> + <group value="remote_storage_aws_s3"/> + </annotations> + <before> + <magentoCLI command="setup:config:set {{RemoteStorageAwsS3ConfigData.enable_options}}" stepKey="enableRemoteStorage"/> + </before> + <after> + <magentoCLI command="setup:config:set {{RemoteStorageAwsS3ConfigData.disable_options}}" stepKey="disableRemoteStorage"/> + </after> + </test> +</tests> diff --git a/app/code/Magento/AwsS3/Test/Mftf/Test/AwsS3AdminAddImageToWYSIWYGBlockTest.xml b/app/code/Magento/AwsS3/Test/Mftf/Test/AwsS3AdminAddImageToWYSIWYGBlockTest.xml new file mode 100644 index 0000000000000..13e0dcbf41c01 --- /dev/null +++ b/app/code/Magento/AwsS3/Test/Mftf/Test/AwsS3AdminAddImageToWYSIWYGBlockTest.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AwsS3AdminAddImageToWYSIWYGBlockTest" extends="AdminAddImageToWYSIWYGBlockTest"> + <annotations> + <title value="AWS S3 Admin should be able to add image to WYSIWYG content of Block with remote filesystem enabled"/> + <stories value="Default WYSIWYG toolbar configuration with Magento Media Gallery"/> + <description value="Admin should be able to add image to WYSIWYG content of Block"/> + <severity value="BLOCKER"/> + <testCaseId value="MC-38302"/> + <group value="remote_storage_aws_s3"/> + </annotations> + <before> + <magentoCLI command="setup:config:set {{RemoteStorageAwsS3ConfigData.enable_options}}" stepKey="enableRemoteStorage"/> + </before> + <after> + <magentoCLI command="setup:config:set {{RemoteStorageAwsS3ConfigData.disable_options}}" stepKey="disableRemoteStorage"/> + </after> + </test> +</tests> diff --git a/app/code/Magento/AwsS3/Test/Mftf/Test/AwsS3AdminAddImageToWYSIWYGCMSTest.xml b/app/code/Magento/AwsS3/Test/Mftf/Test/AwsS3AdminAddImageToWYSIWYGCMSTest.xml new file mode 100644 index 0000000000000..a56d5d0710d3a --- /dev/null +++ b/app/code/Magento/AwsS3/Test/Mftf/Test/AwsS3AdminAddImageToWYSIWYGCMSTest.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AwsS3AdminAddImageToWYSIWYGCMSTest" extends="AdminAddImageToWYSIWYGCMSTest"> + <annotations> + <title value="AWS S3 Admin should be able to add image to WYSIWYG content of CMS Page with remote filesystem enabled"/> + <stories value="Default WYSIWYG toolbar configuration with Magento Media Gallery"/> + <description value="Admin should be able to add image to WYSIWYG content of CMS Page"/> + <severity value="BLOCKER"/> + <testCaseId value="MC-38295"/> + <group value="remote_storage_aws_s3"/> + </annotations> + <before> + <magentoCLI command="setup:config:set {{RemoteStorageAwsS3ConfigData.enable_options}}" stepKey="enableRemoteStorage"/> + </before> + <after> + <magentoCLI command="setup:config:set {{RemoteStorageAwsS3ConfigData.disable_options}}" stepKey="disableRemoteStorage"/> + </after> + </test> +</tests> diff --git a/app/code/Magento/AwsS3/Test/Mftf/Test/AwsS3AdminAddImageToWYSIWYGNewsletterTest.xml b/app/code/Magento/AwsS3/Test/Mftf/Test/AwsS3AdminAddImageToWYSIWYGNewsletterTest.xml new file mode 100644 index 0000000000000..adc4eea8acf2e --- /dev/null +++ b/app/code/Magento/AwsS3/Test/Mftf/Test/AwsS3AdminAddImageToWYSIWYGNewsletterTest.xml @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AwsS3AdminAddImageToWYSIWYGNewsletterTest" extends="AdminAddImageToWYSIWYGNewsletterTest"> + <annotations> + <features value="Newsletter"/> + <stories value="Apply new WYSIWYG in Newsletter"/> + <group value="Newsletter"/> + <title value="AWS S3 Admin should be able to add image to WYSIWYG content of Newsletter"/> + <description value="Admin should be able to add image to WYSIWYG content Newsletter"/> + <severity value="CRITICAL"/> + <testCaseId value="MC-38716"/> + <group value="remote_storage_aws_s3"/> + </annotations> + <before> + <magentoCLI command="setup:config:set {{RemoteStorageAwsS3ConfigData.enable_options}}" stepKey="enableRemoteStorage"/> + </before> + <after> + <magentoCLI command="setup:config:set {{RemoteStorageAwsS3ConfigData.disable_options}}" stepKey="disableRemoteStorage"/> + </after> + </test> +</tests> diff --git a/app/code/Magento/AwsS3/Test/Mftf/Test/AwsS3AdminAddRemoveDefaultVideoSimpleProductTest.xml b/app/code/Magento/AwsS3/Test/Mftf/Test/AwsS3AdminAddRemoveDefaultVideoSimpleProductTest.xml new file mode 100644 index 0000000000000..2b46ddcacb94c --- /dev/null +++ b/app/code/Magento/AwsS3/Test/Mftf/Test/AwsS3AdminAddRemoveDefaultVideoSimpleProductTest.xml @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AwsS3AdminAddRemoveDefaultVideoSimpleProductTest" extends="AdminAddRemoveDefaultVideoSimpleProductTest"> + <annotations> + <title value="AWS S3Admin should be able to add/remove default product video for a Simple Product"/> + <stories value="Add/remove images and videos for all product types and category"/> + <description value="Admin should be able to add/remove default product video for a Simple Product"/> + <severity value="MAJOR"/> + <testCaseId value="MC-38693"/> + <group value="remote_storage_aws_s3"/> + <skip> + <issueId value="MC-33903"/> + </skip> + </annotations> + <before> + <magentoCLI command="setup:config:set {{RemoteStorageAwsS3ConfigData.enable_options}}" stepKey="enableRemoteStorage"/> + </before> + <after> + <magentoCLI command="setup:config:set {{RemoteStorageAwsS3ConfigData.disable_options}}" stepKey="disableRemoteStorage"/> + </after> + </test> +</tests> diff --git a/app/code/Magento/AwsS3/Test/Mftf/Test/AwsS3AdminCreateDownloadableProductWithLinkTest.xml b/app/code/Magento/AwsS3/Test/Mftf/Test/AwsS3AdminCreateDownloadableProductWithLinkTest.xml new file mode 100644 index 0000000000000..dd0fe36f44dde --- /dev/null +++ b/app/code/Magento/AwsS3/Test/Mftf/Test/AwsS3AdminCreateDownloadableProductWithLinkTest.xml @@ -0,0 +1,189 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AwsS3AdminCreateDownloadableProductWithLinkTest"> + <annotations> + <features value="Catalog"/> + <stories value="Support remote file storage by downloadable products"/> + <title value="Create, view and check out downloadable product with remote filesystem configured. "/> + <description value="Admin should be able to create downloadable product with remote filesystem enabled"/> + <severity value="MAJOR"/> + <testCaseId value="MC-38036"/> + <testCaseId value="MC-38037"/> + <testCaseId value="MC-38039"/> + <group value="Downloadable"/> + <group value="remote_storage_aws_s3"/> + <skip> + <issueId value="MQE-2288" /> + </skip> + </annotations> + <before> + <magentoCLI command="setup:config:set {{RemoteStorageAwsS3ConfigData.enable_options}}" stepKey="enableRemoteStorage"/> + <magentoCLI stepKey="addDownloadableDomain" command="downloadable:domains:add static.magento.com"/> + <!-- Create category --> + <createData entity="SimpleSubCategory" stepKey="createCategory"/> + <createData entity="Simple_US_Customer_Multiple_Addresses" stepKey="createCustomer"/> + <!-- Login as admin --> + <actionGroup ref="AdminLoginActionGroup" stepKey="LoginAsAdmin"/> + </before> + <after> + <magentoCLI command="setup:config:set {{RemoteStorageAwsS3ConfigData.disable_options}}" stepKey="disableRemoteStorage"/> + <magentoCLI stepKey="removeDownloadableDomain" command="downloadable:domains:remove static.magento.com"/> + <!-- Delete customer --> + <deleteData createDataKey="createCustomer" stepKey="deleteCustomer"/> + + <!-- Delete category --> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + + <!-- Delete created downloadable product --> + <actionGroup ref="DeleteProductUsingProductGridActionGroup" stepKey="deleteProduct"> + <argument name="product" value="DownloadableProduct"/> + </actionGroup> + + <!-- Log out --> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> + </after> + + <!-- Create downloadable product --> + <actionGroup ref="AdminOpenProductIndexPageActionGroup" stepKey="amOnProductGridPage"/> + <actionGroup ref="GoToSpecifiedCreateProductPageActionGroup" stepKey="createProduct"> + <argument name="productType" value="downloadable"/> + </actionGroup> + + <!-- Fill downloadable product values --> + <actionGroup ref="FillMainProductFormNoWeightActionGroup" stepKey="fillDownloadableProductForm"> + <argument name="product" value="DownloadableProduct"/> + </actionGroup> + + <!-- Add downloadable product to category --> + <searchAndMultiSelectOption selector="{{AdminProductFormSection.categoriesDropdown}}" parameterArray="[$$createCategory.name$$]" stepKey="fillCategory"/> + + <!-- Add downloadable links --> + <click selector="{{AdminProductDownloadableSection.sectionHeader}}" stepKey="openDownloadableSection"/> + <checkOption selector="{{AdminProductDownloadableSection.isDownloadableProduct}}" stepKey="checkIsDownloadable"/> + <fillField userInput="{{downloadableData.link_title}}" selector="{{AdminProductDownloadableSection.linksTitleInput}}" stepKey="fillDownloadableLinkTitle"/> + <checkOption selector="{{AdminProductDownloadableSection.isLinksPurchasedSeparately}}" stepKey="checkLinksPurchasedSeparately"/> + <fillField userInput="{{downloadableData.sample_title}}" selector="{{AdminProductDownloadableSection.samplesTitleInput}}" stepKey="fillDownloadableSampleTitle"/> + <actionGroup ref="AddDownloadableProductLinkWithMaxDownloadsActionGroup" stepKey="addDownloadableLinkWithMaxDownloads"> + <argument name="link" value="downloadableLinkWithMaxDownloads"/> + </actionGroup> + <actionGroup ref="AddDownloadableProductLinkActionGroup" stepKey="addDownloadableLink"> + <argument name="link" value="downloadableLink"/> + </actionGroup> + <!-- Add downloadable sample--> + <actionGroup ref="AddDownloadableSampleFileActionGroup" stepKey="addDownloadableProductSample"/> + + <!-- Save product --> + <actionGroup ref="SaveProductFormActionGroup" stepKey="saveProduct"/> + <magentoCron stepKey="runIndexCronJobs" groups="index"/> + + <!-- Login to frontend --> + <actionGroup ref="LoginToStorefrontActionGroup" stepKey="signIn"> + <argument name="Customer" value="$$createCustomer$$"/> + </actionGroup> + <!-- Assert product in storefront category page --> + <amOnPage url="$$createCategory.name$$.html" stepKey="amOnCategoryPage"/> + <waitForPageLoad stepKey="waitForCategoryPageLoad"/> + <actionGroup ref="StorefrontCheckProductPriceInCategoryActionGroup" stepKey="StorefrontCheckCategorySimpleProduct"> + <argument name="product" value="DownloadableProduct"/> + </actionGroup> + + <!-- Assert product in storefront product page --> + <actionGroup ref="AssertProductNameAndSkuInStorefrontProductPageActionGroup" stepKey="AssertProductInStorefrontProductPage"> + <argument name="product" value="DownloadableProduct"/> + </actionGroup> + + <!-- Assert product price in storefront product page --> + <see selector="{{StorefrontProductInfoMainSection.productPrice}}" userInput="{{DownloadableProduct.price}}" stepKey="assertProductPrice"/> + + <!-- Assert link sample urls are accessible --> + <!-- Click on the link sample --> + <actionGroup ref="AssertStorefrontSeeElementActionGroup" stepKey="seeDownloadableLinkSampleWithMaxDownloads"> + <argument name="selector" value="{{StorefrontDownloadableProductSection.downloadableLinkSampleByTitle(downloadableLinkWithMaxDownloads.title)}}"/> + </actionGroup> + <click selector="{{StorefrontDownloadableProductSection.downloadableLinkSampleByTitle(downloadableLinkWithMaxDownloads.title)}}" stepKey="clickDownloadableLinkSampleWithMaxDownloads"/> + <waitForPageLoad stepKey="waitForLinkSampleWithMaxDownloadsPage"/> + <!-- Grab Link Sample id --> + <switchToNextTab stepKey="switchToLinkSampleWithMaxDownloadsTab"/> + <grabFromCurrentUrl regex="~/link_id/(\d+)/~" stepKey="grabDownloadableLinkWithMaxDownloadsId"/> + <!-- Check is svg --> + <seeElement selector="{{StorefrontDownloadableLinkSection.downloadedSvg('Logo')}}" stepKey="assertDownloadableLinkWithMaxDownloadsIsSvg"/> + <closeTab stepKey="closeLinkSampleWithMaxDownloadsTab"/> + <!-- Click on the link sample --> + <actionGroup ref="AssertStorefrontSeeElementActionGroup" stepKey="seeDownloadableLinkSample"> + <argument name="selector" value="{{StorefrontDownloadableProductSection.downloadableLinkSampleByTitle(downloadableLink.title)}}"/> + </actionGroup> + <click selector="{{StorefrontDownloadableProductSection.downloadableLinkSampleByTitle(downloadableLink.title)}}" stepKey="clickDownloadableLinkSample"/> + <waitForPageLoad stepKey="waitForLinkSamplePage"/> + <!-- Grab Link Sample id --> + <switchToNextTab stepKey="switchToLinkSampleTab"/> + <grabFromCurrentUrl regex="~/link_id/(\d+)/~" stepKey="grabDownloadableLinkSampleId"/> + <!-- Check is image --> + <seeElement selector="{{StorefrontDownloadableLinkSection.downloadedImage}}" stepKey="assertDownloadableLinkSampleIsImage"/> + <closeTab stepKey="closeLinkSampleTab"/> + + <!-- Assert sample file is accessible --> + <actionGroup ref="AssertStorefrontSeeElementActionGroup" stepKey="seeDownloadableSample"> + <argument name="selector" value="{{StorefrontDownloadableProductSection.downloadableSampleLabel(downloadableSampleFile.title)}}"/> + </actionGroup> + <click selector="{{StorefrontDownloadableProductSection.downloadableSampleLabel(downloadableSampleFile.title)}}" stepKey="clickDownloadableSample"/> + <waitForPageLoad stepKey="waitForSamplePage"/> + <!-- Grab Sample id --> + <switchToNextTab stepKey="switchToSampleTab"/> + <grabFromCurrentUrl regex="~/sample_id/(\d+)/~" stepKey="grabDownloadableSampleId"/> + <!-- Check is image --> + <seeElement selector="{{StorefrontDownloadableLinkSection.downloadedImage}}" stepKey="assertDownloadableSampleIsImage"/> + <closeTab stepKey="closeSampleTab"/> + + <!-- Select product link in storefront product page--> + <scrollTo selector="{{StorefrontDownloadableProductSection.downloadableLinkBlock}}" stepKey="scrollToLinks"/> + <click selector="{{StorefrontDownloadableProductSection.downloadableLinkByTitle(downloadableLink.title)}}" stepKey="selectProductLink"/> + + <!-- Add product with selected link to the cart --> + <actionGroup ref="StorefrontAddProductToCartActionGroup" stepKey="addProductToCart"> + <argument name="product" value="DownloadableProduct"/> + <argument name="productCount" value="1"/> + </actionGroup> + + <!-- Assert product price in cart --> + <actionGroup ref="StorefrontCartPageOpenActionGroup" stepKey="openShoppingCartPage"/> + <see selector="{{CheckoutCartProductSection.ProductPriceByName(DownloadableProduct.name)}}" userInput="$52.99" stepKey="assertProductPriceInCart"/> + + <!-- Perform checkout --> + <click selector="{{CheckoutCartSummarySection.proceedToCheckout}}" stepKey="clickProceedToCheckout"/> + <waitForElement selector="{{CheckoutPaymentSection.paymentSectionTitle}}" stepKey="waitForPaymentSectionLoaded"/> + <click selector="{{CheckoutPaymentSection.placeOrder}}" stepKey="clickPlaceOrderButton"/> + <seeElement selector="{{CheckoutSuccessMainSection.success}}" stepKey="orderIsSuccessfullyPlaced"/> + <grabTextFrom selector="{{CheckoutSuccessMainSection.orderNumber22}}" stepKey="grabOrderNumber"/> + + <!-- Open created order --> + <amOnPage url="{{AdminOrdersPage.url}}" stepKey="onOrdersPage"/> + <actionGroup ref="SearchAdminDataGridByKeywordActionGroup" stepKey="searchOrder"> + <argument name="keyword" value="$grabOrderNumber"/> + </actionGroup> + <actionGroup ref="AdminOrderGridClickFirstRowActionGroup" stepKey="clickOrderRow"/> + + <!-- Open Create invoice --> + <actionGroup ref="AdminCreateInvoiceActionGroup" stepKey="createCreditMemo"/> + + <!-- Check downloadable product link on frontend --> + <actionGroup ref="StorefrontAssertDownloadableProductIsPresentInCustomerAccount" stepKey="seeStorefrontMyAccountDownloadableProductsLink"> + <argument name="product" value="DownloadableProduct"/> + </actionGroup> + <click selector="{{StorefrontCustomerDownloadableProductsSection.downloadableLink}}" stepKey="clickDownloadLink" /> + <waitForPageLoad stepKey="waitForDownloadedLinkPage"/> + <!-- Grab downloadable URL --> + <switchToNextTab stepKey="switchToDownloadedLinkTab"/> + <grabFromCurrentUrl regex="~/link/id/(.+)/~" stepKey="grabDownloadLinkUrl"/> + <!-- Check is svg --> + <seeElement selector="{{StorefrontDownloadableLinkSection.downloadedSvg('Logo')}}" stepKey="assertDownloadedLinkIsSvg"/> + <closeTab stepKey="closeDownloadedLinkTab"/> + </test> +</tests> diff --git a/app/code/Magento/AwsS3/Test/Mftf/Test/AwsS3AdminMarketingCreateSitemapEntityTest.xml b/app/code/Magento/AwsS3/Test/Mftf/Test/AwsS3AdminMarketingCreateSitemapEntityTest.xml new file mode 100644 index 0000000000000..d9dc75c18ad4b --- /dev/null +++ b/app/code/Magento/AwsS3/Test/Mftf/Test/AwsS3AdminMarketingCreateSitemapEntityTest.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="UTF-8"?> + <!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + --> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AwsS3AdminMarketingCreateSitemapEntityTest" extends="AdminMarketingCreateSitemapEntityTest"> + <annotations> + <stories value="Admin Creates Sitemap Entity"/> + <description value="Sitemap Entity Creation"/> + <severity value="MAJOR"/> + <title value="AWS S3 Sitemap Creation"/> + <testCaseId value="MC-38319"/> + <group value="remote_storage_aws_s3"/> + </annotations> + <before> + <magentoCLI command="setup:config:set {{RemoteStorageAwsS3ConfigData.enable_options}}" stepKey="enableRemoteStorage"/> + </before> + <after> + <magentoCLI command="setup:config:set {{RemoteStorageAwsS3ConfigData.disable_options}}" stepKey="disableRemoteStorage"/> + </after> + </test> +</tests> diff --git a/app/code/Magento/AwsS3/Test/Mftf/Test/AwsS3AdminMarketingSiteMapCreateNewTest.xml b/app/code/Magento/AwsS3/Test/Mftf/Test/AwsS3AdminMarketingSiteMapCreateNewTest.xml new file mode 100644 index 0000000000000..bbdeb7ff1155a --- /dev/null +++ b/app/code/Magento/AwsS3/Test/Mftf/Test/AwsS3AdminMarketingSiteMapCreateNewTest.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="UTF-8"?> + <!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + --> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AwsS3AdminMarketingSiteMapCreateNewTest" extends="AdminMarketingSiteMapCreateNewTest"> + <annotations> + <title value="AWS S3 Create New Site Map with valid data"/> + <stories value="Create Site Map"/> + <description value="Create New Site Map with valid data"/> + <severity value="CRITICAL"/> + <testCaseId value="MC-38320" /> + <group value="remote_storage_aws_s3"/> + </annotations> + <before> + <magentoCLI command="setup:config:set {{RemoteStorageAwsS3ConfigData.enable_options}}" stepKey="enableRemoteStorage"/> + </before> + <after> + <magentoCLI command="setup:config:set {{RemoteStorageAwsS3ConfigData.disable_options}}" stepKey="disableRemoteStorage"/> + </after> + </test> +</tests> diff --git a/app/code/Magento/AwsS3/Test/Mftf/Test/AwsS3CheckingRMAPrintTest.xml b/app/code/Magento/AwsS3/Test/Mftf/Test/AwsS3CheckingRMAPrintTest.xml new file mode 100644 index 0000000000000..6d9d89fd29be5 --- /dev/null +++ b/app/code/Magento/AwsS3/Test/Mftf/Test/AwsS3CheckingRMAPrintTest.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AwsS3CheckingRMAPrintTest" extends="CheckingRMAPrintTest"> + <annotations> + <title value="AWS S3 Checking Returns Print"/> + <stories value="Exception when try to print RMA"/> + <description value="RMA file should be downloaded"/> + <severity value="MAJOR"/> + <testCaseId value="MC-38694"/> + <group value="remote_storage_aws_s3"/> + </annotations> + <before> + <magentoCLI command="setup:config:set {{RemoteStorageAwsS3ConfigData.enable_options}}" stepKey="enableRemoteStorage"/> + </before> + <after> + <magentoCLI command="setup:config:set {{RemoteStorageAwsS3ConfigData.disable_options}}" stepKey="disableRemoteStorage"/> + </after> + </test> +</tests> diff --git a/app/code/Magento/AwsS3/Test/Mftf/Test/AwsS3ConfigurableProductChildImageShouldBeShownOnWishListTest.xml b/app/code/Magento/AwsS3/Test/Mftf/Test/AwsS3ConfigurableProductChildImageShouldBeShownOnWishListTest.xml new file mode 100644 index 0000000000000..049caa2180d69 --- /dev/null +++ b/app/code/Magento/AwsS3/Test/Mftf/Test/AwsS3ConfigurableProductChildImageShouldBeShownOnWishListTest.xml @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AwsS3ConfigurableProductChildImageShouldBeShownOnWishListTest" extends="ConfigurableProductChildImageShouldBeShownOnWishListTest"> + <annotations> + <features value="Wishlist"/> + <stories value="Configurable product child image should be Shown on wishlist"/> + <group value="wishlist"/> + <title value="AWS S3 when user add Configurable child product to WIshlist then child product image should be shown in Wishlist"/> + <description value="When user add Configurable child product to WIshlist then child product image should be shown in Wishlist"/> + <severity value="MAJOR"/> + <testCaseId value="MC-38708"/> + <group value="remote_storage_aws_s3"/> + </annotations> + <before> + <magentoCLI command="setup:config:set {{RemoteStorageAwsS3ConfigData.enable_options}}" stepKey="enableRemoteStorage"/> + </before> + <after> + <magentoCLI command="setup:config:set {{RemoteStorageAwsS3ConfigData.disable_options}}" stepKey="disableRemoteStorage"/> + </after> + </test> +</tests> diff --git a/app/code/Magento/AwsS3/Test/Mftf/Test/AwsS3StorefrontPrintOrderGuestTest.xml b/app/code/Magento/AwsS3/Test/Mftf/Test/AwsS3StorefrontPrintOrderGuestTest.xml new file mode 100644 index 0000000000000..c8d2947632b59 --- /dev/null +++ b/app/code/Magento/AwsS3/Test/Mftf/Test/AwsS3StorefrontPrintOrderGuestTest.xml @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AwsS3StorefrontPrintOrderGuestTest" extends="StorefrontPrintOrderGuestTest"> + <annotations> + <title value="AWS S3 Print Order from Guest on Frontend"/> + <stories value="Print Order"/> + <description value="Print Order from Guest on Frontend"/> + <severity value="BLOCKER"/> + <testCaseId value="MC-38689"/> + <group value="remote_storage_aws_s3"/> + <skip> + <issueId value="MQE-2288" /> + </skip> + </annotations> + <before> + <magentoCLI command="setup:config:set {{RemoteStorageAwsS3ConfigData.enable_options}}" stepKey="enableRemoteStorage"/> + </before> + <after> + <magentoCLI command="setup:config:set {{RemoteStorageAwsS3ConfigData.disable_options}}" stepKey="disableRemoteStorage"/> + </after> + </test> +</tests> diff --git a/app/code/Magento/AwsS3/Test/Mftf/Test/AwsS3UpdateImageFileCustomerAttributeTest.xml b/app/code/Magento/AwsS3/Test/Mftf/Test/AwsS3UpdateImageFileCustomerAttributeTest.xml new file mode 100644 index 0000000000000..8e2ec348d4f41 --- /dev/null +++ b/app/code/Magento/AwsS3/Test/Mftf/Test/AwsS3UpdateImageFileCustomerAttributeTest.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AwsS3UpdateImageFileCustomerAttributeTest" extends="UpdateImageFileCustomerAttributeTest"> + <annotations> + <title value="AWS S3 Update image file customer attribute test"/> + <stories value="Update Customer Custom Attributes"/> + <description value="Update image file customer attribute"/> + <severity value="MAJOR"/> + <testCaseId value="MC-38692"/> + <group value="remote_storage_aws_s3"/> + </annotations> + <before> + <magentoCLI command="setup:config:set {{RemoteStorageAwsS3ConfigData.enable_options}}" stepKey="enableRemoteStorage"/> + </before> + <after> + <magentoCLI command="setup:config:set {{RemoteStorageAwsS3ConfigData.disable_options}}" stepKey="disableRemoteStorage"/> + </after> + </test> +</tests> diff --git a/app/code/Magento/AwsS3/Test/Unit/Driver/AwsS3Test.php b/app/code/Magento/AwsS3/Test/Unit/Driver/AwsS3Test.php new file mode 100644 index 0000000000000..20bc28be4583c --- /dev/null +++ b/app/code/Magento/AwsS3/Test/Unit/Driver/AwsS3Test.php @@ -0,0 +1,438 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\AwsS3\Test\Unit\Driver; + +use League\Flysystem\AwsS3v3\AwsS3Adapter; +use League\Flysystem\Cached\CachedAdapter; +use Magento\AwsS3\Driver\AwsS3; +use Magento\Framework\Exception\FileSystemException; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +/** + * @see AwsS3 + */ +class AwsS3Test extends TestCase +{ + private const URL = 'https://test.s3.amazonaws.com/'; + + /** + * @var AwsS3 + */ + private $driver; + + /** + * @var AwsS3Adapter|MockObject + */ + private $adapterMock; + + /** + * @inheritDoc + */ + protected function setUp(): void + { + $this->adapterMock = $this->createMock(CachedAdapter::class); + $loggerMock = $this->getMockForAbstractClass(LoggerInterface::class); + + $this->driver = new AwsS3($this->adapterMock, $loggerMock, self::URL); + } + + /** + * @param string|null $basePath + * @param string|null $path + * @param string $expected + * + * @dataProvider getAbsolutePathDataProvider + */ + public function testGetAbsolutePath($basePath, $path, string $expected): void + { + self::assertSame($expected, $this->driver->getAbsolutePath($basePath, $path)); + } + + /** + * @return array + */ + public function getAbsolutePathDataProvider(): array + { + return [ + [ + null, + 'test.png', + self::URL . 'test.png' + ], + [ + self::URL . 'test/test.png', + null, + self::URL . 'test/test.png' + ], + [ + '', + 'test.png', + self::URL . 'test.png' + ], + [ + '', + '/test/test.png', + self::URL . 'test/test.png' + ], + [ + self::URL . 'test/test.png', + self::URL . 'test/test.png', + self::URL . 'test/test.png' + ], + [ + self::URL, + self::URL . 'media/catalog/test.png', + self::URL . 'media/catalog/test.png' + ], + [ + '', + self::URL . 'media/catalog/test.png', + self::URL . 'media/catalog/test.png' + ], + [ + self::URL . 'test/', + 'test.txt', + self::URL . 'test/test.txt' + ], + [ + self::URL . 'media/', + 'media/image.jpg', + self::URL . 'media/image.jpg' + ], + [ + self::URL . 'media/', + '/catalog/test.png', + self::URL . 'media/catalog/test.png' + ], + [ + self::URL, + 'var/import/images', + self::URL . 'var/import/images' + ], + [ + self::URL . 'export/', + null, + self::URL . 'export/' + ], + [ + self::URL . 'var/import/images/product_images/', + self::URL . 'var/import/images/product_images/1.png', + self::URL . 'var/import/images/product_images/1.png' + ], + [ + '', + self::URL . 'media/catalog/test.png', + self::URL . 'media/catalog/test.png' + ], + [ + self::URL, + 'var/import/images', + self::URL . 'var/import/images' + ], + [ + self::URL . 'var/import/images/product_images/', + self::URL . 'var/import/images/product_images/1.png', + self::URL . 'var/import/images/product_images/1.png' + ] + ]; + } + + /** + * @param string $basePath + * @param string $path + * @param string $expected + * + * @dataProvider getRelativePathDataProvider + */ + public function testGetRelativePath(string $basePath, string $path, string $expected): void + { + self::assertSame($expected, $this->driver->getRelativePath($basePath, $path)); + } + + /** + * @return array + */ + public function getRelativePathDataProvider(): array + { + return [ + [ + '', + 'test/test.txt', + 'test/test.txt' + ], + [ + '', + '/test/test.txt', + 'test/test.txt' + ], + [ + self::URL, + self::URL . 'test/test.txt', + 'test/test.txt' + ], + + ]; + } + + /** + * @param string $path + * @param string $normalizedPath + * @param bool $has + * @param array $metadata + * @param bool $expected + * @throws FileSystemException + * + * @dataProvider isDirectoryDataProvider + */ + public function testIsDirectory( + string $path, + string $normalizedPath, + bool $has, + array $metadata, + bool $expected + ): void { + $this->adapterMock->method('has') + ->with($normalizedPath) + ->willReturn($has); + $this->adapterMock->method('getMetadata') + ->with($normalizedPath) + ->willReturn($metadata); + + self::assertSame($expected, $this->driver->isDirectory($path)); + } + + /** + * @return array + */ + public function isDirectoryDataProvider(): array + { + return [ + [ + 'some_directory/', + 'some_directory/', + false, + [], + false + ], + [ + 'some_directory', + 'some_directory/', + true, + [ + 'type' => AwsS3::TYPE_DIR + ], + true + ], + [ + self::URL . 'some_directory', + 'some_directory/', + true, + [ + 'type' => AwsS3::TYPE_DIR + ], + true + ], + [ + self::URL . 'some_directory', + 'some_directory/', + true, + [ + 'type' => AwsS3::TYPE_FILE + ], + false + ], + [ + '', + '', + true, + [], + true + ], + [ + '/', + '', + true, + [], + true + ], + ]; + } + + /** + * @param string $path + * @param string $normalizedPath + * @param bool $has + * @param array $metadata + * @param bool $expected + * @throws FileSystemException + * + * @dataProvider isFileDataProvider + */ + public function testIsFile( + string $path, + string $normalizedPath, + bool $has, + array $metadata, + bool $expected + ): void { + $this->adapterMock->method('has') + ->with($normalizedPath) + ->willReturn($has); + $this->adapterMock->method('getMetadata') + ->with($normalizedPath) + ->willReturn($metadata); + + self::assertSame($expected, $this->driver->isFile($path)); + } + + /** + * @return array + */ + public function isFileDataProvider(): array + { + return [ + [ + 'some_file.txt', + 'some_file.txt', + false, + [], + false + ], + [ + 'some_file.txt/', + 'some_file.txt', + true, + [ + 'type' => AwsS3::TYPE_FILE + ], + true + ], + [ + self::URL . 'some_file.txt', + 'some_file.txt', + true, + [ + 'type' => AwsS3::TYPE_FILE + ], + true + ], + [ + self::URL . 'some_file.txt/', + 'some_file.txt', + true, + [ + 'type' => AwsS3::TYPE_DIR + ], + false + ], + [ + '', + '', + false, + [], + false + ], + [ + '/', + '', + false, + [], + false + ] + ]; + } + + /** + * @param string $path + * @param string $expected + * + * @dataProvider getRealPathSafetyDataProvider + */ + public function testGetRealPathSafety(string $path, string $expected): void + { + self::assertSame($expected, $this->driver->getRealPathSafety($path)); + } + + /** + * @return array + */ + public function getRealPathSafetyDataProvider(): array + { + return [ + [ + self::URL, + self::URL + ], + [ + 'test.txt', + 'test.txt' + ], + [ + self::URL . 'test/test/../test.txt', + self::URL . 'test/test.txt' + ], + [ + 'test/test/../test.txt', + 'test/test.txt' + ] + ]; + } + + /** + * @throws FileSystemException + */ + public function testSearchDirectory(): void + { + $expression = '/*'; + $path = 'path/'; + $subPaths = [ + ['path' => 'path/1'], + ['path' => 'path/2'] + ]; + $expectedResult = ['path/1', 'path/2']; + $this->adapterMock->expects(self::atLeastOnce())->method('has') + ->willReturnMap([ + [$path, true] + ]); + $this->adapterMock->expects(self::atLeastOnce())->method('getMetadata') + ->willReturnMap([ + [$path, ['type' => AwsS3::TYPE_DIR]] + ]); + $this->adapterMock->expects(self::atLeastOnce())->method('listContents')->with($path, false) + ->willReturn($subPaths); + self::assertEquals($expectedResult, $this->driver->search($expression, $path)); + } + + /** + * @throws FileSystemException + */ + public function testSearchFiles(): void + { + $expression = "/*"; + $path = 'path/'; + $subPaths = [ + ['path' => 'path/1.jpg'], + ['path' => 'path/2.png'] + ]; + $expectedResult = ['path/1.jpg', 'path/2.png']; + + $this->adapterMock->expects(self::atLeastOnce())->method('has') + ->willReturnMap([ + [$path, true], + ]); + $this->adapterMock->expects(self::atLeastOnce())->method('getMetadata') + ->willReturnMap([ + [$path, ['type' => AwsS3::TYPE_DIR]], + ]); + $this->adapterMock->expects(self::atLeastOnce())->method('listContents')->with($path, false) + ->willReturn($subPaths); + self::assertEquals($expectedResult, $this->driver->search($expression, $path)); + } +} diff --git a/app/code/Magento/AwsS3/composer.json b/app/code/Magento/AwsS3/composer.json new file mode 100644 index 0000000000000..6e72ac37f8ba6 --- /dev/null +++ b/app/code/Magento/AwsS3/composer.json @@ -0,0 +1,27 @@ +{ + "name": "magento/module-aws-s-3", + "description": "N/A", + "config": { + "sort-packages": true + }, + "require": { + "php": "~7.3.0||~7.4.0", + "magento/framework": "^100.0.2", + "magento/module-remote-storage": "*", + "league/flysystem": "^1.0", + "league/flysystem-aws-s3-v3": "^1.0", + "league/flysystem-cached-adapter": "^1.0" + }, + "type": "magento2-module", + "license": [ + "proprietary" + ], + "autoload": { + "files": [ + "registration.php" + ], + "psr-4": { + "Magento\\AwsS3\\": "" + } + } +} diff --git a/app/code/Magento/AwsS3/etc/adminhtml/di.xml b/app/code/Magento/AwsS3/etc/adminhtml/di.xml new file mode 100644 index 0000000000000..4d3dcd601047f --- /dev/null +++ b/app/code/Magento/AwsS3/etc/adminhtml/di.xml @@ -0,0 +1,19 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd"> + <type name="Magento\RemoteStorage\Model\Config\Source\FileStorage"> + <arguments> + <argument name="options" xsi:type="array"> + <item name="aws-s3" xsi:type="array"> + <item name="value" xsi:type="string">aws-s3</item> + <item name="label" xsi:type="string" translate="true">AWS S3</item> + </item> + </argument> + </arguments> + </type> +</config> diff --git a/app/code/Magento/AwsS3/etc/di.xml b/app/code/Magento/AwsS3/etc/di.xml new file mode 100644 index 0000000000000..94df51fcd6856 --- /dev/null +++ b/app/code/Magento/AwsS3/etc/di.xml @@ -0,0 +1,16 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd"> + <type name="Magento\RemoteStorage\Driver\DriverFactoryPool"> + <arguments> + <argument name="pool" xsi:type="array"> + <item name="aws-s3" xsi:type="object">Magento\AwsS3\Driver\AwsS3Factory</item> + </argument> + </arguments> + </type> +</config> diff --git a/app/code/Magento/AwsS3/etc/module.xml b/app/code/Magento/AwsS3/etc/module.xml new file mode 100644 index 0000000000000..ab99195d45ab5 --- /dev/null +++ b/app/code/Magento/AwsS3/etc/module.xml @@ -0,0 +1,14 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd"> + <module name="Magento_AwsS3"> + <sequence> + <module name="Magento_RemoteStorage"/> + </sequence> + </module> +</config> diff --git a/app/code/Magento/AwsS3/registration.php b/app/code/Magento/AwsS3/registration.php new file mode 100644 index 0000000000000..496fbad1d3371 --- /dev/null +++ b/app/code/Magento/AwsS3/registration.php @@ -0,0 +1,9 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +use Magento\Framework\Component\ComponentRegistrar; + +ComponentRegistrar::register(ComponentRegistrar::MODULE, 'Magento_AwsS3', __DIR__); diff --git a/setup/src/Magento/Setup/Console/Command/AbstractMaintenanceCommand.php b/app/code/Magento/Backend/Console/Command/AbstractMaintenanceCommand.php similarity index 85% rename from setup/src/Magento/Setup/Console/Command/AbstractMaintenanceCommand.php rename to app/code/Magento/Backend/Console/Command/AbstractMaintenanceCommand.php index 85ae008adf366..eb452f62e91ce 100644 --- a/setup/src/Magento/Setup/Console/Command/AbstractMaintenanceCommand.php +++ b/app/code/Magento/Backend/Console/Command/AbstractMaintenanceCommand.php @@ -3,14 +3,19 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ -namespace Magento\Setup\Console\Command; +namespace Magento\Backend\Console\Command; use Magento\Framework\App\MaintenanceMode; -use Magento\Setup\Validator\IpValidator; +use Magento\Framework\Console\Cli; +use Magento\Setup\Console\Command\AbstractSetupCommand; +use Magento\Backend\Model\Validator\IpValidator; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +/** + * General maintenance command. + */ abstract class AbstractMaintenanceCommand extends AbstractSetupCommand { /** @@ -38,6 +43,7 @@ public function __construct(MaintenanceMode $maintenanceMode, IpValidator $ipVal { $this->maintenanceMode = $maintenanceMode; $this->ipValidator = $ipValidator; + parent::__construct(); } @@ -57,6 +63,7 @@ protected function configure() ), ]; $this->setDefinition($options); + parent::configure(); } @@ -75,16 +82,18 @@ abstract protected function isEnable(); abstract protected function getDisplayString(); /** - * {@inheritdoc} + * @inheritDoc */ - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { $addresses = $input->getOption(self::INPUT_KEY_IP); $messages = $this->validate($addresses); + if (!empty($messages)) { $output->writeln('<error>' . implode('</error>' . PHP_EOL . '<error>', $messages)); - // we must have an exit code higher than zero to indicate something was wrong - return \Magento\Framework\Console\Cli::RETURN_FAILURE; + + // We must have an exit code higher than zero to indicate something was wrong + return Cli::RETURN_FAILURE; } $this->maintenanceMode->set($this->isEnable()); @@ -92,14 +101,15 @@ protected function execute(InputInterface $input, OutputInterface $output) if (!empty($addresses)) { $addresses = implode(',', $addresses); - $addresses = ('none' == $addresses) ? '' : $addresses; + $addresses = ('none' === $addresses) ? '' : $addresses; $this->maintenanceMode->setAddresses($addresses); $output->writeln( '<info>Set exempt IP-addresses: ' . (implode(', ', $this->maintenanceMode->getAddressInfo()) ?: 'none') . '</info>' ); } - return \Magento\Framework\Console\Cli::RETURN_SUCCESS; + + return Cli::RETURN_SUCCESS; } /** diff --git a/setup/src/Magento/Setup/Console/Command/MaintenanceAllowIpsCommand.php b/app/code/Magento/Backend/Console/Command/MaintenanceAllowIpsCommand.php similarity index 90% rename from setup/src/Magento/Setup/Console/Command/MaintenanceAllowIpsCommand.php rename to app/code/Magento/Backend/Console/Command/MaintenanceAllowIpsCommand.php index 09f33cf85062c..230c6a6814ebc 100644 --- a/setup/src/Magento/Setup/Console/Command/MaintenanceAllowIpsCommand.php +++ b/app/code/Magento/Backend/Console/Command/MaintenanceAllowIpsCommand.php @@ -3,12 +3,12 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ - -namespace Magento\Setup\Console\Command; +namespace Magento\Backend\Console\Command; use Magento\Framework\App\MaintenanceMode; -use Magento\Framework\Module\ModuleList; -use Magento\Setup\Validator\IpValidator; +use Magento\Framework\Console\Cli; +use Magento\Setup\Console\Command\AbstractSetupCommand; +use Magento\Backend\Model\Validator\IpValidator; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputOption; @@ -37,8 +37,6 @@ class MaintenanceAllowIpsCommand extends AbstractSetupCommand private $ipValidator; /** - * Constructor - * * @param MaintenanceMode $maintenanceMode * @param IpValidator $ipValidator */ @@ -46,6 +44,7 @@ public function __construct(MaintenanceMode $maintenanceMode, IpValidator $ipVal { $this->maintenanceMode = $maintenanceMode; $this->ipValidator = $ipValidator; + parent::__construct(); } @@ -54,7 +53,7 @@ public function __construct(MaintenanceMode $maintenanceMode, IpValidator $ipVal * * @return void */ - protected function configure() + protected function configure(): void { $arguments = [ new InputArgument( @@ -80,19 +79,21 @@ protected function configure() $this->setName('maintenance:allow-ips') ->setDescription('Sets maintenance mode exempt IPs') ->setDefinition(array_merge($arguments, $options)); + parent::configure(); } /** - * {@inheritdoc} + * @inheritDoc */ - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { if (!$input->getOption(self::INPUT_KEY_NONE)) { $addresses = $input->getArgument(self::INPUT_KEY_IP); $messages = $this->validate($addresses); if (!empty($messages)) { $output->writeln('<error>' . implode('</error>' . PHP_EOL . '<error>', $messages)); + // we must have an exit code higher than zero to indicate something was wrong return \Magento\Framework\Console\Cli::RETURN_FAILURE; } @@ -111,7 +112,8 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->maintenanceMode->setAddresses(''); $output->writeln('<info>Set exempt IP-addresses: none</info>'); } - return \Magento\Framework\Console\Cli::RETURN_SUCCESS; + + return Cli::RETURN_SUCCESS; } /** @@ -120,7 +122,7 @@ protected function execute(InputInterface $input, OutputInterface $output) * @param string[] $addresses * @return string[] */ - protected function validate(array $addresses) + protected function validate(array $addresses): array { return $this->ipValidator->validateIps($addresses, false); } diff --git a/setup/src/Magento/Setup/Console/Command/MaintenanceDisableCommand.php b/app/code/Magento/Backend/Console/Command/MaintenanceDisableCommand.php similarity index 84% rename from setup/src/Magento/Setup/Console/Command/MaintenanceDisableCommand.php rename to app/code/Magento/Backend/Console/Command/MaintenanceDisableCommand.php index abebbdb76346b..5108866fbe65c 100644 --- a/setup/src/Magento/Setup/Console/Command/MaintenanceDisableCommand.php +++ b/app/code/Magento/Backend/Console/Command/MaintenanceDisableCommand.php @@ -3,8 +3,7 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ - -namespace Magento\Setup\Console\Command; +namespace Magento\Backend\Console\Command; /** * Command for disabling maintenance mode @@ -19,6 +18,7 @@ class MaintenanceDisableCommand extends AbstractMaintenanceCommand protected function configure() { $this->setName('maintenance:disable')->setDescription('Disables maintenance mode'); + parent::configure(); } @@ -27,7 +27,7 @@ protected function configure() * * @return bool */ - protected function isEnable() + protected function isEnable(): bool { return false; } @@ -37,7 +37,7 @@ protected function isEnable() * * @return string */ - protected function getDisplayString() + protected function getDisplayString(): string { return '<info>Disabled maintenance mode</info>'; } @@ -47,7 +47,7 @@ protected function getDisplayString() * * @return bool */ - public function isSetAddressInfo() + public function isSetAddressInfo(): bool { return count($this->maintenanceMode->getAddressInfo()) > 0; } diff --git a/setup/src/Magento/Setup/Console/Command/MaintenanceEnableCommand.php b/app/code/Magento/Backend/Console/Command/MaintenanceEnableCommand.php similarity index 80% rename from setup/src/Magento/Setup/Console/Command/MaintenanceEnableCommand.php rename to app/code/Magento/Backend/Console/Command/MaintenanceEnableCommand.php index 94ab312b60811..7e5e034483d20 100644 --- a/setup/src/Magento/Setup/Console/Command/MaintenanceEnableCommand.php +++ b/app/code/Magento/Backend/Console/Command/MaintenanceEnableCommand.php @@ -3,8 +3,7 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ - -namespace Magento\Setup\Console\Command; +namespace Magento\Backend\Console\Command; /** * Command for enabling maintenance mode @@ -16,9 +15,10 @@ class MaintenanceEnableCommand extends AbstractMaintenanceCommand * * @return void */ - protected function configure() + protected function configure(): void { $this->setName('maintenance:enable')->setDescription('Enables maintenance mode'); + parent::configure(); } @@ -27,7 +27,7 @@ protected function configure() * * @return bool */ - protected function isEnable() + protected function isEnable(): bool { return true; } @@ -37,7 +37,7 @@ protected function isEnable() * * @return string */ - protected function getDisplayString() + protected function getDisplayString(): string { return '<info>Enabled maintenance mode</info>'; } diff --git a/setup/src/Magento/Setup/Console/Command/MaintenanceStatusCommand.php b/app/code/Magento/Backend/Console/Command/MaintenanceStatusCommand.php similarity index 85% rename from setup/src/Magento/Setup/Console/Command/MaintenanceStatusCommand.php rename to app/code/Magento/Backend/Console/Command/MaintenanceStatusCommand.php index f2d3d2bf30caa..e7feae32cf8b0 100644 --- a/setup/src/Magento/Setup/Console/Command/MaintenanceStatusCommand.php +++ b/app/code/Magento/Backend/Console/Command/MaintenanceStatusCommand.php @@ -3,11 +3,11 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ - -namespace Magento\Setup\Console\Command; +namespace Magento\Backend\Console\Command; use Magento\Framework\App\MaintenanceMode; -use Magento\Framework\Module\ModuleList; +use Magento\Framework\Console\Cli; +use Magento\Setup\Console\Command\AbstractSetupCommand; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -29,6 +29,7 @@ class MaintenanceStatusCommand extends AbstractSetupCommand public function __construct(MaintenanceMode $maintenanceMode) { $this->maintenanceMode = $maintenanceMode; + parent::__construct(); } @@ -37,17 +38,18 @@ public function __construct(MaintenanceMode $maintenanceMode) * * @return void */ - protected function configure() + protected function configure(): void { $this->setName('maintenance:status') ->setDescription('Displays maintenance mode status'); + parent::configure(); } /** - * {@inheritdoc} + * @inheritDoc */ - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { $output->writeln( '<info>Status: maintenance mode is ' . @@ -56,6 +58,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $addressInfo = $this->maintenanceMode->getAddressInfo(); $addresses = implode(' ', $addressInfo); $output->writeln('<info>List of exempt IP-addresses: ' . ($addresses ? $addresses : 'none') . '</info>'); - return \Magento\Framework\Console\Cli::RETURN_SUCCESS; + + return Cli::RETURN_SUCCESS; } } diff --git a/app/code/Magento/Backend/Console/CommandList.php b/app/code/Magento/Backend/Console/CommandList.php new file mode 100644 index 0000000000000..563ef964812ab --- /dev/null +++ b/app/code/Magento/Backend/Console/CommandList.php @@ -0,0 +1,64 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +namespace Magento\Backend\Console; + +use Magento\Backend\Console\Command\MaintenanceAllowIpsCommand; +use Magento\Backend\Console\Command\MaintenanceDisableCommand; +use Magento\Backend\Console\Command\MaintenanceEnableCommand; +use Magento\Backend\Console\Command\MaintenanceStatusCommand; +use Magento\Framework\Console\CommandListInterface; +use Magento\Framework\ObjectManagerInterface; + +/** + * Provides list of commands to be available for uninstalled application + */ +class CommandList implements CommandListInterface +{ + /** + * @var ObjectManagerInterface + */ + private $objectManager; + + /** + * @param ObjectManagerInterface $objectManager + */ + public function __construct(ObjectManagerInterface $objectManager) + { + $this->objectManager = $objectManager; + } + + /** + * Gets list of command classes + * + * @return string[] + */ + private function getCommandsClasses(): array + { + return [ + MaintenanceAllowIpsCommand::class, + MaintenanceDisableCommand::class, + MaintenanceEnableCommand::class, + MaintenanceStatusCommand::class + ]; + } + + /** + * @inheritdoc + */ + public function getCommands(): array + { + $commands = []; + foreach ($this->getCommandsClasses() as $class) { + if (class_exists($class)) { + $commands[] = $this->objectManager->get($class); + } else { + throw new \RuntimeException('Class ' . $class . ' does not exist'); + } + } + + return $commands; + } +} diff --git a/app/code/Magento/Backend/Model/Auth/Session.php b/app/code/Magento/Backend/Model/Auth/Session.php index 809b78b7b98bc..8f959d873243a 100644 --- a/app/code/Magento/Backend/Model/Auth/Session.php +++ b/app/code/Magento/Backend/Model/Auth/Session.php @@ -5,8 +5,10 @@ */ namespace Magento\Backend\Model\Auth; +use Magento\Framework\App\ObjectManager; use Magento\Framework\Stdlib\Cookie\CookieMetadataFactory; use Magento\Framework\Stdlib\CookieManagerInterface; +use Magento\Framework\Message\ManagerInterface; /** * Backend Auth session model @@ -56,6 +58,11 @@ class Session extends \Magento\Framework\Session\SessionManager implements \Mage */ protected $_config; + /** + * @var ManagerInterface + */ + private $messageManager; + /** * @param \Magento\Framework\App\Request\Http $request * @param \Magento\Framework\Session\SidResolverInterface $sidResolver @@ -69,6 +76,7 @@ class Session extends \Magento\Framework\Session\SessionManager implements \Mage * @param \Magento\Framework\Acl\Builder $aclBuilder * @param \Magento\Backend\Model\UrlInterface $backendUrl * @param \Magento\Backend\App\ConfigInterface $config + * @param ManagerInterface $messageManager * @throws \Magento\Framework\Exception\SessionException * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ @@ -84,11 +92,13 @@ public function __construct( \Magento\Framework\App\State $appState, \Magento\Framework\Acl\Builder $aclBuilder, \Magento\Backend\Model\UrlInterface $backendUrl, - \Magento\Backend\App\ConfigInterface $config + \Magento\Backend\App\ConfigInterface $config, + ManagerInterface $messageManager = null ) { $this->_config = $config; $this->_aclBuilder = $aclBuilder; $this->_backendUrl = $backendUrl; + $this->messageManager = $messageManager ?? ObjectManager::getInstance()->get(ManagerInterface::class); parent::__construct( $request, $sidResolver, @@ -171,6 +181,25 @@ public function isLoggedIn() */ public function prolong() { + $sessionUser = $this->getUser(); + $errorMessage = ''; + if ($sessionUser !== null) { + if ((int)$sessionUser->getIsActive() !== 1) { + $errorMessage = 'The account sign-in was incorrect or your account is disabled temporarily. ' + . 'Please wait and try again later.'; + } + if (!$sessionUser->hasAssigned2Role($sessionUser->getId())) { + $errorMessage = 'More permissions are needed to access this.'; + } + + if (!empty($errorMessage)) { + $this->destroy(); + $this->messageManager->addErrorMessage(__($errorMessage)); + + return; + } + } + $lifetime = $this->_config->getValue(self::XML_PATH_SESSION_LIFETIME); $cookieValue = $this->cookieManager->getCookie($this->getName()); diff --git a/app/code/Magento/Backend/Model/Dashboard/Period.php b/app/code/Magento/Backend/Model/Dashboard/Period.php index 28286129e8a68..2dc8c5de1a2d5 100644 --- a/app/code/Magento/Backend/Model/Dashboard/Period.php +++ b/app/code/Magento/Backend/Model/Dashboard/Period.php @@ -46,11 +46,11 @@ public function getDatePeriods(): array public function getPeriodChartUnits(): array { return [ - static::PERIOD_24_HOURS => static::PERIOD_UNIT_HOUR, - static::PERIOD_7_DAYS => static::PERIOD_UNIT_DAY, - static::PERIOD_1_MONTH => static::PERIOD_UNIT_DAY, - static::PERIOD_1_YEAR => static::PERIOD_UNIT_MONTH, - static::PERIOD_2_YEARS => static::PERIOD_UNIT_MONTH + static::PERIOD_24_HOURS => self::PERIOD_UNIT_HOUR, + static::PERIOD_7_DAYS => self::PERIOD_UNIT_DAY, + static::PERIOD_1_MONTH => self::PERIOD_UNIT_DAY, + static::PERIOD_1_YEAR => self::PERIOD_UNIT_MONTH, + static::PERIOD_2_YEARS => self::PERIOD_UNIT_MONTH ]; } } diff --git a/setup/src/Magento/Setup/Validator/IpValidator.php b/app/code/Magento/Backend/Model/Validator/IpValidator.php similarity index 97% rename from setup/src/Magento/Setup/Validator/IpValidator.php rename to app/code/Magento/Backend/Model/Validator/IpValidator.php index 5d1e83021e34b..f208d02ee140a 100644 --- a/setup/src/Magento/Setup/Validator/IpValidator.php +++ b/app/code/Magento/Backend/Model/Validator/IpValidator.php @@ -3,7 +3,7 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ -namespace Magento\Setup\Validator; +namespace Magento\Backend\Model\Validator; /** * Class to validate list of IPs for maintenance commands diff --git a/app/code/Magento/Backend/Model/Widget/Grid/AbstractTotals.php b/app/code/Magento/Backend/Model/Widget/Grid/AbstractTotals.php index bb68853dcc08d..dd92661eb9452 100644 --- a/app/code/Magento/Backend/Model/Widget/Grid/AbstractTotals.php +++ b/app/code/Magento/Backend/Model/Widget/Grid/AbstractTotals.php @@ -117,7 +117,7 @@ protected function _countExpr($expr, $collection) foreach ($parsedExpression as $operand) { if ($this->_parser->isOperation($operand)) { $this->_checkOperandsSet($firstOperand, $secondOperand, $tmpResult, $result); - $result = $this->_operate($firstOperand, $secondOperand, $operand, $tmpResult, $result); + $result = $this->_operate($firstOperand, $secondOperand, $operand); $firstOperand = $secondOperand = null; } else { if (null === $firstOperand) { @@ -133,9 +133,9 @@ protected function _countExpr($expr, $collection) /** * Check if operands in not null and set operands values if they are empty * - * @param float|int &$firstOperand - * @param float|int &$secondOperand - * @param float|int &$tmpResult + * @param float|int $firstOperand + * @param float|int $secondOperand + * @param float|int $tmpResult * @param float|int $result * @return void */ diff --git a/app/code/Magento/Backend/Test/Mftf/ActionGroup/AdminOpenConfigurationStoresPageActionGroup.xml b/app/code/Magento/Backend/Test/Mftf/ActionGroup/AdminOpenConfigurationStoresPageActionGroup.xml new file mode 100644 index 0000000000000..d38edd0b8a781 --- /dev/null +++ b/app/code/Magento/Backend/Test/Mftf/ActionGroup/AdminOpenConfigurationStoresPageActionGroup.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminOpenConfigurationStoresPageActionGroup"> + <annotations> + <description>Open configuration stores page.</description> + </annotations> + + <amOnPage url="{{AdminConfigurationStoresPage.url}}" stepKey="goToConfigurationStoresPage"/> + <waitForPageLoad stepKey="waitPageLoad"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Backend/Test/Mftf/ActionGroup/AdminReloadDashboardDataActionGroup.xml b/app/code/Magento/Backend/Test/Mftf/ActionGroup/AdminReloadDashboardDataActionGroup.xml new file mode 100644 index 0000000000000..c4ea56a09a7eb --- /dev/null +++ b/app/code/Magento/Backend/Test/Mftf/ActionGroup/AdminReloadDashboardDataActionGroup.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminReloadDashboardDataActionGroup"> + <annotations> + <description>Go to Admin Dashboard Page, and reload Dashboard data.</description> + </annotations> + + <amOnPage url="{{AdminDashboardPage.url}}" stepKey="amOnDashboardPage"/> + <click selector="{{AdminDashboardSection.dashboardButtonReloadData}}" stepKey="reloadDashboardData"/> + <waitForPageLoad stepKey="waitForPageToReload"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Backend/Test/Mftf/Page/AdminConfigurationStoresPage.xml b/app/code/Magento/Backend/Test/Mftf/Page/AdminConfigurationStoresPage.xml new file mode 100644 index 0000000000000..480f9e86c19f6 --- /dev/null +++ b/app/code/Magento/Backend/Test/Mftf/Page/AdminConfigurationStoresPage.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<pages xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/PageObject.xsd"> + <page name="AdminConfigurationStoresPage" url="admin/system_config/edit/section/cms/" area="admin" module="Catalog"> + <section name="WYSIWYGOptionsSection"/> + </page> +</pages> diff --git a/app/code/Magento/Backend/Test/Mftf/Page/AdminConfigurationStoresPage/ConfigurationStoresPage.xml b/app/code/Magento/Backend/Test/Mftf/Page/AdminConfigurationStoresPage/ConfigurationStoresPage.xml index 81eb04692cc57..85118a3197985 100644 --- a/app/code/Magento/Backend/Test/Mftf/Page/AdminConfigurationStoresPage/ConfigurationStoresPage.xml +++ b/app/code/Magento/Backend/Test/Mftf/Page/AdminConfigurationStoresPage/ConfigurationStoresPage.xml @@ -7,6 +7,7 @@ --> <pages xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/PageObject.xsd"> + <!-- @deprecated New Page was introduced. Please use "AdminConfigurationStoresPage" --> <page name="ConfigurationStoresPage" url="admin/system_config/edit/section/cms/" area="admin" module="Catalog"> <section name="WYSIWYGOptionsSection"/> </page> diff --git a/app/code/Magento/Backend/Test/Mftf/Section/AdminDashboardSection.xml b/app/code/Magento/Backend/Test/Mftf/Section/AdminDashboardSection.xml index e67025cfa68d5..cb5704413df22 100644 --- a/app/code/Magento/Backend/Test/Mftf/Section/AdminDashboardSection.xml +++ b/app/code/Magento/Backend/Test/Mftf/Section/AdminDashboardSection.xml @@ -17,5 +17,7 @@ <element name="dashboardDiagramAmountsContentTab" type="block" selector="#diagram_tab_amounts_content"/> <element name="dashboardDiagramTotals" type="text" selector="#diagram_tab_amounts_content"/> <element name="dashboardTotals" type="text" selector="//*[@class='dashboard-totals-label' and contains(text(), '{{columnName}}')]/../*[@class='dashboard-totals-value']" parameterized="true"/> + <element name="productInBestsellers" type="text" selector="#productsOrderedGrid_table td.col-product.col-name"/> + <element name="dashboardButtonReloadData" type="button" selector=".action-primary[title='Reload Data'][type='submit']"/> </section> </sections> diff --git a/app/code/Magento/Backend/Test/Mftf/Section/AdminHeaderSection.xml b/app/code/Magento/Backend/Test/Mftf/Section/AdminHeaderSection.xml index 186bb183d68d6..4ebb3316a0245 100644 --- a/app/code/Magento/Backend/Test/Mftf/Section/AdminHeaderSection.xml +++ b/app/code/Magento/Backend/Test/Mftf/Section/AdminHeaderSection.xml @@ -11,6 +11,8 @@ <section name="AdminHeaderSection"> <element name="pageTitle" type="text" selector=".page-header h1.page-title"/> <element name="adminUserAccountText" type="text" selector=".page-header .admin-user-account-text" /> + <element name="globalSearchInput" type="text" selector="#search-global" /> + <element name="globalSearchInputVisible" type="text" selector=".search-global-field._active #search-global" /> <!-- Legacy heading section. Mostly used for admin 404 and 403 pages --> <element name="pageHeading" type="text" selector=".page-content .page-heading"/> <!-- Used for page not found error --> diff --git a/app/code/Magento/Backend/Test/Mftf/Test/AdminDashboardWithChartsTest.xml b/app/code/Magento/Backend/Test/Mftf/Test/AdminDashboardWithChartsTest.xml index 439b6ac063618..44577771fe4f8 100644 --- a/app/code/Magento/Backend/Test/Mftf/Test/AdminDashboardWithChartsTest.xml +++ b/app/code/Magento/Backend/Test/Mftf/Test/AdminDashboardWithChartsTest.xml @@ -34,7 +34,6 @@ <!-- Reset admin order filter --> <comment userInput="Reset admin order filter" stepKey="resetAdminOrderFilter"/> <actionGroup ref="AdminOrdersGridClearFiltersActionGroup" stepKey="clearOrderFilters"/> - <waitForLoadingMaskToDisappear stepKey="waitForLoadingOrderGrid"/> <magentoCLI command="config:set admin/dashboard/enable_charts 0" stepKey="setDisableChartsAsDefault"/> <deleteData createDataKey="createProduct" stepKey="deleteProduct"/> <deleteData createDataKey="createCustomer" stepKey="deleteCustomer"/> @@ -84,8 +83,7 @@ <comment userInput="Create invoice" stepKey="createInvoice"/> <actionGroup ref="AdminOrderGridClickFirstRowActionGroup" stepKey="clickOrderRow"/> - <click selector="{{AdminOrderDetailsMainActionsSection.invoice}}" stepKey="clickInvoiceButton"/> - <waitForPageLoad stepKey="waitForInvoicePageToLoad"/> + <actionGroup ref="AdminClickInvoiceButtonOrderViewActionGroup" stepKey="clickInvoiceButton"/> <see selector="{{AdminHeaderSection.pageTitle}}" userInput="New Invoice" stepKey="seeNewInvoiceInPageTitle" after="clickInvoiceButton"/> <see selector="{{AdminInvoiceTotalSection.total('Subtotal')}}" userInput="$150.00" stepKey="seeCorrectGrandTotal"/> <actionGroup ref="AdminInvoiceClickSubmitActionGroup" stepKey="clickSubmitInvoice"/> diff --git a/app/code/Magento/Backend/Test/Mftf/Test/AdminExpireAdminSessionTest.xml b/app/code/Magento/Backend/Test/Mftf/Test/AdminExpireAdminSessionTest.xml index 2469151337bfe..b87b92e86528c 100644 --- a/app/code/Magento/Backend/Test/Mftf/Test/AdminExpireAdminSessionTest.xml +++ b/app/code/Magento/Backend/Test/Mftf/Test/AdminExpireAdminSessionTest.xml @@ -29,7 +29,7 @@ <!-- 2. Wait for session to expire. --> <actionGroup ref="AdminLoginActionGroup" stepKey="LoginAsAdmin"/> <wait time="60" stepKey="waitForSessionLifetime"/> - <reloadPage stepKey="reloadPage"/> + <actionGroup ref="ReloadPageActionGroup" stepKey="reloadPage"/> <!-- 3. Perform asserts. --> <seeElement selector="{{AdminLoginFormSection.loginBlock}}" stepKey="assertAdminLoginPageIsAvailable"/> diff --git a/app/code/Magento/Backend/Test/Mftf/Test/AdminExpireCustomerSessionTest.xml b/app/code/Magento/Backend/Test/Mftf/Test/AdminExpireCustomerSessionTest.xml index 0e3bf07d32441..b2b71c4ad3eca 100644 --- a/app/code/Magento/Backend/Test/Mftf/Test/AdminExpireCustomerSessionTest.xml +++ b/app/code/Magento/Backend/Test/Mftf/Test/AdminExpireCustomerSessionTest.xml @@ -40,7 +40,7 @@ <argument name="Customer" value="$$createCustomer$$" /> </actionGroup> <wait time="60" stepKey="waitForCookieLifetime"/> - <reloadPage stepKey="reloadPage"/> + <actionGroup ref="ReloadPageActionGroup" stepKey="reloadPage"/> <!-- 5. Perform asserts. --> <seeElement selector="{{StorefrontPanelHeaderSection.customerLoginLink}}" stepKey="assertAuthorizationLinkIsVisibleOnStoreFront"/> diff --git a/app/code/Magento/Backend/Test/Mftf/Test/AdminLoginAfterJSMinificationTest.xml b/app/code/Magento/Backend/Test/Mftf/Test/AdminLoginAfterJSMinificationTest.xml index fb58b59b0ccaa..af0a5751a7488 100644 --- a/app/code/Magento/Backend/Test/Mftf/Test/AdminLoginAfterJSMinificationTest.xml +++ b/app/code/Magento/Backend/Test/Mftf/Test/AdminLoginAfterJSMinificationTest.xml @@ -24,14 +24,15 @@ <actionGroup ref="CliCacheCleanActionGroup" stepKey="cleanCache"> <argument name="tags" value="config"/> </actionGroup> + <magentoCLI command="setup:static-content:deploy -f" stepKey="deployStaticContent"/> <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> </before> <after> <magentoCLI command="config:set {{MinifyJavaScriptFilesDisableConfigData.path}} {{MinifyJavaScriptFilesDisableConfigData.value}}" stepKey="disableJsMinification"/> <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> </after> - <see userInput="Dashboard" selector="{{AdminHeaderSection.pageTitle}}" stepKey="seeDashboardTitle"/> <waitForPageLoad stepKey="waitForPageLoadOnDashboard"/> + <see userInput="Dashboard" selector="{{AdminHeaderSection.pageTitle}}" stepKey="seeDashboardTitle"/> <actionGroup ref="AssertAdminSuccessLoginActionGroup" stepKey="loggedInSuccessfully"/> <actionGroup ref="AssertAdminPageIsNot404ActionGroup" stepKey="dontSee404Page"/> </test> diff --git a/app/code/Magento/Backend/Test/Mftf/Test/AdminLoginWithRestrictPermissionTest.xml b/app/code/Magento/Backend/Test/Mftf/Test/AdminLoginWithRestrictPermissionTest.xml index b3797b0720400..bc0e883bf5089 100644 --- a/app/code/Magento/Backend/Test/Mftf/Test/AdminLoginWithRestrictPermissionTest.xml +++ b/app/code/Magento/Backend/Test/Mftf/Test/AdminLoginWithRestrictPermissionTest.xml @@ -42,9 +42,8 @@ <actionGroup ref="AdminLogoutActionGroup" stepKey="logoutAsSaleRoleUser"/> <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> <!--Delete created data--> - <actionGroup ref="AdminUserOpenAdminRolesPageActionGroup" stepKey="navigateToUserRoleGrid"/> - <actionGroup ref="AdminDeleteRoleActionGroup" stepKey="deleteUserRole"> - <argument name="role" value="adminRole"/> + <actionGroup ref="AdminDeleteUserRoleActionGroup" stepKey="deleteUserRole"> + <argument name="roleName" value="{{adminRole.rolename}}"/> </actionGroup> <actionGroup ref="AdminOpenAdminUsersPageActionGroup" stepKey="goToAllUsersPage"/> <actionGroup ref="AdminDeleteNewUserActionGroup" stepKey="deleteUser"> diff --git a/app/code/Magento/Backend/Test/Mftf/Test/AdminMenuNavigationWithSecretKeysTest.xml b/app/code/Magento/Backend/Test/Mftf/Test/AdminMenuNavigationWithSecretKeysTest.xml index aa246cb5f9d22..45a49f58788fc 100644 --- a/app/code/Magento/Backend/Test/Mftf/Test/AdminMenuNavigationWithSecretKeysTest.xml +++ b/app/code/Magento/Backend/Test/Mftf/Test/AdminMenuNavigationWithSecretKeysTest.xml @@ -35,18 +35,21 @@ <click selector="{{AdminMenuSection.stores}}" stepKey="clickStoresMenuOption1"/> <waitForLoadingMaskToDisappear stepKey="waitForStoresMenu1" /> + <waitForElementVisible selector="{{AdminMenuSection.configuration}}" stepKey="waitForConfigurationVisible1"/> <click selector="{{AdminMenuSection.configuration}}" stepKey="clickStoresConfigurationMenuOption1"/> <waitForPageLoad stepKey="waitForConfigurationPageLoad1"/> <seeCurrentUrlMatches regex="~\/admin\/system_config\/~" stepKey="seeCurrentUrlMatchesConfigPath1"/> <click selector="{{AdminMenuSection.catalog}}" stepKey="clickCatalogMenuOption"/> <waitForLoadingMaskToDisappear stepKey="waitForCatalogMenu1" /> + <waitForElementVisible selector="{{AdminMenuSection.catalogProducts}}" stepKey="waitForCatalogProductsVisible"/> <click selector="{{AdminMenuSection.catalogProducts}}" stepKey="clickCatalogProductsMenuOption"/> <waitForPageLoad stepKey="waitForProductsPageLoad"/> <seeCurrentUrlMatches regex="~\/catalog\/product\/~" stepKey="seeCurrentUrlMatchesProductsPath"/> <click selector="{{AdminMenuSection.stores}}" stepKey="clickStoresMenuOption2"/> <waitForLoadingMaskToDisappear stepKey="waitForStoresMenu2" /> + <waitForElementVisible selector="{{AdminMenuSection.configuration}}" stepKey="waitForConfigurationVisible2"/> <click selector="{{AdminMenuSection.configuration}}" stepKey="clickStoresConfigurationMenuOption2"/> <waitForPageLoad stepKey="waitForConfigurationPageLoad2"/> <seeCurrentUrlMatches regex="~\/admin\/system_config\/~" stepKey="seeCurrentUrlMatchesConfigPath2"/> diff --git a/app/code/Magento/Backend/Test/Mftf/Test/AdminSearchHotkeyTest.xml b/app/code/Magento/Backend/Test/Mftf/Test/AdminSearchHotkeyTest.xml new file mode 100644 index 0000000000000..89e8668fa3c23 --- /dev/null +++ b/app/code/Magento/Backend/Test/Mftf/Test/AdminSearchHotkeyTest.xml @@ -0,0 +1,34 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminSearchHotkeyTest"> + <annotations> + <features value="Backend"/> + <stories value="Search form hotkey in backend"/> + <title value="Admin should be able focus on the search field with a hotkey"/> + <description value="Admin should be able focus on the search field with a hotkey - forwardslash"/> + <severity value="MINOR"/> + <group value="backend"/> + <group value="search"/> + </annotations> + <before> + <actionGroup ref="AdminLoginActionGroup" stepKey="LoginAsAdmin"/> + </before> + <after> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> + </after> + + <pressKey selector="body" parameterArray="[/]" stepKey="pressForwardslashKey"/> + <seeElement selector="{{AdminHeaderSection.globalSearchInputVisible}}" stepKey="seeActiveGlobalSearchInput"/> + <seeInField userInput="" selector="{{AdminHeaderSection.globalSearchInput}}" stepKey="seeEmptyGlobalSearchInput"/> + <pressKey selector="{{AdminHeaderSection.globalSearchInput}}" parameterArray="[/]" stepKey="pressForwardslashKeyAgain"/> + <seeInField userInput="/" selector="{{AdminHeaderSection.globalSearchInput}}" stepKey="seeForwardSlashInGlobalSearchInput"/> + </test> +</tests> diff --git a/setup/src/Magento/Setup/Test/Unit/Console/Command/MaintenanceAllowIpsCommandTest.php b/app/code/Magento/Backend/Test/Unit/Console/Command/MaintenanceAllowIpsCommandTest.php similarity index 96% rename from setup/src/Magento/Setup/Test/Unit/Console/Command/MaintenanceAllowIpsCommandTest.php rename to app/code/Magento/Backend/Test/Unit/Console/Command/MaintenanceAllowIpsCommandTest.php index 2a18a892ed06d..281065c51337d 100644 --- a/setup/src/Magento/Setup/Test/Unit/Console/Command/MaintenanceAllowIpsCommandTest.php +++ b/app/code/Magento/Backend/Test/Unit/Console/Command/MaintenanceAllowIpsCommandTest.php @@ -5,11 +5,11 @@ */ declare(strict_types=1); -namespace Magento\Setup\Test\Unit\Console\Command; +namespace Magento\Backend\Test\Unit\Console\Command; +use Magento\Backend\Console\Command\MaintenanceAllowIpsCommand; use Magento\Framework\App\MaintenanceMode; -use Magento\Setup\Console\Command\MaintenanceAllowIpsCommand; -use Magento\Setup\Validator\IpValidator; +use Magento\Backend\Model\Validator\IpValidator; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Tester\CommandTester; diff --git a/setup/src/Magento/Setup/Test/Unit/Console/Command/MaintenanceDisableCommandTest.php b/app/code/Magento/Backend/Test/Unit/Console/Command/MaintenanceDisableCommandTest.php similarity index 95% rename from setup/src/Magento/Setup/Test/Unit/Console/Command/MaintenanceDisableCommandTest.php rename to app/code/Magento/Backend/Test/Unit/Console/Command/MaintenanceDisableCommandTest.php index 73afa22f3ebcd..6663a7f9f6504 100644 --- a/setup/src/Magento/Setup/Test/Unit/Console/Command/MaintenanceDisableCommandTest.php +++ b/app/code/Magento/Backend/Test/Unit/Console/Command/MaintenanceDisableCommandTest.php @@ -5,11 +5,11 @@ */ declare(strict_types=1); -namespace Magento\Setup\Test\Unit\Console\Command; +namespace Magento\Backend\Test\Unit\Console\Command; +use Magento\Backend\Console\Command\MaintenanceDisableCommand; use Magento\Framework\App\MaintenanceMode; -use Magento\Setup\Console\Command\MaintenanceDisableCommand; -use Magento\Setup\Validator\IpValidator; +use Magento\Backend\Model\Validator\IpValidator; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Tester\CommandTester; diff --git a/setup/src/Magento/Setup/Test/Unit/Console/Command/MaintenanceEnableCommandTest.php b/app/code/Magento/Backend/Test/Unit/Console/Command/MaintenanceEnableCommandTest.php similarity index 93% rename from setup/src/Magento/Setup/Test/Unit/Console/Command/MaintenanceEnableCommandTest.php rename to app/code/Magento/Backend/Test/Unit/Console/Command/MaintenanceEnableCommandTest.php index 0b1afb7310c08..c4a2e35d37d49 100644 --- a/setup/src/Magento/Setup/Test/Unit/Console/Command/MaintenanceEnableCommandTest.php +++ b/app/code/Magento/Backend/Test/Unit/Console/Command/MaintenanceEnableCommandTest.php @@ -5,11 +5,11 @@ */ declare(strict_types=1); -namespace Magento\Setup\Test\Unit\Console\Command; +namespace Magento\Backend\Test\Unit\Console\Command; +use Magento\Backend\Console\Command\MaintenanceEnableCommand; use Magento\Framework\App\MaintenanceMode; -use Magento\Setup\Console\Command\MaintenanceEnableCommand; -use Magento\Setup\Validator\IpValidator; +use Magento\Backend\Model\Validator\IpValidator; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Tester\CommandTester; diff --git a/setup/src/Magento/Setup/Test/Unit/Console/Command/MaintenanceStatusCommandTest.php b/app/code/Magento/Backend/Test/Unit/Console/Command/MaintenanceStatusCommandTest.php similarity index 95% rename from setup/src/Magento/Setup/Test/Unit/Console/Command/MaintenanceStatusCommandTest.php rename to app/code/Magento/Backend/Test/Unit/Console/Command/MaintenanceStatusCommandTest.php index 731eff370b00f..8e3970aa5529e 100644 --- a/setup/src/Magento/Setup/Test/Unit/Console/Command/MaintenanceStatusCommandTest.php +++ b/app/code/Magento/Backend/Test/Unit/Console/Command/MaintenanceStatusCommandTest.php @@ -5,10 +5,10 @@ */ declare(strict_types=1); -namespace Magento\Setup\Test\Unit\Console\Command; +namespace Magento\Backend\Test\Unit\Console\Command; +use Magento\Backend\Console\Command\MaintenanceStatusCommand; use Magento\Framework\App\MaintenanceMode; -use Magento\Setup\Console\Command\MaintenanceStatusCommand; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Tester\CommandTester; diff --git a/app/code/Magento/Backend/Test/Unit/Helper/DataTest.php b/app/code/Magento/Backend/Test/Unit/Helper/DataTest.php index cfeed42b11ba1..58fec039a90fe 100644 --- a/app/code/Magento/Backend/Test/Unit/Helper/DataTest.php +++ b/app/code/Magento/Backend/Test/Unit/Helper/DataTest.php @@ -42,7 +42,6 @@ protected function setUp(): void $this->createMock(Auth::class), $this->_frontResolverMock, $this->createMock(Random::class), - $this->getMockForAbstractClass(RequestInterface::class) ); } diff --git a/setup/src/Magento/Setup/Test/Unit/Validator/IpValidatorTest.php b/app/code/Magento/Backend/Test/Unit/Model/Validator/IpValidatorTest.php similarity index 79% rename from setup/src/Magento/Setup/Test/Unit/Validator/IpValidatorTest.php rename to app/code/Magento/Backend/Test/Unit/Model/Validator/IpValidatorTest.php index b6f9f01c80ee5..ccffc58d79780 100644 --- a/setup/src/Magento/Setup/Test/Unit/Validator/IpValidatorTest.php +++ b/app/code/Magento/Backend/Test/Unit/Model/Validator/IpValidatorTest.php @@ -5,11 +5,14 @@ */ declare(strict_types=1); -namespace Magento\Setup\Test\Unit\Validator; +namespace Magento\Backend\Test\Unit\Model\Validator; -use Magento\Setup\Validator\IpValidator; +use Magento\Backend\Model\Validator\IpValidator; use PHPUnit\Framework\TestCase; +/** + * @see IpValidator + */ class IpValidatorTest extends TestCase { /** @@ -17,6 +20,9 @@ class IpValidatorTest extends TestCase */ private $ipValidator; + /** + * @inheritDoc + */ protected function setUp(): void { $this->ipValidator = new IpValidator(); @@ -27,15 +33,15 @@ protected function setUp(): void * @param string[] $ips * @param string[] $expectedMessages */ - public function testValidateIpsNoneAllowed($ips, $expectedMessages) + public function testValidateIpsNoneAllowed(array $ips, array $expectedMessages): void { - $this->assertEquals($expectedMessages, $this->ipValidator->validateIps($ips, true)); + self::assertEquals($expectedMessages, $this->ipValidator->validateIps($ips, true)); } /** * @return array */ - public function validateIpsNoneAllowedDataProvider() + public function validateIpsNoneAllowedDataProvider(): array { return [ [['127.0.0.1', '127.0.0.2'], []], @@ -54,9 +60,9 @@ public function validateIpsNoneAllowedDataProvider() * @param string[] $ips * @param string[] $expectedMessages */ - public function testValidateIpsNoneNotAllowed($ips, $expectedMessages) + public function testValidateIpsNoneNotAllowed($ips, $expectedMessages): void { - $this->assertEquals($expectedMessages, $this->ipValidator->validateIps($ips, false)); + self::assertEquals($expectedMessages, $this->ipValidator->validateIps($ips, false)); } /** diff --git a/app/code/Magento/Backend/ViewModel/ChartDisabled.php b/app/code/Magento/Backend/ViewModel/ChartDisabled.php index f71a2d26441ef..aeb7767c56578 100644 --- a/app/code/Magento/Backend/ViewModel/ChartDisabled.php +++ b/app/code/Magento/Backend/ViewModel/ChartDisabled.php @@ -57,7 +57,7 @@ public function __construct( public function getConfigUrl(): string { return $this->urlBuilder->getUrl( - static::ROUTE_SYSTEM_CONFIG, + self::ROUTE_SYSTEM_CONFIG, ['section' => 'admin', '_fragment' => 'admin_dashboard-link'] ); } @@ -70,7 +70,7 @@ public function getConfigUrl(): string public function isChartEnabled(): bool { return $this->scopeConfig->isSetFlag( - static::XML_PATH_ENABLE_CHARTS, + self::XML_PATH_ENABLE_CHARTS, ScopeInterface::SCOPE_STORE ); } diff --git a/app/code/Magento/Backend/cli_commands.php b/app/code/Magento/Backend/cli_commands.php new file mode 100644 index 0000000000000..3c4140b40a993 --- /dev/null +++ b/app/code/Magento/Backend/cli_commands.php @@ -0,0 +1,8 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +if (PHP_SAPI === 'cli') { + \Magento\Framework\Console\CommandLocator::register(\Magento\Backend\Console\CommandList::class); +} diff --git a/app/code/Magento/Backend/composer.json b/app/code/Magento/Backend/composer.json index ee5491057d861..29a78d9ae0ab0 100644 --- a/app/code/Magento/Backend/composer.json +++ b/app/code/Magento/Backend/composer.json @@ -10,6 +10,7 @@ "magento/module-backup": "*", "magento/module-catalog": "*", "magento/module-config": "*", + "magento/module-cms": "*", "magento/module-customer": "*", "magento/module-developer": "*", "magento/module-directory": "*", @@ -34,7 +35,8 @@ ], "autoload": { "files": [ - "registration.php" + "registration.php", + "cli_commands.php" ], "psr-4": { "Magento\\Backend\\": "" diff --git a/app/code/Magento/Backend/etc/di.xml b/app/code/Magento/Backend/etc/di.xml index 65f73f028eb20..1297bd9603a1f 100644 --- a/app/code/Magento/Backend/etc/di.xml +++ b/app/code/Magento/Backend/etc/di.xml @@ -150,6 +150,10 @@ <item name="cacheFlushCommand" xsi:type="object">Magento\Backend\Console\Command\CacheFlushCommand</item> <item name="cacheCleanCommand" xsi:type="object">Magento\Backend\Console\Command\CacheCleanCommand</item> <item name="cacheStatusCommand" xsi:type="object">Magento\Backend\Console\Command\CacheStatusCommand</item> + <item name="maintenanceAllowIps" xsi:type="object">Magento\Backend\Console\Command\MaintenanceAllowIpsCommand</item> + <item name="maintenanceDisable" xsi:type="object">Magento\Backend\Console\Command\MaintenanceDisableCommand</item> + <item name="maintenanceEnableCommand" xsi:type="object">Magento\Backend\Console\Command\MaintenanceDisableCommand</item> + <item name="maintenanceStatusCommand" xsi:type="object">Magento\Backend\Console\Command\MaintenanceStatusCommand</item> </argument> </arguments> </type> diff --git a/app/code/Magento/Backend/view/adminhtml/layout/default.xml b/app/code/Magento/Backend/view/adminhtml/layout/default.xml index 0d629e31d6d91..5da9e33dfee36 100644 --- a/app/code/Magento/Backend/view/adminhtml/layout/default.xml +++ b/app/code/Magento/Backend/view/adminhtml/layout/default.xml @@ -17,6 +17,7 @@ <body> <attribute name="id" value="html-body"/> <block name="require.js" class="Magento\Backend\Block\Page\RequireJs" template="Magento_Backend::page/js/require_js.phtml"/> + <block class="Magento\Framework\View\Element\Template" name="head.additional" template="Magento_Backend::page/container.phtml"/> <referenceContainer name="global.notices"> <block class="Magento\Backend\Block\Page\Notices" name="global_notices" as="global_notices" template="Magento_Backend::page/notices.phtml"/> </referenceContainer> diff --git a/app/code/Magento/Backend/view/adminhtml/templates/page/container.phtml b/app/code/Magento/Backend/view/adminhtml/templates/page/container.phtml new file mode 100644 index 0000000000000..6da55e4f8f8b1 --- /dev/null +++ b/app/code/Magento/Backend/view/adminhtml/templates/page/container.phtml @@ -0,0 +1,7 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +?> +<?= $block->getChildHtml(); ?> diff --git a/app/code/Magento/Backend/view/adminhtml/templates/widget/tabshoriz.phtml b/app/code/Magento/Backend/view/adminhtml/templates/widget/tabshoriz.phtml index c51b357091bda..9a3c941fdc9ed 100644 --- a/app/code/Magento/Backend/view/adminhtml/templates/widget/tabshoriz.phtml +++ b/app/code/Magento/Backend/view/adminhtml/templates/widget/tabshoriz.phtml @@ -4,45 +4,52 @@ * See COPYING.txt for license details. */ -/** @var \Magento\Framework\View\Helper\SecureHtmlRenderer $secureRenderer */ +use Magento\Framework\Escaper; +use Magento\Framework\View\Helper\SecureHtmlRenderer; + +/** + * @var SecureHtmlRenderer $secureRenderer + * @var Escaper $escaper + */ ?> -<!-- <?php if ($block->getTitle()): ?> - <h3><?= $block->escapeHtml($block->getTitle()) ?></h3> -<?php endif ?> --> <?php if (!empty($tabs)): ?> -<div id="<?= $block->escapeHtmlAttr($block->getId()) ?>"> + <?php $blockId = $block->getId() ?> +<div id="<?= $escaper->escapeHtmlAttr($blockId) ?>" class="hidden"> <ul class="tabs-horiz"> <?php foreach ($tabs as $_tab): ?> + <?php $tabId = $block->getTabId($_tab) ?> <?php $_tabClass = 'tab-item-link ' . $block->getTabClass($_tab) . ' ' . (preg_match('/\s?ajax\s?/', $_tab->getClass()) ? 'notloaded' : '') ?> <?php $_tabType = (!preg_match('/\s?ajax\s?/', $_tabClass) && $block->getTabUrl($_tab) != '#') ? 'link' : '' ?> <?php $_tabHref = $block->getTabUrl($_tab) == '#' ? - '#' . $block->getTabId($_tab) . '_content' : + '#' . $tabId . '_content' : $block->getTabUrl($_tab) ?> <li> - <a href="<?= $block->escapeUrl($_tabHref) ?>" - id="<?= $block->escapeHtmlAttr($block->getTabId($_tab)) ?>" - title="<?= $block->escapeHtmlAttr($block->getTabTitle($_tab)) ?>" - class="<?= $block->escapeHtmlAttr($_tabClass) ?>" - data-tab-type="<?= $block->escapeHtmlAttr($_tabType) ?>"> + <a href="<?= $escaper->escapeUrl($_tabHref) ?>" + id="<?= $escaper->escapeHtmlAttr($tabId) ?>" + title="<?= $escaper->escapeHtmlAttr($block->getTabTitle($_tab)) ?>" + class="<?= $escaper->escapeHtmlAttr($_tabClass) ?>" + data-tab-type="<?= $escaper->escapeHtmlAttr($_tabType) ?>"> <span> <span class="changed" - title="<?= $block->escapeHtmlAttr(__('The information in this tab has been changed.')) ?>"></span> + title="<?= $escaper->escapeHtmlAttr(__( + 'The information in this tab has been changed.' + )) ?>"></span> <span class="error" - title="<?= $block->escapeHtmlAttr(__( + title="<?= $escaper->escapeHtmlAttr(__( 'This tab contains invalid data. Please resolve this before saving.' )) ?>"></span> <span class="loader" - title="<?= $block->escapeHtmlAttr(__('Loading...')) ?>"></span> - <?= $block->escapeHtml($block->getTabLabel($_tab)) ?> + title="<?= $escaper->escapeHtmlAttr(__('Loading...')) ?>"></span> + <?= $escaper->escapeHtml($block->getTabLabel($_tab)) ?> </span> </a> - <div id="<?= $block->escapeHtmlAttr($block->getTabId($_tab)) ?>_content"> + <div id="<?= $escaper->escapeHtmlAttr($tabId) ?>_content"> <?= /* @noEscape */ $block->getTabContent($_tab) ?> </div> <?= /* @noEscape */ $secureRenderer->renderStyleAsTag( 'display:none', - '#' . $block->escapeJs($block->getTabId($_tab)) . '_content' + '#' . $escaper->escapeJs($tabId) . '_content' ); ?> </li> <?php endforeach; ?> @@ -51,11 +58,12 @@ <?php $scriptString = <<<script require(["jquery","mage/backend/tabs"], function($){ $(function() { - $('#{$block->getId()}').tabs({ - active: '{$block->getActiveTabId()}', - destination: '#{$block->getDestElementId()}', + $('#{$escaper->escapeJs($blockId)}').tabs({ + active: '{$escaper->escapeJs($block->getActiveTabId())}', + destination: '#{$escaper->escapeJs($block->getDestElementId())}', shadowTabs: {$block->getAllShadowTabs()} }); + $('#{$escaper->escapeJs($blockId)}').removeClass('hidden'); }); }); script; diff --git a/app/code/Magento/Backend/view/adminhtml/web/js/media-uploader.js b/app/code/Magento/Backend/view/adminhtml/web/js/media-uploader.js index 119e7a35747cb..c22c1788cdd29 100644 --- a/app/code/Magento/Backend/view/adminhtml/web/js/media-uploader.js +++ b/app/code/Magento/Backend/view/adminhtml/web/js/media-uploader.js @@ -37,14 +37,14 @@ define([ progressTmpl = mageTemplate('[data-template="uploader"]'), isResizeEnabled = this.options.isResizeEnabled, resizeConfiguration = { - action: 'resize', + action: 'resizeImage', maxWidth: this.options.maxWidth, maxHeight: this.options.maxHeight }; if (!isResizeEnabled) { resizeConfiguration = { - action: 'resize' + action: 'resizeImage' }; } @@ -131,13 +131,13 @@ define([ }); this.element.find('input[type=file]').fileupload('option', { - process: [{ - action: 'load', + processQueue: [{ + action: 'loadImage', fileTypes: /^image\/(gif|jpeg|png)$/ }, resizeConfiguration, { - action: 'save' + action: 'saveImage' }] }); } diff --git a/app/code/Magento/Backup/Model/Fs/Collection.php b/app/code/Magento/Backup/Model/Fs/Collection.php index b17c17f7074fb..41a497495f687 100644 --- a/app/code/Magento/Backup/Model/Fs/Collection.php +++ b/app/code/Magento/Backup/Model/Fs/Collection.php @@ -45,6 +45,7 @@ class Collection extends \Magento\Framework\Data\Collection\Filesystem * @param \Magento\Backup\Helper\Data $backupData * @param \Magento\Framework\Filesystem $filesystem * @param \Magento\Backup\Model\Backup $backup + * @throws \Magento\Framework\Exception\FileSystemException */ public function __construct( \Magento\Framework\Data\Collection\EntityFactory $entityFactory, @@ -53,7 +54,7 @@ public function __construct( \Magento\Backup\Model\Backup $backup ) { $this->_backupData = $backupData; - parent::__construct($entityFactory); + parent::__construct($entityFactory, $filesystem); $this->_filesystem = $filesystem; $this->_backup = $backup; diff --git a/app/code/Magento/Backup/Test/Unit/Model/Fs/CollectionTest.php b/app/code/Magento/Backup/Test/Unit/Model/Fs/CollectionTest.php index 69e2fcb6e1f25..cec0ccff70ce6 100644 --- a/app/code/Magento/Backup/Test/Unit/Model/Fs/CollectionTest.php +++ b/app/code/Magento/Backup/Test/Unit/Model/Fs/CollectionTest.php @@ -10,6 +10,7 @@ use Magento\Backup\Helper\Data; use Magento\Backup\Model\Fs\Collection; use Magento\Framework\Filesystem; +use Magento\Framework\Filesystem\Directory\TargetDirectory; use Magento\Framework\Filesystem\Directory\WriteInterface; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; use PHPUnit\Framework\TestCase; @@ -36,10 +37,23 @@ public function testConstructor() $directoryWrite->expects($this->any())->method('create')->with('backups'); $directoryWrite->expects($this->any())->method('getAbsolutePath')->with('backups'); - + $directoryWrite->expects($this->any())->method('isDirectory')->willReturn(true); + $targetDirectory = $this->getMockBuilder(TargetDirectory::class) + ->disableOriginalConstructor() + ->getMock(); + $targetDirectoryWrite = $this->getMockBuilder(WriteInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $targetDirectoryWrite->expects($this->any())->method('isDirectory')->willReturn(true); + $targetDirectory->expects($this->any())->method('getDirectoryWrite')->willReturn($targetDirectoryWrite); $classObject = $helper->getObject( Collection::class, - ['filesystem' => $filesystem, 'backupData' => $backupData] + [ + 'filesystem' => $filesystem, + 'backupData' => $backupData, + 'directoryWrite' => $directoryWrite, + 'targetDirectory' => $targetDirectory + ] ); $this->assertNotNull($classObject); } diff --git a/app/code/Magento/Bundle/Model/Product/Type.php b/app/code/Magento/Bundle/Model/Product/Type.php index fe120e9a179dd..6ee67859db015 100644 --- a/app/code/Magento/Bundle/Model/Product/Type.php +++ b/app/code/Magento/Bundle/Model/Product/Type.php @@ -13,6 +13,7 @@ use Magento\Catalog\Api\ProductRepositoryInterface; use Magento\Framework\App\ObjectManager; use Magento\Framework\EntityManager\MetadataPool; +use Magento\Framework\File\UploaderFactory; use Magento\Framework\Pricing\PriceCurrencyInterface; use Magento\Framework\Serialize\Serializer\Json; use Magento\Framework\Stdlib\ArrayUtils; @@ -190,11 +191,11 @@ class Type extends \Magento\Catalog\Model\Product\Type\AbstractType * @param PriceCurrencyInterface $priceCurrency * @param \Magento\CatalogInventory\Api\StockRegistryInterface $stockRegistry * @param \Magento\CatalogInventory\Api\StockStateInterface $stockState - * @param \Magento\Framework\Serialize\Serializer\Json $serializer + * @param Json|null $serializer * @param MetadataPool|null $metadataPool * @param SelectionCollectionFilterApplier|null $selectionCollectionFilterApplier * @param ArrayUtils|null $arrayUtility - * + * @param UploaderFactory $uploaderFactory * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -222,7 +223,8 @@ public function __construct( Json $serializer = null, MetadataPool $metadataPool = null, SelectionCollectionFilterApplier $selectionCollectionFilterApplier = null, - ArrayUtils $arrayUtility = null + ArrayUtils $arrayUtility = null, + UploaderFactory $uploaderFactory = null ) { $this->_catalogProduct = $catalogProduct; $this->_catalogData = $catalogData; @@ -254,7 +256,8 @@ public function __construct( $coreRegistry, $logger, $productRepository, - $serializer + $serializer, + $uploaderFactory ); } diff --git a/app/code/Magento/Bundle/Model/ResourceModel/Indexer/Price.php b/app/code/Magento/Bundle/Model/ResourceModel/Indexer/Price.php index 96d68d7e74117..55bef4980098b 100644 --- a/app/code/Magento/Bundle/Model/ResourceModel/Indexer/Price.php +++ b/app/code/Magento/Bundle/Model/ResourceModel/Indexer/Price.php @@ -17,6 +17,7 @@ use Magento\Customer\Model\Indexer\CustomerGroupDimensionProvider; use Magento\Store\Model\Indexer\WebsiteDimensionProvider; use Magento\Catalog\Model\Product\Attribute\Source\Status; +use Magento\CatalogInventory\Model\Stock; /** * Bundle products Price indexer resource model @@ -624,6 +625,13 @@ private function calculateDynamicBundleSelectionPrice($dimensions) 'tier_price' => $tierExpr, ] ); + $select->join( + ['si' => $this->getTable('cataloginventory_stock_status')], + 'si.product_id = bs.product_id', + [] + ); + $select->where('si.stock_status = ?', Stock::STOCK_IN_STOCK); + $this->tableMaintainer->insertFromSelect($select, $this->getBundleSelectionTable(), []); } diff --git a/app/code/Magento/Bundle/Test/Mftf/Test/AdminAddBundleItemsTest.xml b/app/code/Magento/Bundle/Test/Mftf/Test/AdminAddBundleItemsTest.xml index a79bd333499af..26119c5267d86 100644 --- a/app/code/Magento/Bundle/Test/Mftf/Test/AdminAddBundleItemsTest.xml +++ b/app/code/Magento/Bundle/Test/Mftf/Test/AdminAddBundleItemsTest.xml @@ -86,8 +86,7 @@ <!--Add another bundle option with 2 items--> <!--Go to bundle product creation page--> - <amOnPage url="{{AdminCatalogProductPage.url}}" stepKey="GoToCatalogProductPage"/> - <waitForPageLoad stepKey="WaitForPageToLoad"/> + <actionGroup ref="AdminOpenCatalogProductPageActionGroup" stepKey="goToCatalogProductPage"/> <conditionalClick selector="{{AdminProductFiltersSection.filtersClear}}" dependentSelector="{{AdminProductFiltersSection.filtersClear}}" visible="true" stepKey="ClickOnButtonToRemoveFiltersIfPresent"/> <waitForPageLoad stepKey="WaitForClear"/> <actionGroup ref="FilterProductGridByNameActionGroup" stepKey="filterBundleProductOptionsDownToName"> diff --git a/app/code/Magento/Bundle/Test/Mftf/Test/AdminAttributeSetSelectionTest.xml b/app/code/Magento/Bundle/Test/Mftf/Test/AdminAttributeSetSelectionTest.xml index 33bfa455e2bdf..ca8a35ee7a363 100644 --- a/app/code/Magento/Bundle/Test/Mftf/Test/AdminAttributeSetSelectionTest.xml +++ b/app/code/Magento/Bundle/Test/Mftf/Test/AdminAttributeSetSelectionTest.xml @@ -54,8 +54,7 @@ <!--Testing that price appears correctly in admin catalog--> <!--Set filter to product name--> - <amOnPage url="{{AdminCatalogProductPage.url}}" stepKey="GoToCatalogProductPage"/> - <waitForPageLoad stepKey="WaitForPageToLoad"/> + <actionGroup ref="AdminOpenCatalogProductPageActionGroup" stepKey="goToCatalogProductPage"/> <actionGroup ref="FilterProductGridByNameActionGroup" stepKey="filterBundleProductOptionsDownToName"> <argument name="product" value="BundleProduct"/> </actionGroup> @@ -75,8 +74,7 @@ <!--Testing that price appears correctly in admin catalog--> <!--Set filter to product name--> - <amOnPage url="{{AdminCatalogProductPage.url}}" stepKey="GoToCatalogProductPage2"/> - <waitForPageLoad stepKey="WaitForPageToLoad2"/> + <actionGroup ref="AdminOpenCatalogProductPageActionGroup" stepKey="goToCatalogProductPage2"/> <actionGroup ref="FilterProductGridByNameActionGroup" stepKey="filterBundleProductOptionsDownToName2"> <argument name="product" value="BundleProduct"/> </actionGroup> diff --git a/app/code/Magento/Bundle/Test/Mftf/Test/AdminBasicBundleProductAttributesTest.xml b/app/code/Magento/Bundle/Test/Mftf/Test/AdminBasicBundleProductAttributesTest.xml index 41b372cf150a0..79d85c6ced957 100644 --- a/app/code/Magento/Bundle/Test/Mftf/Test/AdminBasicBundleProductAttributesTest.xml +++ b/app/code/Magento/Bundle/Test/Mftf/Test/AdminBasicBundleProductAttributesTest.xml @@ -96,8 +96,7 @@ </actionGroup> <!--Filter catalog--> - <amOnPage url="{{AdminCatalogProductPage.url}}" stepKey="goToCatalogProductPage"/> - <waitForPageLoad stepKey="WaitForPageToLoad"/> + <actionGroup ref="AdminOpenCatalogProductPageActionGroup" stepKey="goToCatalogProductPage"/> <actionGroup ref="FilterProductGridByNameActionGroup" stepKey="filterBundleProductOptionsDownToName"> <argument name="product" value="BundleProduct"/> </actionGroup> diff --git a/app/code/Magento/Bundle/Test/Mftf/Test/AdminDeleteABundleProductTest.xml b/app/code/Magento/Bundle/Test/Mftf/Test/AdminDeleteABundleProductTest.xml index 66295e148b40d..83db83949f059 100644 --- a/app/code/Magento/Bundle/Test/Mftf/Test/AdminDeleteABundleProductTest.xml +++ b/app/code/Magento/Bundle/Test/Mftf/Test/AdminDeleteABundleProductTest.xml @@ -58,9 +58,7 @@ <actionGroup ref="AdminProductFormSaveActionGroup" stepKey="clickSaveButton"/> <seeElement selector="{{AdminCategoryMessagesSection.SuccessMessage}}" stepKey="messageYouSavedTheProductIsShown"/> - <!--Go to catalog deletion page--> - <amOnPage url="{{AdminCatalogProductPage.url}}" stepKey="goToCatalogPage"/> - <waitForPageLoad stepKey="Loading"/> + <actionGroup ref="AdminOpenCatalogProductPageActionGroup" stepKey="goToCatalogPage"/> <!--Apply Name Filter--> <actionGroup ref="FilterProductGridByNameActionGroup" stepKey="filterBundleProductOptionsDownToName"> diff --git a/app/code/Magento/Bundle/Test/Mftf/Test/AdminDeleteBundleDynamicPriceProductTest.xml b/app/code/Magento/Bundle/Test/Mftf/Test/AdminDeleteBundleDynamicPriceProductTest.xml new file mode 100644 index 0000000000000..8b50fffec091f --- /dev/null +++ b/app/code/Magento/Bundle/Test/Mftf/Test/AdminDeleteBundleDynamicPriceProductTest.xml @@ -0,0 +1,67 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminDeleteBundleDynamicPriceProductTest"> + <annotations> + <features value="Bundle"/> + <stories value="Delete products"/> + <title value="Delete Bundle Dynamic Product"/> + <description value="Admin should be able to delete a bundle dynamic product"/> + <severity value="CRITICAL"/> + <testCaseId value="MC-26056"/> + <group value="mtf_migrated"/> + <group value="bundle"/> + </annotations> + <before> + <!-- Create category and simple product --> + <createData entity="SimpleSubCategory" stepKey="createCategory"/> + <createData entity="SimpleProduct2" stepKey="createSimpleProduct"/> + + <!-- Create bundle product --> + <createData entity="ApiBundleProductPriceViewRange" stepKey="createDynamicBundleProduct"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="DropDownBundleOption" stepKey="bundleOption"> + <requiredEntity createDataKey="createDynamicBundleProduct"/> + </createData> + <createData entity="ApiBundleLink" stepKey="createNewBundleLink"> + <requiredEntity createDataKey="createDynamicBundleProduct"/> + <requiredEntity createDataKey="bundleOption"/> + <requiredEntity createDataKey="createSimpleProduct"/> + </createData> + <!-- TODO: Remove this action when MC-37719 will be fixed --> + <actionGroup ref="CliIndexerReindexActionGroup" stepKey="reindexInvalidatedIndices"> + <argument name="indices" value="cataloginventory_stock"/> + </actionGroup> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + </before> + <after> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + <deleteData createDataKey="createSimpleProduct" stepKey="deleteSimpleProduct"/> + <magentoCron groups="index" stepKey="reindexInvalidatedIndices"/> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> + </after> + <actionGroup ref="DeleteProductBySkuActionGroup" stepKey="deleteBundleProductBySku"> + <argument name="sku" value="$createDynamicBundleProduct.sku$"/> + </actionGroup> + <!-- Verify product on Product Page --> + <amOnPage url="{{StorefrontProductPage.url($createDynamicBundleProduct.custom_attributes[url_key]$)}}" stepKey="openBundleProductPage"/> + <see selector="{{StorefrontProductInfoMainSection.productName}}" userInput="Whoops, our bad..." stepKey="seeWhoopsMessage"/> + <!-- Search for the product by sku --> + <actionGroup ref="StoreFrontQuickSearchActionGroup" stepKey="searchBySku"> + <argument name="query" value="$createDynamicBundleProduct.sku$"/> + </actionGroup> + <!-- Should not see bundle product --> + <dontSee userInput="$createDynamicBundleProduct.sku$" selector="{{StorefrontCatalogSearchMainSection.searchResults}}" stepKey="dontSeeProduct"/> + <amOnPage url="{{StorefrontCategoryPage.url($createCategory.custom_attributes[url_key]$)}}" stepKey="openCategoryPage"/> + <!-- Should not see any products in category --> + <dontSee userInput="$createDynamicBundleProduct.name$" selector="{{StorefrontCategoryMainSection.productsList}}" stepKey="dontSeeProductInCategory"/> + <see selector="{{StorefrontCategoryMainSection.emptyProductMessage}}" userInput="We can't find products matching the selection." stepKey="seeEmptyProductMessage"/> + </test> +</tests> diff --git a/app/code/Magento/Bundle/Test/Mftf/Test/AdminDeleteBundleDynamicProductTest.xml b/app/code/Magento/Bundle/Test/Mftf/Test/AdminDeleteBundleDynamicProductTest.xml index a2f26e235fc23..7973860e4d5c5 100644 --- a/app/code/Magento/Bundle/Test/Mftf/Test/AdminDeleteBundleDynamicProductTest.xml +++ b/app/code/Magento/Bundle/Test/Mftf/Test/AdminDeleteBundleDynamicProductTest.xml @@ -7,17 +7,17 @@ --> <tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> - <test name="AdminDeleteBundleDynamicProductTest"> + <test name="AdminDeleteBundleDynamicProductTest" deprecated="Use AdminDeleteBundleDynamicPriceProductTest instead"> <annotations> <features value="Bundle"/> <stories value="Delete products"/> - <title value="Delete Bundle Dynamic Product"/> - <description value="Admin should be able to delete a bundle dynamic product"/> + <title value="Deprecated. Delete Bundle Dynamic Product"/> + <description value="Deprecated. Admin should be able to delete a bundle dynamic product"/> <severity value="CRITICAL"/> <testCaseId value="MC-11016"/> <group value="mtf_migrated"/> <skip> - <issueId value="MC-16393"/> + <issueId value="DEPRECATED">Use AdminDeleteBundleDynamicPriceProductTest instead</issueId> </skip> </annotations> <before> diff --git a/app/code/Magento/Bundle/Test/Mftf/Test/BundleProductFixedPricingTest.xml b/app/code/Magento/Bundle/Test/Mftf/Test/BundleProductFixedPricingTest.xml index b91f995b70ba7..d9ab2962964b2 100644 --- a/app/code/Magento/Bundle/Test/Mftf/Test/BundleProductFixedPricingTest.xml +++ b/app/code/Magento/Bundle/Test/Mftf/Test/BundleProductFixedPricingTest.xml @@ -76,8 +76,7 @@ <!--Testing that price appears correctly in admin catalog--> <!--Set filter to product name--> - <amOnPage url="{{AdminCatalogProductPage.url}}" stepKey="GoToCatalogProductPage"/> - <waitForPageLoad stepKey="WaitForPageToLoad"/> + <actionGroup ref="AdminOpenCatalogProductPageActionGroup" stepKey="goToCatalogProductPage"/> <conditionalClick selector="{{AdminProductFiltersSection.filtersClear}}" dependentSelector="{{AdminProductFiltersSection.filtersClear}}" visible="true" stepKey="ClickOnButtonToRemoveFiltersIfPresent"/> <waitForPageLoad stepKey="WaitForClear"/> <actionGroup ref="FilterProductGridByNameActionGroup" stepKey="filterBundleProductOptionsDownToName"> diff --git a/app/code/Magento/Bundle/Test/Mftf/Test/NewBundleProductSelectionTest.xml b/app/code/Magento/Bundle/Test/Mftf/Test/NewBundleProductSelectionTest.xml index e722caaf090c5..f4b81e9ba9577 100644 --- a/app/code/Magento/Bundle/Test/Mftf/Test/NewBundleProductSelectionTest.xml +++ b/app/code/Magento/Bundle/Test/Mftf/Test/NewBundleProductSelectionTest.xml @@ -24,8 +24,7 @@ <after> <actionGroup ref="AdminLogoutActionGroup" stepKey="amOnLogoutPage"/> </after> - <amOnPage url="{{AdminCatalogProductPage.url}}" stepKey="GoToCatalogProductPage"/> - <waitForPageLoad stepKey="WaitForPageToLoad"/> + <actionGroup ref="AdminOpenCatalogProductPageActionGroup" stepKey="goToCatalogProductPage"/> <!--Selecting new bundle product--> <actionGroup ref="GoToCreateProductPageActionGroup" stepKey="goToCreateBundleProduct"> <argument name="product" value="BundleProduct"/> diff --git a/app/code/Magento/Bundle/Test/Mftf/Test/StorefrontBundlePlaceOrderWithVirtualAndSimpleChildrenTest.xml b/app/code/Magento/Bundle/Test/Mftf/Test/StorefrontBundlePlaceOrderWithVirtualAndSimpleChildrenTest.xml new file mode 100644 index 0000000000000..fe4faed29d144 --- /dev/null +++ b/app/code/Magento/Bundle/Test/Mftf/Test/StorefrontBundlePlaceOrderWithVirtualAndSimpleChildrenTest.xml @@ -0,0 +1,92 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="StorefrontBundlePlaceOrderWithVirtualAndSimpleChildrenTest"> + <annotations> + <features value="Bundle"/> + <stories value="Bundle product placing order"/> + <title value="Admin should be able to invoice order for the bundle product with virtual and simple products in options"/> + <description value="Place order for bundle product and create invoice"/> + <severity value="MAJOR"/> + <testCaseId value="MC-38683"/> + <useCaseId value="MC-37663"/> + <group value="Bundle"/> + </annotations> + <before> + <createData entity="CustomerEntityOne" stepKey="createCustomer"/> + <!--Create bundle product with fixed price with simple and virtual products in options--> + <createData entity="SimpleProduct2" stepKey="createSimpleProduct"> + <field key="price">100.00</field> + </createData> + <createData entity="VirtualProduct" stepKey="createVirtualProduct"> + <field key="price">50.00</field> + </createData> + <createData entity="ApiFixedBundleProduct" stepKey="createFixedBundleProduct"/> + <createData entity="DropDownBundleOption" stepKey="createFirstBundleOption"> + <requiredEntity createDataKey="createFixedBundleProduct"/> + </createData> + <createData entity="DropDownBundleOption" stepKey="createSecondBundleOption"> + <requiredEntity createDataKey="createFixedBundleProduct"/> + </createData> + <createData entity="ApiBundleLink" stepKey="firstLinkOptionToFixedProduct"> + <requiredEntity createDataKey="createFixedBundleProduct"/> + <requiredEntity createDataKey="createFirstBundleOption"/> + <requiredEntity createDataKey="createSimpleProduct"/> + </createData> + <createData entity="ApiBundleLink" stepKey="secondLinkOptionToFixedProduct"> + <requiredEntity createDataKey="createFixedBundleProduct"/> + <requiredEntity createDataKey="createSecondBundleOption"/> + <requiredEntity createDataKey="createVirtualProduct"/> + </createData> + <actionGroup stepKey="loginToAdminPanel" ref="AdminLoginActionGroup"/> + <actionGroup ref="AdminProductPageOpenByIdActionGroup" stepKey="goToProductEditPage"> + <argument name="productId" value="$createFixedBundleProduct.id$"/> + </actionGroup> + <actionGroup ref="SaveProductFormActionGroup" stepKey="saveProduct"/> + <!--Perform reindex and flush cache--> + <actionGroup ref="AdminReindexAndFlushCache" stepKey="reindexAndFlushCache"/> + </before> + <after> + <deleteData createDataKey="createSimpleProduct" stepKey="deleteSimpleProductForBundleItem"/> + <deleteData createDataKey="createVirtualProduct" stepKey="deleteVirtualProductForBundleItem"/> + <deleteData createDataKey="createFixedBundleProduct" stepKey="deleteBundleProduct"/> + <deleteData createDataKey="createCustomer" stepKey="deleteCustomer"/> + <actionGroup ref="AdminClearFiltersActionGroup" stepKey="clearProductsGridFilters"/> + <waitForPageLoad stepKey="waitForClearProductsGridFilters"/> + </after> + <!--Login customer on storefront--> + <actionGroup ref="LoginToStorefrontActionGroup" stepKey="loginCustomer"> + <argument name="Customer" value="$$createCustomer$$" /> + </actionGroup> + <!--Open Product Page--> + <actionGroup ref="StorefrontOpenProductEntityPageActionGroup" stepKey="openBundleProductPage"> + <argument name="product" value="$createFixedBundleProduct$"/> + </actionGroup> + <!--Add bundle to cart--> + <actionGroup ref="StorefrontSelectCustomizeAndAddToTheCartButtonActionGroup" stepKey="clickAddToCart"> + <argument name="productUrl" value="$createFixedBundleProduct.name$"/> + </actionGroup> + <actionGroup ref="StorefrontEnterProductQuantityAndAddToTheCartActionGroup" stepKey="enterProductQuantityAndAddToTheCart"> + <argument name="quantity" value="1"/> + </actionGroup> + <!--Navigate to checkout--> + <actionGroup ref="StorefrontOpenCheckoutPageActionGroup" stepKey="openCheckoutPage"/> + <!--Click next button to open payment section--> + <actionGroup ref="StorefrontCheckoutClickNextButtonActionGroup" stepKey="clickNext"/> + <!--Click place order--> + <actionGroup ref="ClickPlaceOrderActionGroup" stepKey="placeOrder"/> + <grabTextFrom selector="{{CheckoutSuccessMainSection.orderNumber22}}" stepKey="grabOrderNumber"/> + <!--Order review page has address that was created during checkout--> + <actionGroup ref="OpenOrderByIdActionGroup" stepKey="filterOrdersGridById"> + <argument name="orderId" value="{$grabOrderNumber}"/> + </actionGroup> + <!--Create Invoice for this Order--> + <actionGroup ref="StartCreateInvoiceFromOrderPageActionGroup" stepKey="createInvoice"/> + <actionGroup ref="SubmitInvoiceActionGroup" stepKey="submitInvoice"/> + </test> +</tests> diff --git a/app/code/Magento/Bundle/Test/Unit/Pricing/Adjustment/CalculatorTest.php b/app/code/Magento/Bundle/Test/Unit/Pricing/Adjustment/CalculatorTest.php index e3762632b45fd..a14a1e06dab2d 100644 --- a/app/code/Magento/Bundle/Test/Unit/Pricing/Adjustment/CalculatorTest.php +++ b/app/code/Magento/Bundle/Test/Unit/Pricing/Adjustment/CalculatorTest.php @@ -166,9 +166,10 @@ public function testGetterAmount($amountForBundle, $optionList, $expectedResult) $optionSelections = []; foreach ($options as $option) { - // phpcs:ignore Magento2.Performance.ForeachArrayMerge - $optionSelections = array_merge($optionSelections, $option->getSelections()); + $optionSelections[] = $option->getSelections(); } + $optionSelections = array_merge([], ...$optionSelections); + $this->selectionPriceListProvider->expects($this->any())->method('getPriceList')->willReturn($optionSelections); $price = $this->createMock(BundleOptionPrice::class); diff --git a/app/code/Magento/Bundle/view/adminhtml/ui_component/bundle_product_listing.xml b/app/code/Magento/Bundle/view/adminhtml/ui_component/bundle_product_listing.xml index b259e3280bfd5..d69196a61c59d 100644 --- a/app/code/Magento/Bundle/view/adminhtml/ui_component/bundle_product_listing.xml +++ b/app/code/Magento/Bundle/view/adminhtml/ui_component/bundle_product_listing.xml @@ -58,7 +58,7 @@ <label translate="true">Status</label> <dataScope>status</dataScope> <imports> - <link name="visible">componentType = column, index = ${ $.index }:visible</link> + <link name="visible">ns = ${ $.ns }, index = ${ $.index }:visible</link> </imports> </settings> </filterSelect> diff --git a/app/code/Magento/BundleImportExport/Model/Import/Product/Type/Bundle.php b/app/code/Magento/BundleImportExport/Model/Import/Product/Type/Bundle.php index 49881f67f5c9a..04384ca71cfbe 100644 --- a/app/code/Magento/BundleImportExport/Model/Import/Product/Type/Bundle.php +++ b/app/code/Magento/BundleImportExport/Model/Import/Product/Type/Bundle.php @@ -603,11 +603,7 @@ protected function populateInsertOptionValues(array $optionIds): array if ($assoc['position'] == $this->_cachedOptions[$entityId][$key]['index'] && $assoc['parent_id'] == $entityId) { $option['parent_id'] = $entityId; - //phpcs:ignore Magento2.Performance.ForeachArrayMerge - $optionValues = array_merge( - $optionValues, - $this->populateOptionValueTemplate($option, $optionId) - ); + $optionValues[] = $this->populateOptionValueTemplate($option, $optionId); $this->_cachedOptions[$entityId][$key]['option_id'] = $optionId; break; } @@ -615,7 +611,7 @@ protected function populateInsertOptionValues(array $optionIds): array } } - return $optionValues; + return array_merge([], ...$optionValues); } /** diff --git a/app/code/Magento/Captcha/Model/DefaultModel.php b/app/code/Magento/Captcha/Model/DefaultModel.php index 0bb46b53c42d3..cf6950ffe8205 100644 --- a/app/code/Magento/Captcha/Model/DefaultModel.php +++ b/app/code/Magento/Captcha/Model/DefaultModel.php @@ -7,7 +7,9 @@ namespace Magento\Captcha\Model; +use Magento\Authorization\Model\UserContextInterface; use Magento\Captcha\Helper\Data; +use Magento\Framework\App\ObjectManager; use Magento\Framework\Math\Random; /** @@ -93,12 +95,18 @@ class DefaultModel extends \Laminas\Captcha\Image implements \Magento\Captcha\Mo */ private $randomMath; + /** + * @var UserContextInterface + */ + private $userContext; + /** * @param \Magento\Framework\Session\SessionManagerInterface $session * @param \Magento\Captcha\Helper\Data $captchaData * @param ResourceModel\LogFactory $resLogFactory * @param string $formId * @param Random $randomMath + * @param UserContextInterface|null $userContext * @throws \Laminas\Captcha\Exception\ExtensionNotLoadedException */ public function __construct( @@ -106,14 +114,16 @@ public function __construct( \Magento\Captcha\Helper\Data $captchaData, \Magento\Captcha\Model\ResourceModel\LogFactory $resLogFactory, $formId, - Random $randomMath = null + Random $randomMath = null, + ?UserContextInterface $userContext = null ) { parent::__construct(); $this->session = $session; $this->captchaData = $captchaData; $this->resLogFactory = $resLogFactory; $this->formId = $formId; - $this->randomMath = $randomMath ?? \Magento\Framework\App\ObjectManager::getInstance()->get(Random::class); + $this->randomMath = $randomMath ?? ObjectManager::getInstance()->get(Random::class); + $this->userContext = $userContext ?? ObjectManager::getInstance()->get(UserContextInterface::class); } /** @@ -152,6 +162,7 @@ public function isRequired($login = null) $this->formId, $this->getTargetForms() ) + || $this->userContext->getUserType() === UserContextInterface::USER_TYPE_INTEGRATION ) { return false; } @@ -241,7 +252,7 @@ private function isOverLimitLoginAttempts($login) */ private function isUserAuth() { - return $this->session->isLoggedIn(); + return $this->session->isLoggedIn() || $this->userContext->getUserId(); } /** @@ -427,7 +438,7 @@ public function getWordLen() $to = self::DEFAULT_WORD_LENGTH_TO; } - return \Magento\Framework\Math\Random::getRandomNumber($from, $to); + return Random::getRandomNumber($from, $to); } /** @@ -549,7 +560,7 @@ private function clearWord() */ protected function randomSize() { - return \Magento\Framework\Math\Random::getRandomNumber(280, 300) / 100; + return Random::getRandomNumber(280, 300) / 100; } /** diff --git a/app/code/Magento/Captcha/Observer/CaptchaStringResolver.php b/app/code/Magento/Captcha/Observer/CaptchaStringResolver.php index d83abc7a6c7d1..059d395f6cf73 100644 --- a/app/code/Magento/Captcha/Observer/CaptchaStringResolver.php +++ b/app/code/Magento/Captcha/Observer/CaptchaStringResolver.php @@ -3,10 +3,14 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + +declare(strict_types=1); + namespace Magento\Captcha\Observer; use Magento\Framework\App\RequestInterface; use Magento\Framework\App\Request\Http as HttpRequest; +use Magento\Captcha\Helper\Data as CaptchaHelper; /** * Extract given captcha word. @@ -22,12 +26,13 @@ class CaptchaStringResolver */ public function resolve(RequestInterface $request, $formId) { - $captchaParams = $request->getPost(\Magento\Captcha\Helper\Data::INPUT_NAME_FIELD_VALUE); + $value = ''; + $captchaParams = $request->getPost(CaptchaHelper::INPUT_NAME_FIELD_VALUE); if (!empty($captchaParams) && !empty($captchaParams[$formId])) { $value = $captchaParams[$formId]; - } else { - //For Web APIs - $value = $request->getHeader('X-Captcha'); + } elseif ($headerValue = $request->getHeader('X-Captcha')) { + //CAPTCHA was provided via header for this XHR/web API request. + $value = $headerValue; } return $value; diff --git a/app/code/Magento/Captcha/Test/Mftf/Test/CaptchaFormsDisplayingTest/CaptchaWithDisabledGuestCheckoutTest.xml b/app/code/Magento/Captcha/Test/Mftf/Test/CaptchaFormsDisplayingTest/CaptchaWithDisabledGuestCheckoutTest.xml index 9e99fa96ee766..66183cb31aebc 100644 --- a/app/code/Magento/Captcha/Test/Mftf/Test/CaptchaFormsDisplayingTest/CaptchaWithDisabledGuestCheckoutTest.xml +++ b/app/code/Magento/Captcha/Test/Mftf/Test/CaptchaFormsDisplayingTest/CaptchaWithDisabledGuestCheckoutTest.xml @@ -46,8 +46,10 @@ <waitForElementVisible selector="{{StorefrontCustomerSignInPopupFormSection.captchaField}}" stepKey="seeCaptchaField"/> <waitForElementVisible selector="{{StorefrontCustomerSignInPopupFormSection.captchaImg}}" stepKey="seeCaptchaImage"/> <waitForElementVisible selector="{{StorefrontCustomerSignInPopupFormSection.captchaReload}}" stepKey="seeCaptchaReloadButton"/> - <reloadPage stepKey="refreshPage"/> - <waitForPageLoad stepKey="waitForPageLoad2"/> + + <actionGroup ref="ReloadPageActionGroup" stepKey="refreshPage"/> + <comment userInput="Replacing reload action and preserve Backward Compatibility" stepKey="waitForPageLoad2" /> + <actionGroup ref="StorefrontClickOnMiniCartActionGroup" stepKey="clickCart2"/> <click selector="{{StorefrontMinicartSection.goToCheckout}}" stepKey="goToCheckout2"/> <waitForElementVisible selector="{{StorefrontCustomerSignInPopupFormSection.email}}" stepKey="waitEmailFieldVisible2"/> diff --git a/app/code/Magento/Captcha/Test/Unit/Helper/DataTest.php b/app/code/Magento/Captcha/Test/Unit/Helper/DataTest.php index ec9f6f03134cc..4b9286f69cce5 100644 --- a/app/code/Magento/Captcha/Test/Unit/Helper/DataTest.php +++ b/app/code/Magento/Captcha/Test/Unit/Helper/DataTest.php @@ -197,7 +197,7 @@ public function testGetImgDir() */ public function testGetImgUrl() { - $this->assertEquals($this->helper->getImgUrl(), 'http://localhost/pub/media/captcha/base/'); + $this->assertEquals($this->helper->getImgUrl(), 'http://localhost/media/captcha/base/'); } /** @@ -223,7 +223,7 @@ protected function _getStoreStub() { $store = $this->createMock(Store::class); - $store->expects($this->any())->method('getBaseUrl')->willReturn('http://localhost/pub/media/'); + $store->expects($this->any())->method('getBaseUrl')->willReturn('http://localhost/media/'); return $store; } diff --git a/app/code/Magento/Captcha/Test/Unit/Model/DefaultTest.php b/app/code/Magento/Captcha/Test/Unit/Model/DefaultTest.php index a20ff898c222e..1d222e273dc1f 100644 --- a/app/code/Magento/Captcha/Test/Unit/Model/DefaultTest.php +++ b/app/code/Magento/Captcha/Test/Unit/Model/DefaultTest.php @@ -7,6 +7,7 @@ namespace Magento\Captcha\Test\Unit\Model; +use Magento\Authorization\Model\UserContextInterface; use Magento\Captcha\Block\Captcha\DefaultCaptcha; use Magento\Captcha\Helper\Data; use Magento\Captcha\Model\DefaultModel; @@ -93,10 +94,15 @@ class DefaultTest extends TestCase protected $session; /** - * @var MockObject + * @var MockObject|LogFactory */ protected $_resLogFactory; + /** + * @var UserContextInterface|MockObject + */ + private $userContextMock; + /** * Sets up the fixture, for example, opens a network connection. * This method is called before a test is executed. @@ -139,11 +145,18 @@ protected function setUp(): void $this->_getResourceModelStub() ); + $randomMock = $this->createMock(Random::class); + $randomMock->method('getRandomString')->willReturn('random-string'); + + $this->userContextMock = $this->getMockForAbstractClass(UserContextInterface::class); + $this->_object = new DefaultModel( $this->session, $this->_getHelperStub(), $this->_resLogFactory, - 'user_create' + 'user_create', + $randomMock, + $this->userContextMock ); } @@ -163,6 +176,19 @@ public function testIsRequired() $this->assertTrue($this->_object->isRequired()); } + /** + * Validate that CAPTCHA is disabled for integrations. + * + * @return void + */ + public function testIsRequiredForIntegration(): void + { + $this->userContextMock->method('getUserType')->willReturn(UserContextInterface::USER_TYPE_INTEGRATION); + $this->userContextMock->method('getUserId')->willReturn(1); + + $this->assertFalse($this->_object->isRequired()); + } + /** * @covers \Magento\Captcha\Model\DefaultModel::isCaseSensitive */ @@ -217,7 +243,7 @@ public function testGetImgSrc() { $this->assertEquals( $this->_object->getImgSrc(), - 'http://localhost/pub/media/captcha/base/' . $this->_object->getId() . '.png' + 'http://localhost/media/captcha/base/' . $this->_object->getId() . '.png' ); } @@ -310,7 +336,7 @@ protected function _getHelperStub() )->method( 'getImgUrl' )->willReturn( - 'http://localhost/pub/media/captcha/base/' + 'http://localhost/media/captcha/base/' ); return $helper; @@ -365,7 +391,7 @@ protected function _getStoreStub() ->onlyMethods(['getBaseUrl']) ->disableOriginalConstructor() ->getMock(); - $store->expects($this->any())->method('getBaseUrl')->willReturn('http://localhost/pub/media/'); + $store->expects($this->any())->method('getBaseUrl')->willReturn('http://localhost/media/'); $store->expects($this->any())->method('isAdmin')->willReturn(false); return $store; } diff --git a/app/code/Magento/Captcha/composer.json b/app/code/Magento/Captcha/composer.json index a6ee83d3f0924..3c3aa58c3fe2f 100644 --- a/app/code/Magento/Captcha/composer.json +++ b/app/code/Magento/Captcha/composer.json @@ -11,6 +11,7 @@ "magento/module-checkout": "*", "magento/module-customer": "*", "magento/module-store": "*", + "magento/module-authorization": "*", "laminas/laminas-captcha": "^2.7.1", "laminas/laminas-db": "^2.8.2", "laminas/laminas-session": "^2.7.3" diff --git a/app/code/Magento/Captcha/i18n/en_US.csv b/app/code/Magento/Captcha/i18n/en_US.csv index 480107df8adfe..ac6a7cf9d57e7 100644 --- a/app/code/Magento/Captcha/i18n/en_US.csv +++ b/app/code/Magento/Captcha/i18n/en_US.csv @@ -9,6 +9,7 @@ Always,Always "Reload captcha","Reload captcha" "Please type the letters and numbers below","Please type the letters and numbers below" "Attention: Captcha is case sensitive.","Attention: Captcha is case sensitive." +"Please provide CAPTCHA code and try again","Please provide CAPTCHA code and try again" CAPTCHA,CAPTCHA "Enable CAPTCHA in Admin","Enable CAPTCHA in Admin" Font,Font diff --git a/app/code/Magento/Catalog/Block/Adminhtml/Product/Attribute/Edit.php b/app/code/Magento/Catalog/Block/Adminhtml/Product/Attribute/Edit.php index 6ab039aa27849..efb7d6dbbeff3 100644 --- a/app/code/Magento/Catalog/Block/Adminhtml/Product/Attribute/Edit.php +++ b/app/code/Magento/Catalog/Block/Adminhtml/Product/Attribute/Edit.php @@ -42,6 +42,8 @@ public function __construct( } /** + * Construct block + * * @return void */ protected function _construct() @@ -51,6 +53,14 @@ protected function _construct() parent::_construct(); + $this->buttonList->update('save', 'label', __('Save Attribute')); + $this->buttonList->update('save', 'class', 'save primary'); + $this->buttonList->update( + 'save', + 'data_attribute', + ['mage-init' => ['button' => ['event' => 'save', 'target' => '#edit_form']]] + ); + if ($this->getRequest()->getParam('popup')) { $this->buttonList->remove('back'); if ($this->getRequest()->getParam('product_tab') != 'variations') { @@ -64,6 +74,8 @@ protected function _construct() 100 ); } + $this->buttonList->update('reset', 'level', 10); + $this->buttonList->update('save', 'class', 'save action-secondary'); } else { $this->addButton( 'save_and_edit_button', @@ -79,14 +91,6 @@ protected function _construct() ); } - $this->buttonList->update('save', 'label', __('Save Attribute')); - $this->buttonList->update('save', 'class', 'save primary'); - $this->buttonList->update( - 'save', - 'data_attribute', - ['mage-init' => ['button' => ['event' => 'save', 'target' => '#edit_form']]] - ); - $entityAttribute = $this->_coreRegistry->registry('entity_attribute'); if (!$entityAttribute || !$entityAttribute->getIsUserDefined()) { $this->buttonList->remove('delete'); @@ -96,14 +100,14 @@ protected function _construct() } /** - * {@inheritdoc} + * @inheritdoc */ public function addButton($buttonId, $data, $level = 0, $sortOrder = 0, $region = 'toolbar') { if ($this->getRequest()->getParam('popup')) { $region = 'header'; } - parent::addButton($buttonId, $data, $level, $sortOrder, $region); + return parent::addButton($buttonId, $data, $level, $sortOrder, $region); } /** diff --git a/app/code/Magento/Catalog/Block/Product/ListProduct.php b/app/code/Magento/Catalog/Block/Product/ListProduct.php index 6cec9bf3ef88a..b181a5392905b 100644 --- a/app/code/Magento/Catalog/Block/Product/ListProduct.php +++ b/app/code/Magento/Catalog/Block/Product/ListProduct.php @@ -367,7 +367,7 @@ public function getIdentities() $identities[] = $item->getIdentities(); } } - $identities = array_merge(...$identities); + $identities = array_merge([], ...$identities); return $identities; } diff --git a/app/code/Magento/Catalog/Block/Product/ProductList/Related.php b/app/code/Magento/Catalog/Block/Product/ProductList/Related.php index 387fac770c5bc..42f610f89768d 100644 --- a/app/code/Magento/Catalog/Block/Product/ProductList/Related.php +++ b/app/code/Magento/Catalog/Block/Product/ProductList/Related.php @@ -143,11 +143,11 @@ public function getItems() */ public function getIdentities() { - $identities = [[]]; + $identities = []; foreach ($this->getItems() as $item) { $identities[] = $item->getIdentities(); } - return array_merge(...$identities); + return array_merge([], ...$identities); } /** diff --git a/app/code/Magento/Catalog/Block/Product/ProductList/Upsell.php b/app/code/Magento/Catalog/Block/Product/ProductList/Upsell.php index ac66392efe5dc..adcb1b5666560 100644 --- a/app/code/Magento/Catalog/Block/Product/ProductList/Upsell.php +++ b/app/code/Magento/Catalog/Block/Product/ProductList/Upsell.php @@ -267,10 +267,10 @@ public function getItemLimit($type = '') */ public function getIdentities() { - $identities = array_map(function (DataObject $item) { - return $item->getIdentities(); - }, $this->getItems()) ?: [[]]; - - return array_merge(...$identities); + $identities = []; + foreach ($this->getItems() as $item) { + $identities[] = $item->getIdentities(); + } + return array_merge([], ...$identities); } } diff --git a/app/code/Magento/Catalog/Block/Product/View/Details.php b/app/code/Magento/Catalog/Block/Product/View/Details.php index 67303d177e71e..8a569c268dd1d 100644 --- a/app/code/Magento/Catalog/Block/Product/View/Details.php +++ b/app/code/Magento/Catalog/Block/Product/View/Details.php @@ -27,10 +27,11 @@ class Details extends \Magento\Framework\View\Element\Template * * @return array * @since 103.0.1 + * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ public function getGroupSortedChildNames(string $groupName, string $callback): array { - $groupChildNames = $this->getGroupChildNames($groupName, $callback); + $groupChildNames = $this->getGroupChildNames($groupName); $layout = $this->getLayout(); $childNamesSortOrder = []; diff --git a/app/code/Magento/Catalog/Block/Product/View/Options/AbstractOptions.php b/app/code/Magento/Catalog/Block/Product/View/Options/AbstractOptions.php index 1dcbf60db15c3..de92546a8dd88 100644 --- a/app/code/Magento/Catalog/Block/Product/View/Options/AbstractOptions.php +++ b/app/code/Magento/Catalog/Block/Product/View/Options/AbstractOptions.php @@ -12,7 +12,10 @@ namespace Magento\Catalog\Block\Product\View\Options; +use Magento\Catalog\Pricing\Price\BasePrice; +use Magento\Catalog\Pricing\Price\CalculateCustomOptionCatalogRule; use Magento\Catalog\Pricing\Price\CustomOptionPriceInterface; +use Magento\Framework\App\ObjectManager; /** * Product options section abstract block. @@ -47,20 +50,29 @@ abstract class AbstractOptions extends \Magento\Framework\View\Element\Template */ protected $_catalogHelper; + /** + * @var CalculateCustomOptionCatalogRule + */ + private $calculateCustomOptionCatalogRule; + /** * @param \Magento\Framework\View\Element\Template\Context $context * @param \Magento\Framework\Pricing\Helper\Data $pricingHelper * @param \Magento\Catalog\Helper\Data $catalogData * @param array $data + * @param CalculateCustomOptionCatalogRule|null $calculateCustomOptionCatalogRule */ public function __construct( \Magento\Framework\View\Element\Template\Context $context, \Magento\Framework\Pricing\Helper\Data $pricingHelper, \Magento\Catalog\Helper\Data $catalogData, - array $data = [] + array $data = [], + CalculateCustomOptionCatalogRule $calculateCustomOptionCatalogRule = null ) { $this->pricingHelper = $pricingHelper; $this->_catalogHelper = $catalogData; + $this->calculateCustomOptionCatalogRule = $calculateCustomOptionCatalogRule + ?? ObjectManager::getInstance()->get(CalculateCustomOptionCatalogRule::class); parent::__construct($context, $data); } @@ -162,6 +174,19 @@ protected function _formatPrice($value, $flag = true) $priceStr = $sign; $customOptionPrice = $this->getProduct()->getPriceInfo()->getPrice('custom_option_price'); + $isPercent = (bool) $value['is_percent']; + + if (!$isPercent) { + $catalogPriceValue = $this->calculateCustomOptionCatalogRule->execute( + $this->getProduct(), + (float)$value['pricing_value'], + $isPercent + ); + if ($catalogPriceValue !== null) { + $value['pricing_value'] = $catalogPriceValue; + } + } + $context = [CustomOptionPriceInterface::CONFIGURATION_OPTION_FLAG => true]; $optionAmount = $customOptionPrice->getCustomAmount($value['pricing_value'], null, $context); $priceStr .= $this->getLayout()->getBlock('product.price.render.default')->renderAmount( diff --git a/app/code/Magento/Catalog/Controller/Adminhtml/Product/Attribute/Validate.php b/app/code/Magento/Catalog/Controller/Adminhtml/Product/Attribute/Validate.php index a2be7db7e62be..47839941c5837 100644 --- a/app/code/Magento/Catalog/Controller/Adminhtml/Product/Attribute/Validate.php +++ b/app/code/Magento/Catalog/Controller/Adminhtml/Product/Attribute/Validate.php @@ -33,6 +33,7 @@ class Validate extends AttributeAction implements HttpGetActionInterface, HttpPostActionInterface { const DEFAULT_MESSAGE_KEY = 'message'; + private const RESERVED_ATTRIBUTE_CODES = ['product_type', 'type_id']; /** * @var JsonFactory @@ -145,11 +146,16 @@ public function execute() ); } - if ($attribute->getId() && !$attributeId || $attributeCode === 'product_type' || $attributeCode === 'type_id') { + if (in_array($attributeCode, self::RESERVED_ATTRIBUTE_CODES, true)) { + $message = __('Code (%1) is a reserved key and cannot be used as attribute code.', $attributeCode); + $this->setMessageToResponse($response, [$message]); + $response->setError(true); + } + + if ($attribute->getId() && !$attributeId) { $message = strlen($this->getRequest()->getParam('attribute_code')) ? __('An attribute with this code already exists.') : __('An attribute with the same code (%1) already exists.', $attributeCode); - $this->setMessageToResponse($response, [$message]); $response->setError(true); diff --git a/app/code/Magento/Catalog/Controller/Adminhtml/Product/Initialization/Helper.php b/app/code/Magento/Catalog/Controller/Adminhtml/Product/Initialization/Helper.php index d948daed1c7d9..f0e0ff73b838c 100644 --- a/app/code/Magento/Catalog/Controller/Adminhtml/Product/Initialization/Helper.php +++ b/app/code/Magento/Catalog/Controller/Adminhtml/Product/Initialization/Helper.php @@ -7,6 +7,7 @@ namespace Magento\Catalog\Controller\Adminhtml\Product\Initialization; use Magento\Backend\Helper\Js; +use Magento\Catalog\Api\Data\CategoryLinkInterfaceFactory; use Magento\Catalog\Api\Data\ProductCustomOptionInterfaceFactory as CustomOptionFactory; use Magento\Catalog\Api\Data\ProductLinkInterfaceFactory as ProductLinkFactory; use Magento\Catalog\Api\Data\ProductLinkTypeInterface; @@ -115,6 +116,11 @@ class Helper */ private $dateTimeFilter; + /** + * @var CategoryLinkInterfaceFactory + */ + private $categoryLinkFactory; + /** * Constructor * @@ -132,6 +138,7 @@ class Helper * @param FormatInterface|null $localeFormat * @param ProductAuthorization|null $productAuthorization * @param DateTimeFilter|null $dateTimeFilter + * @param CategoryLinkInterfaceFactory|null $categoryLinkFactory * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -148,7 +155,8 @@ public function __construct( AttributeFilter $attributeFilter = null, FormatInterface $localeFormat = null, ?ProductAuthorization $productAuthorization = null, - ?DateTimeFilter $dateTimeFilter = null + ?DateTimeFilter $dateTimeFilter = null, + ?CategoryLinkInterfaceFactory $categoryLinkFactory = null ) { $this->request = $request; $this->storeManager = $storeManager; @@ -166,6 +174,7 @@ public function __construct( $this->localeFormat = $localeFormat ?: $objectManager->get(FormatInterface::class); $this->productAuthorization = $productAuthorization ?? $objectManager->get(ProductAuthorization::class); $this->dateTimeFilter = $dateTimeFilter ?? $objectManager->get(DateTimeFilter::class); + $this->categoryLinkFactory = $categoryLinkFactory ?? $objectManager->get(CategoryLinkInterfaceFactory::class); } /** @@ -238,6 +247,7 @@ public function initializeFromData(Product $product, array $productData) $product = $this->setProductLinks($product); $product = $this->fillProductOptions($product, $productOptions); + $this->setCategoryLinks($product); $product->setCanSaveCustomOptions( !empty($productData['affect_product_custom_options']) && !$product->getOptionsReadonly() @@ -484,4 +494,30 @@ function ($valueData) { return $product->setOptions($customOptions); } + + /** + * Set category links based on initialized category ids + * + * @param Product $product + */ + private function setCategoryLinks(Product $product): void + { + $extensionAttributes = $product->getExtensionAttributes(); + $categoryLinks = []; + foreach ((array) $extensionAttributes->getCategoryLinks() as $categoryLink) { + $categoryLinks[$categoryLink->getCategoryId()] = $categoryLink; + } + + $newCategoryLinks = []; + foreach ($product->getCategoryIds() as $categoryId) { + $categoryLink = $categoryLinks[$categoryId] ?? + $this->categoryLinkFactory->create() + ->setCategoryId($categoryId) + ->setPosition(0); + $newCategoryLinks[] = $categoryLink; + } + + $extensionAttributes->setCategoryLinks(!empty($newCategoryLinks) ? $newCategoryLinks : null); + $product->setExtensionAttributes($extensionAttributes); + } } diff --git a/app/code/Magento/Catalog/Controller/Adminhtml/Product/Initialization/Helper/AttributeFilter.php b/app/code/Magento/Catalog/Controller/Adminhtml/Product/Initialization/Helper/AttributeFilter.php index 49165c85f85d7..1d6939acacfd0 100644 --- a/app/code/Magento/Catalog/Controller/Adminhtml/Product/Initialization/Helper/AttributeFilter.php +++ b/app/code/Magento/Catalog/Controller/Adminhtml/Product/Initialization/Helper/AttributeFilter.php @@ -80,7 +80,7 @@ private function prepareDefaultData(array $attributeList, string $attributeCode, // For non-numeric types set the attributeValue to 'false' to trigger their removal from the db if ($attributeType === 'varchar' || $attributeType === 'text' || $attributeType === 'datetime') { $attribute->setIsRequired(false); - $productData[$attributeCode] = false; + $productData[$attributeCode] = $attribute->getDefaultValue() ?: false; } else { $productData[$attributeCode] = null; } diff --git a/app/code/Magento/Catalog/Controller/Adminhtml/Product/Save.php b/app/code/Magento/Catalog/Controller/Adminhtml/Product/Save.php index 5c3e27334cb66..97b57317851fc 100644 --- a/app/code/Magento/Catalog/Controller/Adminhtml/Product/Save.php +++ b/app/code/Magento/Catalog/Controller/Adminhtml/Product/Save.php @@ -15,7 +15,8 @@ use Magento\Framework\App\Request\DataPersistorInterface; /** - * Class Save + * Product save controller + * * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class Save extends \Magento\Catalog\Controller\Adminhtml\Product implements HttpPostActionInterface @@ -141,10 +142,6 @@ public function execute() $canSaveCustomOptions = $product->getCanSaveCustomOptions(); $product->save(); $this->handleImageRemoveError($data, $product->getId()); - $this->categoryLinkManagement->assignProductToCategories( - $product->getSku(), - $product->getCategoryIds() - ); $productId = $product->getEntityId(); $productAttributeSetId = $product->getAttributeSetId(); $productTypeId = $product->getTypeId(); diff --git a/app/code/Magento/Catalog/Helper/Image.php b/app/code/Magento/Catalog/Helper/Image.php index ab74b5694ce9f..de32f6b7637d4 100644 --- a/app/code/Magento/Catalog/Helper/Image.php +++ b/app/code/Magento/Catalog/Helper/Image.php @@ -5,7 +5,10 @@ */ namespace Magento\Catalog\Helper; +use Magento\Catalog\Model\Config\CatalogMediaConfig; use Magento\Framework\App\Helper\AbstractHelper; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\Exception\LocalizedException; use Magento\Framework\View\Element\Block\ArgumentInterface; /** @@ -133,27 +136,34 @@ class Image extends AbstractHelper implements ArgumentInterface */ private $viewAssetPlaceholderFactory; + /** + * @var CatalogMediaConfig + */ + private $mediaConfig; + /** * @param \Magento\Framework\App\Helper\Context $context * @param \Magento\Catalog\Model\Product\ImageFactory $productImageFactory * @param \Magento\Framework\View\Asset\Repository $assetRepo * @param \Magento\Framework\View\ConfigInterface $viewConfig * @param \Magento\Catalog\Model\View\Asset\PlaceholderFactory $placeholderFactory + * @param CatalogMediaConfig $mediaConfig */ public function __construct( \Magento\Framework\App\Helper\Context $context, \Magento\Catalog\Model\Product\ImageFactory $productImageFactory, \Magento\Framework\View\Asset\Repository $assetRepo, \Magento\Framework\View\ConfigInterface $viewConfig, - \Magento\Catalog\Model\View\Asset\PlaceholderFactory $placeholderFactory = null + \Magento\Catalog\Model\View\Asset\PlaceholderFactory $placeholderFactory = null, + CatalogMediaConfig $mediaConfig = null ) { $this->_productImageFactory = $productImageFactory; parent::__construct($context); $this->_assetRepo = $assetRepo; $this->viewConfig = $viewConfig; $this->viewAssetPlaceholderFactory = $placeholderFactory - ?: \Magento\Framework\App\ObjectManager::getInstance() - ->get(\Magento\Catalog\Model\View\Asset\PlaceholderFactory::class); + ?: ObjectManager::getInstance()->get(\Magento\Catalog\Model\View\Asset\PlaceholderFactory::class); + $this->mediaConfig = $mediaConfig ?: ObjectManager::getInstance()->get(CatalogMediaConfig::class); } /** @@ -532,7 +542,16 @@ protected function isScheduledActionsAllowed() public function getUrl() { try { - $this->applyScheduledActions(); + switch ($this->mediaConfig->getMediaUrlFormat()) { + case CatalogMediaConfig::IMAGE_OPTIMIZATION_PARAMETERS: + $this->initBaseFile(); + break; + case CatalogMediaConfig::HASH: + $this->applyScheduledActions(); + break; + default: + throw new LocalizedException(__("The specified Catalog media URL format is not supported.")); + } return $this->_getModel()->getUrl(); } catch (\Exception $e) { return $this->getDefaultPlaceholderUrl(); diff --git a/app/code/Magento/Catalog/Helper/Product/View.php b/app/code/Magento/Catalog/Helper/Product/View.php index cf5b15cadc997..95698d382f09e 100644 --- a/app/code/Magento/Catalog/Helper/Product/View.php +++ b/app/code/Magento/Catalog/Helper/Product/View.php @@ -193,7 +193,7 @@ public function initProductLayout(ResultPage $resultPage, $product, $params = nu $resultPage->addPageLayoutHandles(['id' => $product->getId(), 'sku' => $urlSafeSku], $handle); } } - + $resultPage->addPageLayoutHandles(['type' => $product->getTypeId()], null, false); $resultPage->addPageLayoutHandles(['id' => $product->getId(), 'sku' => $urlSafeSku]); diff --git a/app/code/Magento/Catalog/Model/Attribute/Backend/DefaultBackend.php b/app/code/Magento/Catalog/Model/Attribute/Backend/DefaultBackend.php new file mode 100644 index 0000000000000..a02d589fae055 --- /dev/null +++ b/app/code/Magento/Catalog/Model/Attribute/Backend/DefaultBackend.php @@ -0,0 +1,96 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +declare(strict_types=1); + +namespace Magento\Catalog\Model\Attribute\Backend; + +use Magento\Catalog\Model\AbstractModel; +use Magento\Catalog\Model\ResourceModel\Eav\Attribute; +use Magento\Eav\Model\Entity\Attribute\Backend\DefaultBackend as ParentBackend; +use Magento\Eav\Model\Entity\Attribute\Exception; +use Magento\Framework\DataObject; +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\Validation\ValidationException; +use Magento\Framework\Validator\HTML\WYSIWYGValidatorInterface; + +/** + * Default backend model for catalog attributes. + */ +class DefaultBackend extends ParentBackend +{ + /** + * @var WYSIWYGValidatorInterface + */ + private $wysiwygValidator; + + /** + * @param WYSIWYGValidatorInterface $wysiwygValidator + */ + public function __construct(WYSIWYGValidatorInterface $wysiwygValidator) + { + $this->wysiwygValidator = $wysiwygValidator; + } + + /** + * Validate user HTML value. + * + * @param DataObject $object + * @return void + * @throws LocalizedException + */ + private function validateHtml(DataObject $object): void + { + $attribute = $this->getAttribute(); + $code = $attribute->getAttributeCode(); + if ($attribute instanceof Attribute && $attribute->getIsHtmlAllowedOnFront()) { + $value = $object->getData($code); + if ($value + && is_string($value) + && (!($object instanceof AbstractModel) || $object->getData($code) !== $object->getOrigData($code)) + ) { + try { + $this->wysiwygValidator->validate($object->getData($code)); + } catch (ValidationException $exception) { + $attributeException = new Exception( + __( + 'Using restricted HTML elements for "%1". %2', + $attribute->getName(), + $exception->getMessage() + ), + $exception + ); + $attributeException->setAttributeCode($code)->setPart('backend'); + throw $attributeException; + } + } + } + } + + /** + * @inheritDoc + */ + public function beforeSave($object) + { + parent::beforeSave($object); + $this->validateHtml($object); + + return $this; + } + + /** + * @inheritDoc + */ + public function validate($object) + { + $isValid = parent::validate($object); + if ($isValid) { + $this->validateHtml($object); + } + + return $isValid; + } +} diff --git a/app/code/Magento/Catalog/Model/Category/DataProvider.php b/app/code/Magento/Catalog/Model/Category/DataProvider.php index 0a562a9a80c89..f3e3caf309059 100644 --- a/app/code/Magento/Catalog/Model/Category/DataProvider.php +++ b/app/code/Magento/Catalog/Model/Category/DataProvider.php @@ -22,6 +22,8 @@ use Magento\Eav\Model\Entity\Type; use Magento\Framework\App\ObjectManager; use Magento\Framework\App\RequestInterface; +use Magento\Framework\AuthorizationInterface; +use Magento\Framework\Config\DataInterfaceFactory; use Magento\Framework\Exception\LocalizedException; use Magento\Framework\Exception\NoSuchEntityException; use Magento\Framework\Registry; @@ -32,7 +34,6 @@ use Magento\Ui\Component\Form\Field; use Magento\Ui\DataProvider\EavValidationRules; use Magento\Ui\DataProvider\Modifier\PoolInterface; -use Magento\Framework\AuthorizationInterface; use Magento\Ui\DataProvider\ModifierPoolDataProvider; /** @@ -153,6 +154,11 @@ class DataProvider extends ModifierPoolDataProvider */ private $categoryFactory; + /** + * @var DataInterfaceFactory + */ + private $uiConfigFactory; + /** * @var ScopeOverriddenValue */ @@ -177,6 +183,7 @@ class DataProvider extends ModifierPoolDataProvider * @var AuthorizationInterface */ private $auth; + /** * @var Image */ @@ -202,6 +209,7 @@ class DataProvider extends ModifierPoolDataProvider * @param ArrayManager|null $arrayManager * @param FileInfo|null $fileInfo * @param Image|null $categoryImage + * @param DataInterfaceFactory|null $uiConfigFactory * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -223,7 +231,8 @@ public function __construct( ScopeOverriddenValue $scopeOverriddenValue = null, ArrayManager $arrayManager = null, FileInfo $fileInfo = null, - ?Image $categoryImage = null + ?Image $categoryImage = null, + ?DataInterfaceFactory $uiConfigFactory = null ) { $this->eavValidationRules = $eavValidationRules; $this->collection = $categoryCollectionFactory->create(); @@ -240,6 +249,10 @@ public function __construct( $this->arrayManager = $arrayManager ?: ObjectManager::getInstance()->get(ArrayManager::class); $this->fileInfo = $fileInfo ?: ObjectManager::getInstance()->get(FileInfo::class); $this->categoryImage = $categoryImage ?? ObjectManager::getInstance()->get(Image::class); + $this->uiConfigFactory = $uiConfigFactory ?? ObjectManager::getInstance()->create( + DataInterfaceFactory::class, + ['instanceName' => \Magento\Ui\Config\Data::class] + ); parent::__construct($name, $primaryFieldName, $requestFieldName, $meta, $data, $pool); } @@ -611,7 +624,7 @@ private function convertValues($category, $categoryData): array $categoryData[$attributeCode][0]['url'] = $this->categoryImage->getUrl($category, $attributeCode); - $categoryData[$attributeCode][0]['size'] = isset($stat) ? $stat['size'] : 0; + $categoryData[$attributeCode][0]['size'] = $stat['size']; $categoryData[$attributeCode][0]['type'] = $mime; } } @@ -645,56 +658,42 @@ public function getDefaultMetaData($result) */ protected function getFieldsMap() { - return [ - 'general' => [ - 'parent', - 'path', - 'is_active', - 'include_in_menu', - 'name', - ], - 'content' => [ - 'image', - 'description', - 'landing_page', - ], - 'display_settings' => [ - 'display_mode', - 'is_anchor', - 'available_sort_by', - 'use_config.available_sort_by', - 'default_sort_by', - 'use_config.default_sort_by', - 'filter_price_range', - 'use_config.filter_price_range', - ], - 'search_engine_optimization' => [ - 'url_key', - 'url_key_create_redirect', - 'url_key_group', - 'meta_title', - 'meta_keywords', - 'meta_description', - ], - 'assign_products' => [ - ], - 'design' => [ - 'custom_use_parent_settings', - 'custom_apply_to_products', - 'custom_design', - 'page_layout', - 'custom_layout_update', - 'custom_layout_update_file' - ], - 'schedule_design_update' => [ - 'custom_design_from', - 'custom_design_to', - ], - 'category_view_optimization' => [ - ], - 'category_permissions' => [ - ], - ]; + $referenceName = 'category_form'; + $config = $this->uiConfigFactory + ->create(['componentName' => $referenceName]) + ->get($referenceName); + + if (empty($config)) { + return []; + } + + $fieldsMap = []; + + foreach ($config['children'] as $group => $node) { + // Skip disabled components (required for Commerce Edition) + if ($node['arguments']['data']['config']['componentDisabled'] ?? false) { + continue; + } + + $fields = []; + + foreach ($node['children'] as $childName => $childNode) { + if (!empty($childNode['children'])) { + // <container/> nodes need special handling + foreach (array_keys($childNode['children']) as $grandchildName) { + $fields[] = $grandchildName; + } + } else { + $fields[] = $childName; + } + } + + if (!empty($fields)) { + $fieldsMap[$group] = $fields; + } + } + + return $fieldsMap; } /** diff --git a/app/code/Magento/Catalog/Model/Category/FileInfo.php b/app/code/Magento/Catalog/Model/Category/FileInfo.php index 7d679f2645be1..f5aec60b2fcc0 100644 --- a/app/code/Magento/Catalog/Model/Category/FileInfo.php +++ b/app/code/Magento/Catalog/Model/Category/FileInfo.php @@ -239,7 +239,8 @@ private function getMediaDirectoryPathRelativeToBaseDirectoryPath(string $filePa $mediaDirectoryRelativeSubpath = substr($mediaDirectoryPath, strlen($baseDirectoryPath)); $pubDirectory = $baseDirectory->getRelativePath($pubDirectoryPath); - if (strpos($mediaDirectoryRelativeSubpath, $pubDirectory) === 0 && strpos($filePath, $pubDirectory) !== 0) { + if ($pubDirectory && strpos($mediaDirectoryRelativeSubpath, $pubDirectory) === 0 + && strpos($filePath, $pubDirectory) !== 0) { $mediaDirectoryRelativeSubpath = substr($mediaDirectoryRelativeSubpath, strlen($pubDirectory)); } diff --git a/app/code/Magento/Catalog/Model/Category/Product/PositionResolver.php b/app/code/Magento/Catalog/Model/Category/Product/PositionResolver.php index 44bf153f83697..320a253a9a1dd 100644 --- a/app/code/Magento/Catalog/Model/Category/Product/PositionResolver.php +++ b/app/code/Magento/Catalog/Model/Category/Product/PositionResolver.php @@ -49,4 +49,30 @@ public function getPositions(int $categoryId): array return array_flip($connection->fetchCol($select)); } + + /** + * Get category product minimum position + * + * @param int $categoryId + * @return int + */ + public function getMinPosition(int $categoryId): int + { + $connection = $this->getConnection(); + + $select = $connection->select()->from( + ['cpe' => $this->getTable('catalog_product_entity')], + ['position' => new \Zend_Db_Expr('MIN(position)')] + )->joinLeft( + ['ccp' => $this->getTable('catalog_category_product')], + 'ccp.product_id=cpe.entity_id' + )->where( + 'ccp.category_id = ?', + $categoryId + )->order( + 'ccp.product_id ' . \Magento\Framework\DB\Select::SQL_DESC + ); + + return (int)$connection->fetchOne($select); + } } diff --git a/app/code/Magento/Catalog/Model/CategoryLinkManagement.php b/app/code/Magento/Catalog/Model/CategoryLinkManagement.php index 8966848a6d036..591cbc32a0d86 100644 --- a/app/code/Magento/Catalog/Model/CategoryLinkManagement.php +++ b/app/code/Magento/Catalog/Model/CategoryLinkManagement.php @@ -3,11 +3,12 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); namespace Magento\Catalog\Model; /** - * Class CategoryLinkManagement + * Represents Category Product Link Management class */ class CategoryLinkManagement implements \Magento\Catalog\Api\CategoryLinkManagementInterface { @@ -56,7 +57,7 @@ public function __construct( } /** - * {@inheritdoc} + * @inheritdoc */ public function getAssignedProducts($categoryId) { @@ -65,6 +66,7 @@ public function getAssignedProducts($categoryId) /** @var \Magento\Catalog\Model\ResourceModel\Product\Collection $products */ $products = $category->getProductCollection(); $products->addFieldToSelect('position'); + $products->groupByAttribute($products->getProductEntityMetadata()->getIdentifierField()); /** @var \Magento\Catalog\Api\Data\CategoryProductLinkInterface[] $links */ $links = []; diff --git a/app/code/Magento/Catalog/Model/CategoryRepository.php b/app/code/Magento/Catalog/Model/CategoryRepository.php index 0ce52b966c32c..7082fa4747fdc 100644 --- a/app/code/Magento/Catalog/Model/CategoryRepository.php +++ b/app/code/Magento/Catalog/Model/CategoryRepository.php @@ -7,10 +7,16 @@ namespace Magento\Catalog\Model; +use Magento\Catalog\Model\CategoryRepository\PopulateWithValues; +use Magento\Catalog\Model\ResourceModel\Category as CategoryResource; +use Magento\Framework\Api\ExtensibleDataObjectConverter; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\EntityManager\MetadataPool; use Magento\Framework\Exception\CouldNotSaveException; use Magento\Framework\Exception\NoSuchEntityException; use Magento\Framework\Exception\StateException; use Magento\Catalog\Api\Data\CategoryInterface; +use Magento\Store\Model\StoreManagerInterface; /** * Repository for categories. @@ -25,27 +31,27 @@ class CategoryRepository implements \Magento\Catalog\Api\CategoryRepositoryInter protected $instances = []; /** - * @var \Magento\Store\Model\StoreManagerInterface + * @var StoreManagerInterface */ protected $storeManager; /** - * @var \Magento\Catalog\Model\CategoryFactory + * @var CategoryFactory */ protected $categoryFactory; /** - * @var \Magento\Catalog\Model\ResourceModel\Category + * @var CategoryResource */ protected $categoryResource; /** - * @var \Magento\Framework\EntityManager\MetadataPool + * @var MetadataPool */ protected $metadataPool; /** - * @var \Magento\Framework\Api\ExtensibleDataObjectConverter + * @var ExtensibleDataObjectConverter */ private $extensibleDataObjectConverter; @@ -57,28 +63,37 @@ class CategoryRepository implements \Magento\Catalog\Api\CategoryRepositoryInter protected $useConfigFields = ['available_sort_by', 'default_sort_by', 'filter_price_range']; /** - * @param \Magento\Catalog\Model\CategoryFactory $categoryFactory - * @param \Magento\Catalog\Model\ResourceModel\Category $categoryResource - * @param \Magento\Store\Model\StoreManagerInterface $storeManager + * @var PopulateWithValues + */ + private $populateWithValues; + + /** + * @param CategoryFactory $categoryFactory + * @param CategoryResource $categoryResource + * @param StoreManagerInterface $storeManager + * @param PopulateWithValues|null $populateWithValues */ public function __construct( - \Magento\Catalog\Model\CategoryFactory $categoryFactory, - \Magento\Catalog\Model\ResourceModel\Category $categoryResource, - \Magento\Store\Model\StoreManagerInterface $storeManager + CategoryFactory $categoryFactory, + CategoryResource $categoryResource, + StoreManagerInterface $storeManager, + ?PopulateWithValues $populateWithValues ) { $this->categoryFactory = $categoryFactory; $this->categoryResource = $categoryResource; $this->storeManager = $storeManager; + $objectManager = ObjectManager::getInstance(); + $this->populateWithValues = $populateWithValues ?? $objectManager->get(PopulateWithValues::class); } /** * @inheritdoc */ - public function save(\Magento\Catalog\Api\Data\CategoryInterface $category) + public function save(CategoryInterface $category) { $storeId = (int)$this->storeManager->getStore()->getId(); $existingData = $this->getExtensibleDataObjectConverter() - ->toNestedArray($category, [], \Magento\Catalog\Api\Data\CategoryInterface::class); + ->toNestedArray($category, [], CategoryInterface::class); $existingData = array_diff_key($existingData, array_flip(['path', 'level', 'parent_id'])); $existingData['store_id'] = $storeId; @@ -110,7 +125,7 @@ public function save(\Magento\Catalog\Api\Data\CategoryInterface $category) $existingData['parent_id'] = $parentId; $existingData['level'] = null; } - $category->addData($existingData); + $this->populateWithValues->execute($category, $existingData); try { $this->validateCategory($category); $this->categoryResource->save($category); @@ -151,7 +166,7 @@ public function get($categoryId, $storeId = null) /** * @inheritdoc */ - public function delete(\Magento\Catalog\Api\Data\CategoryInterface $category) + public function delete(CategoryInterface $category) { try { $categoryId = $category->getId(); @@ -213,15 +228,15 @@ protected function validateCategory(Category $category) /** * Lazy loader for the converter. * - * @return \Magento\Framework\Api\ExtensibleDataObjectConverter + * @return ExtensibleDataObjectConverter * * @deprecated 101.0.0 */ private function getExtensibleDataObjectConverter() { if ($this->extensibleDataObjectConverter === null) { - $this->extensibleDataObjectConverter = \Magento\Framework\App\ObjectManager::getInstance() - ->get(\Magento\Framework\Api\ExtensibleDataObjectConverter::class); + $this->extensibleDataObjectConverter = ObjectManager::getInstance() + ->get(ExtensibleDataObjectConverter::class); } return $this->extensibleDataObjectConverter; } @@ -229,13 +244,13 @@ private function getExtensibleDataObjectConverter() /** * Lazy loader for the metadata pool. * - * @return \Magento\Framework\EntityManager\MetadataPool + * @return MetadataPool */ private function getMetadataPool() { if (null === $this->metadataPool) { - $this->metadataPool = \Magento\Framework\App\ObjectManager::getInstance() - ->get(\Magento\Framework\EntityManager\MetadataPool::class); + $this->metadataPool = ObjectManager::getInstance() + ->get(MetadataPool::class); } return $this->metadataPool; } diff --git a/app/code/Magento/Catalog/Model/CategoryRepository/PopulateWithValues.php b/app/code/Magento/Catalog/Model/CategoryRepository/PopulateWithValues.php new file mode 100644 index 0000000000000..c6feb049e1a10 --- /dev/null +++ b/app/code/Magento/Catalog/Model/CategoryRepository/PopulateWithValues.php @@ -0,0 +1,153 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Catalog\Model\CategoryRepository; + +use Magento\Catalog\Api\Data\CategoryInterface; +use Magento\Catalog\Model\Attribute\ScopeOverriddenValue; +use Magento\Catalog\Model\Category; +use Magento\Catalog\Api\CategoryAttributeRepositoryInterface as AttributeRepository; +use Magento\Eav\Api\Data\AttributeInterface; +use Magento\Eav\Model\Entity\Attribute\ScopedAttributeInterface; +use Magento\Framework\Api\FilterBuilder; +use Magento\Framework\Api\SearchCriteriaBuilder; +use Magento\Store\Model\Store; + +/** + * Add data to category entity and populate with default values + */ +class PopulateWithValues +{ + /** + * @var ScopeOverriddenValue + */ + private $scopeOverriddenValue; + + /** + * @var AttributeRepository + */ + private $attributeRepository; + + /** + * @var SearchCriteriaBuilder + */ + private $searchCriteriaBuilder; + + /** + * @var FilterBuilder + */ + private $filterBuilder; + + /** + * @var AttributeInterface[] + */ + private $attributes; + + /** + * @param ScopeOverriddenValue $scopeOverriddenValue + * @param AttributeRepository $attributeRepository + * @param SearchCriteriaBuilder $searchCriteriaBuilder + * @param FilterBuilder $filterBuilder + */ + public function __construct( + ScopeOverriddenValue $scopeOverriddenValue, + AttributeRepository $attributeRepository, + SearchCriteriaBuilder $searchCriteriaBuilder, + FilterBuilder $filterBuilder + ) { + $this->scopeOverriddenValue = $scopeOverriddenValue; + $this->attributeRepository = $attributeRepository; + $this->searchCriteriaBuilder = $searchCriteriaBuilder; + $this->filterBuilder = $filterBuilder; + } + + /** + * Set null to entity default values + * + * @param CategoryInterface $category + * @param array $existingData + * @return void + */ + public function execute(CategoryInterface $category, array $existingData): void + { + $storeId = $existingData['store_id'] ?? Store::DEFAULT_STORE_ID; + if ((int)$storeId !== Store::DEFAULT_STORE_ID) { + $overriddenValues = array_filter( + $category->getData(), + function ($key) use ($category, $storeId) { + /** @var Category $category */ + return $this->scopeOverriddenValue->containsValue( + CategoryInterface::class, + $category, + $key, + $storeId + ); + }, + ARRAY_FILTER_USE_KEY + ); + $defaultValues = array_diff_key($category->getData(), $overriddenValues); + array_walk( + $defaultValues, + function (&$value, $key) { + $attributes = $this->getAttributes(); + if (isset($attributes[$key]) && !$attributes[$key]->isStatic()) { + $value = null; + } + } + ); + $category->addData($defaultValues); + } + + $category->addData($existingData); + $useDefaultAttributes = array_filter( + $category->getData(), + function ($attributeValue) { + return null === $attributeValue; + } + ); + $category->setData( + 'use_default', + array_map( + function () { + return true; + }, + $useDefaultAttributes + ) + ); + } + + /** + * Returns entity attributes. + * + * @return AttributeInterface[] + */ + private function getAttributes(): array + { + if ($this->attributes) { + return $this->attributes; + } + + $searchResult = $this->attributeRepository->getList( + $this->searchCriteriaBuilder->addFilters( + [ + $this->filterBuilder + ->setField('is_global') + ->setConditionType('in') + ->setValue([ScopedAttributeInterface::SCOPE_STORE, ScopedAttributeInterface::SCOPE_WEBSITE]) + ->create() + ] + )->create() + ); + + $this->attributes = []; + foreach ($searchResult->getItems() as $attribute) { + $this->attributes[$attribute->getAttributeCode()] = $attribute; + } + + return $this->attributes; + } +} diff --git a/app/code/Magento/Catalog/Model/Config/CatalogMediaConfig.php b/app/code/Magento/Catalog/Model/Config/CatalogMediaConfig.php new file mode 100644 index 0000000000000..0ae128b34d348 --- /dev/null +++ b/app/code/Magento/Catalog/Model/Config/CatalogMediaConfig.php @@ -0,0 +1,50 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Catalog\Model\Config; + +use Magento\Framework\App\Config\ScopeConfigInterface; + +/** + * Config for catalog media + */ +class CatalogMediaConfig +{ + private const XML_PATH_CATALOG_MEDIA_URL_FORMAT = 'web/url/catalog_media_url_format'; + + const IMAGE_OPTIMIZATION_PARAMETERS = 'image_optimization_parameters'; + const HASH = 'hash'; + + /** + * @var ScopeConfigInterface + */ + private $scopeConfig; + + /** + * @param ScopeConfigInterface $scopeConfig + */ + public function __construct(ScopeConfigInterface $scopeConfig) + { + $this->scopeConfig = $scopeConfig; + } + + /** + * Get media URL format for catalog images + * + * @param string $scopeType + * @param null|int|string $scopeCode + * @return string + */ + public function getMediaUrlFormat($scopeType = ScopeConfigInterface::SCOPE_TYPE_DEFAULT, $scopeCode = null): string + { + return $this->scopeConfig->getValue( + CatalogMediaConfig::XML_PATH_CATALOG_MEDIA_URL_FORMAT, + $scopeType, + $scopeCode + ); + } +} diff --git a/app/code/Magento/Catalog/Model/Config/LayerCategoryConfig.php b/app/code/Magento/Catalog/Model/Config/LayerCategoryConfig.php index 0e8565e3b25d1..b6896929e0f5a 100644 --- a/app/code/Magento/Catalog/Model/Config/LayerCategoryConfig.php +++ b/app/code/Magento/Catalog/Model/Config/LayerCategoryConfig.php @@ -60,7 +60,7 @@ public function isCategoryFilterVisibleInLayerNavigation( } return $this->scopeConfig->isSetFlag( - static::XML_PATH_CATALOG_LAYERED_NAVIGATION_DISPLAY_CATEGORY, + self::XML_PATH_CATALOG_LAYERED_NAVIGATION_DISPLAY_CATEGORY, $scopeType, $scopeCode ); diff --git a/app/code/Magento/Catalog/Model/Config/Source/Web/CatalogMediaUrlFormat.php b/app/code/Magento/Catalog/Model/Config/Source/Web/CatalogMediaUrlFormat.php new file mode 100644 index 0000000000000..bab2d5ccb3f1f --- /dev/null +++ b/app/code/Magento/Catalog/Model/Config/Source/Web/CatalogMediaUrlFormat.php @@ -0,0 +1,33 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Catalog\Model\Config\Source\Web; + +use Magento\Catalog\Model\Config\CatalogMediaConfig; + +/** + * Option provider for catalog media URL format system setting. + */ +class CatalogMediaUrlFormat implements \Magento\Framework\Data\OptionSourceInterface +{ + /** + * Get a list of supported catalog media URL formats. + * + * @codeCoverageIgnore + * @return array + */ + public function toOptionArray(): array + { + return [ + [ + 'value' => CatalogMediaConfig::IMAGE_OPTIMIZATION_PARAMETERS, + 'label' => __('Image optimization based on query parameters') + ], + ['value' => CatalogMediaConfig::HASH, 'label' => __('Unique hash per image variant (Legacy mode)')] + ]; + } +} diff --git a/app/code/Magento/Catalog/Model/ImageUploader.php b/app/code/Magento/Catalog/Model/ImageUploader.php index b0c8d56057431..6aff6488164f9 100644 --- a/app/code/Magento/Catalog/Model/ImageUploader.php +++ b/app/code/Magento/Catalog/Model/ImageUploader.php @@ -5,7 +5,17 @@ */ namespace Magento\Catalog\Model; +use Magento\Framework\App\Filesystem\DirectoryList; +use Magento\Framework\Exception\LocalizedException; use Magento\Framework\File\Uploader; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\File\Name; +use Magento\Framework\Filesystem; +use Magento\Framework\Filesystem\Directory\WriteInterface; +use Magento\MediaStorage\Helper\File\Storage\Database; +use Magento\MediaStorage\Model\File\UploaderFactory; +use Magento\Store\Model\StoreManagerInterface; +use Psr\Log\LoggerInterface; /** * Catalog image uploader @@ -13,92 +23,84 @@ class ImageUploader { /** - * Core file storage database - * - * @var \Magento\MediaStorage\Helper\File\Storage\Database + * @var Database */ protected $coreFileStorageDatabase; /** - * Media directory object (writable). - * - * @var \Magento\Framework\Filesystem\Directory\WriteInterface + * @var WriteInterface */ protected $mediaDirectory; /** - * Uploader factory - * - * @var \Magento\MediaStorage\Model\File\UploaderFactory + * @var UploaderFactory */ private $uploaderFactory; /** - * Store manager - * - * @var \Magento\Store\Model\StoreManagerInterface + * @var StoreManagerInterface */ protected $storeManager; /** - * @var \Psr\Log\LoggerInterface + * @var LoggerInterface */ protected $logger; /** - * Base tmp path - * * @var string */ protected $baseTmpPath; /** - * Base path - * * @var string */ protected $basePath; /** - * Allowed extensions - * * @var string */ protected $allowedExtensions; /** - * List of allowed image mime types - * * @var string[] */ private $allowedMimeTypes; /** - * ImageUploader constructor + * @var Name + */ + private $fileNameLookup; + + /** + * ImageUploader constructor. * - * @param \Magento\MediaStorage\Helper\File\Storage\Database $coreFileStorageDatabase - * @param \Magento\Framework\Filesystem $filesystem - * @param \Magento\MediaStorage\Model\File\UploaderFactory $uploaderFactory - * @param \Magento\Store\Model\StoreManagerInterface $storeManager - * @param \Psr\Log\LoggerInterface $logger + * @param Database $coreFileStorageDatabase + * @param Filesystem $filesystem + * @param UploaderFactory $uploaderFactory + * @param StoreManagerInterface $storeManager + * @param LoggerInterface $logger * @param string $baseTmpPath * @param string $basePath * @param string[] $allowedExtensions * @param string[] $allowedMimeTypes + * @param Name|null $fileNameLookup + * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( - \Magento\MediaStorage\Helper\File\Storage\Database $coreFileStorageDatabase, - \Magento\Framework\Filesystem $filesystem, - \Magento\MediaStorage\Model\File\UploaderFactory $uploaderFactory, - \Magento\Store\Model\StoreManagerInterface $storeManager, - \Psr\Log\LoggerInterface $logger, + Database $coreFileStorageDatabase, + Filesystem $filesystem, + UploaderFactory $uploaderFactory, + StoreManagerInterface $storeManager, + LoggerInterface $logger, $baseTmpPath, $basePath, $allowedExtensions, - $allowedMimeTypes = [] + $allowedMimeTypes = [], + Name $fileNameLookup = null ) { $this->coreFileStorageDatabase = $coreFileStorageDatabase; - $this->mediaDirectory = $filesystem->getDirectoryWrite(\Magento\Framework\App\Filesystem\DirectoryList::MEDIA); + $this->mediaDirectory = $filesystem->getDirectoryWrite(DirectoryList::MEDIA); $this->uploaderFactory = $uploaderFactory; $this->storeManager = $storeManager; $this->logger = $logger; @@ -106,13 +108,13 @@ public function __construct( $this->basePath = $basePath; $this->allowedExtensions = $allowedExtensions; $this->allowedMimeTypes = $allowedMimeTypes; + $this->fileNameLookup = $fileNameLookup ?? ObjectManager::getInstance()->get(Name::class); } /** * Set base tmp path * * @param string $baseTmpPath - * * @return void */ public function setBaseTmpPath($baseTmpPath) @@ -124,7 +126,6 @@ public function setBaseTmpPath($baseTmpPath) * Set base path * * @param string $basePath - * * @return void */ public function setBasePath($basePath) @@ -136,7 +137,6 @@ public function setBasePath($basePath) * Set allowed extensions * * @param string[] $allowedExtensions - * * @return void */ public function setAllowedExtensions($allowedExtensions) @@ -179,7 +179,6 @@ public function getAllowedExtensions() * * @param string $path * @param string $imageName - * * @return string */ public function getFilePath($path, $imageName) @@ -194,7 +193,7 @@ public function getFilePath($path, $imageName) * @param bool $returnRelativePath * @return string * - * @throws \Magento\Framework\Exception\LocalizedException + * @throws LocalizedException */ public function moveFileFromTmp($imageName, $returnRelativePath = false) { @@ -203,7 +202,7 @@ public function moveFileFromTmp($imageName, $returnRelativePath = false) $baseImagePath = $this->getFilePath( $basePath, - Uploader::getNewFileName( + $this->fileNameLookup->getNewFileName( $this->mediaDirectory->getAbsolutePath( $this->getFilePath($basePath, $imageName) ) @@ -222,10 +221,7 @@ public function moveFileFromTmp($imageName, $returnRelativePath = false) ); } catch (\Exception $e) { $this->logger->critical($e); - throw new \Magento\Framework\Exception\LocalizedException( - __('Something went wrong while saving the file(s).'), - $e - ); + throw new LocalizedException(__('Something went wrong while saving the file(s).'), $e); } return $returnRelativePath ? $baseImagePath : $imageName; @@ -235,10 +231,9 @@ public function moveFileFromTmp($imageName, $returnRelativePath = false) * Checking file for save and save it to tmp dir * * @param string $fileId - * * @return string[] * - * @throws \Magento\Framework\Exception\LocalizedException + * @throws LocalizedException */ public function saveFileToTmpDir($fileId) { @@ -249,15 +244,13 @@ public function saveFileToTmpDir($fileId) $uploader->setAllowedExtensions($this->getAllowedExtensions()); $uploader->setAllowRenameFiles(true); if (!$uploader->checkMimeType($this->allowedMimeTypes)) { - throw new \Magento\Framework\Exception\LocalizedException(__('File validation failed.')); + throw new LocalizedException(__('File validation failed.')); } $result = $uploader->save($this->mediaDirectory->getAbsolutePath($baseTmpPath)); unset($result['path']); if (!$result) { - throw new \Magento\Framework\Exception\LocalizedException( - __('File can not be saved to the destination folder.') - ); + throw new LocalizedException(__('File can not be saved to the destination folder.')); } /** @@ -277,7 +270,7 @@ public function saveFileToTmpDir($fileId) $this->coreFileStorageDatabase->saveFile($relativePath); } catch (\Exception $e) { $this->logger->critical($e); - throw new \Magento\Framework\Exception\LocalizedException( + throw new LocalizedException( __('Something went wrong while saving the file(s).'), $e ); diff --git a/app/code/Magento/Catalog/Model/Indexer/Category/Product.php b/app/code/Magento/Catalog/Model/Indexer/Category/Product.php index c18404bda1fc8..f5a8c33cfa6c9 100644 --- a/app/code/Magento/Catalog/Model/Indexer/Category/Product.php +++ b/app/code/Magento/Catalog/Model/Indexer/Category/Product.php @@ -64,8 +64,8 @@ public function __construct( */ public function execute($ids) { - $this->executeAction($ids); $this->registerEntities($ids); + $this->executeAction($ids); } /** diff --git a/app/code/Magento/Catalog/Model/Indexer/Category/Product/Action/Rows.php b/app/code/Magento/Catalog/Model/Indexer/Category/Product/Action/Rows.php index 5d81c1405efe0..c53277a58157d 100644 --- a/app/code/Magento/Catalog/Model/Indexer/Category/Product/Action/Rows.php +++ b/app/code/Magento/Catalog/Model/Indexer/Category/Product/Action/Rows.php @@ -13,15 +13,17 @@ use Magento\Store\Model\StoreManagerInterface; use Magento\Framework\DB\Query\Generator as QueryGenerator; use Magento\Framework\EntityManager\MetadataPool; +use Magento\Framework\Indexer\IndexerRegistry; use Magento\Catalog\Model\Config; use Magento\Catalog\Model\Category; -use Magento\Framework\Indexer\IndexerRegistry; use Magento\Catalog\Model\Indexer\Product\Category as ProductCategoryIndexer; +use Magento\Catalog\Model\Indexer\Category\Product as CategoryProductIndexer; +use Magento\Catalog\Model\Indexer\Category\Product\TableMaintainer; +use Magento\Indexer\Model\WorkingStateProvider; /** * Reindex multiple rows action. * - * @package Magento\Catalog\Model\Indexer\Category\Product\Action * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class Rows extends \Magento\Catalog\Model\Indexer\Category\Product\AbstractAction @@ -48,15 +50,23 @@ class Rows extends \Magento\Catalog\Model\Indexer\Category\Product\AbstractActio */ private $indexerRegistry; + /** + * @var WorkingStateProvider + */ + private $workingStateProvider; + /** * @param ResourceConnection $resource * @param StoreManagerInterface $storeManager * @param Config $config * @param QueryGenerator|null $queryGenerator * @param MetadataPool|null $metadataPool + * @param TableMaintainer|null $tableMaintainer * @param CacheContext|null $cacheContext * @param EventManagerInterface|null $eventManager * @param IndexerRegistry|null $indexerRegistry + * @param WorkingStateProvider|null $workingStateProvider + * @SuppressWarnings(PHPMD.ExcessiveParameterList) Preserve compatibility with the parent class */ public function __construct( ResourceConnection $resource, @@ -64,14 +74,18 @@ public function __construct( Config $config, QueryGenerator $queryGenerator = null, MetadataPool $metadataPool = null, + ?TableMaintainer $tableMaintainer = null, CacheContext $cacheContext = null, EventManagerInterface $eventManager = null, - IndexerRegistry $indexerRegistry = null + IndexerRegistry $indexerRegistry = null, + ?WorkingStateProvider $workingStateProvider = null ) { - parent::__construct($resource, $storeManager, $config, $queryGenerator, $metadataPool); + parent::__construct($resource, $storeManager, $config, $queryGenerator, $metadataPool, $tableMaintainer); $this->cacheContext = $cacheContext ?: ObjectManager::getInstance()->get(CacheContext::class); $this->eventManager = $eventManager ?: ObjectManager::getInstance()->get(EventManagerInterface::class); $this->indexerRegistry = $indexerRegistry ?: ObjectManager::getInstance()->get(IndexerRegistry::class); + $this->workingStateProvider = $workingStateProvider ?: + ObjectManager::getInstance()->get(WorkingStateProvider::class); } /** @@ -97,44 +111,64 @@ public function execute(array $entityIds = [], $useTempTable = false) $this->limitationByCategories = array_unique($this->limitationByCategories); $this->useTempTable = $useTempTable; $indexer = $this->indexerRegistry->get(ProductCategoryIndexer::INDEXER_ID); - $workingState = $indexer->isWorking(); + $workingState = $this->isWorkingState(); - if ($useTempTable && !$workingState && $indexer->isScheduled()) { - foreach ($this->storeManager->getStores() as $store) { - $this->connection->truncateTable($this->getIndexTable($store->getId())); + if (!$indexer->isScheduled() + || ($indexer->isScheduled() && !$useTempTable) + || ($indexer->isScheduled() && $useTempTable && !$workingState)) { + if ($useTempTable && !$workingState && $indexer->isScheduled()) { + foreach ($this->storeManager->getStores() as $store) { + $this->connection->truncateTable($this->getIndexTable($store->getId())); + } + } else { + $this->removeEntries(); } - } else { - $this->removeEntries(); - } - $this->reindex(); - - if ($useTempTable && !$workingState && $indexer->isScheduled()) { - foreach ($this->storeManager->getStores() as $store) { - $removalCategoryIds = array_diff($this->limitationByCategories, [$this->getRootCategoryId($store)]); - $this->connection->delete( - $this->tableMaintainer->getMainTable($store->getId()), - ['category_id IN (?)' => $removalCategoryIds] - ); - $select = $this->connection->select() - ->from($this->tableMaintainer->getMainReplicaTable($store->getId())); - $this->connection->query( - $this->connection->insertFromSelect( - $select, + $this->reindex(); + + // get actual state + $workingState = $this->isWorkingState(); + + if ($useTempTable && !$workingState && $indexer->isScheduled()) { + foreach ($this->storeManager->getStores() as $store) { + $removalCategoryIds = array_diff($this->limitationByCategories, [$this->getRootCategoryId($store)]); + $this->connection->delete( $this->tableMaintainer->getMainTable($store->getId()), - [], - AdapterInterface::INSERT_ON_DUPLICATE - ) - ); + ['category_id IN (?)' => $removalCategoryIds] + ); + $select = $this->connection->select() + ->from($this->tableMaintainer->getMainReplicaTable($store->getId())); + $this->connection->query( + $this->connection->insertFromSelect( + $select, + $this->tableMaintainer->getMainTable($store->getId()), + [], + AdapterInterface::INSERT_ON_DUPLICATE + ) + ); + } } - } - $this->registerCategories($entityIds); - $this->eventManager->dispatch('clean_cache_by_tags', ['object' => $this->cacheContext]); + $this->registerCategories($entityIds); + $this->eventManager->dispatch('clean_cache_by_tags', ['object' => $this->cacheContext]); + } return $this; } + /** + * Get state for current and shared indexer + * + * @return bool + */ + private function isWorkingState() : bool + { + $indexer = $this->indexerRegistry->get(ProductCategoryIndexer::INDEXER_ID); + $sharedIndexer = $this->indexerRegistry->get(CategoryProductIndexer::INDEXER_ID); + return $this->workingStateProvider->isWorking($indexer->getId()) + || $this->workingStateProvider->isWorking($sharedIndexer->getId()); + } + /** * Register categories assigned to products * diff --git a/app/code/Magento/Catalog/Model/Indexer/Category/Product/Plugin/TableResolver.php b/app/code/Magento/Catalog/Model/Indexer/Category/Product/Plugin/TableResolver.php index 936e6163cbcc5..0c0c72b0322dc 100644 --- a/app/code/Magento/Catalog/Model/Indexer/Category/Product/Plugin/TableResolver.php +++ b/app/code/Magento/Catalog/Model/Indexer/Category/Product/Plugin/TableResolver.php @@ -55,7 +55,10 @@ public function afterGetTableName( string $result, $modelEntity ) { - if (!is_array($modelEntity) && $modelEntity === AbstractAction::MAIN_INDEX_TABLE) { + if (!is_array($modelEntity) && + $modelEntity === AbstractAction::MAIN_INDEX_TABLE && + $this->storeManager->getStore()->getId() + ) { $catalogCategoryProductDimension = new Dimension( \Magento\Store\Model\Store::ENTITY, $this->storeManager->getStore()->getId() diff --git a/app/code/Magento/Catalog/Model/Indexer/Product/Category/Action/Rows.php b/app/code/Magento/Catalog/Model/Indexer/Product/Category/Action/Rows.php index edd68422ec4ac..ab04f7c56c3db 100644 --- a/app/code/Magento/Catalog/Model/Indexer/Product/Category/Action/Rows.php +++ b/app/code/Magento/Catalog/Model/Indexer/Product/Category/Action/Rows.php @@ -17,7 +17,10 @@ use Magento\Store\Model\StoreManagerInterface; use Magento\Framework\DB\Adapter\AdapterInterface; use Magento\Framework\Indexer\IndexerRegistry; +use Magento\Catalog\Model\Indexer\Product\Category as ProductCategoryIndexer; use Magento\Catalog\Model\Indexer\Category\Product as CategoryProductIndexer; +use Magento\Catalog\Model\Indexer\Category\Product\TableMaintainer; +use Magento\Indexer\Model\WorkingStateProvider; /** * Category rows indexer. @@ -48,15 +51,23 @@ class Rows extends \Magento\Catalog\Model\Indexer\Category\Product\AbstractActio */ private $indexerRegistry; + /** + * @var WorkingStateProvider + */ + private $workingStateProvider; + /** * @param ResourceConnection $resource * @param StoreManagerInterface $storeManager * @param Config $config * @param QueryGenerator|null $queryGenerator * @param MetadataPool|null $metadataPool + * @param TableMaintainer|null $tableMaintainer * @param CacheContext|null $cacheContext * @param EventManagerInterface|null $eventManager * @param IndexerRegistry|null $indexerRegistry + * @param WorkingStateProvider|null $workingStateProvider + * @SuppressWarnings(PHPMD.ExcessiveParameterList) Preserve compatibility with the parent class */ public function __construct( ResourceConnection $resource, @@ -64,14 +75,18 @@ public function __construct( Config $config, QueryGenerator $queryGenerator = null, MetadataPool $metadataPool = null, + ?TableMaintainer $tableMaintainer = null, CacheContext $cacheContext = null, EventManagerInterface $eventManager = null, - IndexerRegistry $indexerRegistry = null + IndexerRegistry $indexerRegistry = null, + ?WorkingStateProvider $workingStateProvider = null ) { - parent::__construct($resource, $storeManager, $config, $queryGenerator, $metadataPool); + parent::__construct($resource, $storeManager, $config, $queryGenerator, $metadataPool, $tableMaintainer); $this->cacheContext = $cacheContext ?: ObjectManager::getInstance()->get(CacheContext::class); $this->eventManager = $eventManager ?: ObjectManager::getInstance()->get(EventManagerInterface::class); $this->indexerRegistry = $indexerRegistry ?: ObjectManager::getInstance()->get(IndexerRegistry::class); + $this->workingStateProvider = $workingStateProvider ?: + ObjectManager::getInstance()->get(WorkingStateProvider::class); } /** @@ -82,6 +97,7 @@ public function __construct( * @return $this * @throws \Exception if metadataPool doesn't contain metadata for ProductInterface * @throws \DomainException + * @SuppressWarnings(PHPMD.CyclomaticComplexity) */ public function execute(array $entityIds = [], $useTempTable = false) { @@ -90,46 +106,68 @@ public function execute(array $entityIds = [], $useTempTable = false) $this->limitationByProducts = $idsToBeReIndexed; $this->useTempTable = $useTempTable; $indexer = $this->indexerRegistry->get(CategoryProductIndexer::INDEXER_ID); - $workingState = $indexer->isWorking(); + $workingState = $this->isWorkingState(); - $affectedCategories = $this->getCategoryIdsFromIndex($idsToBeReIndexed); + if (!$indexer->isScheduled() + || ($indexer->isScheduled() && !$useTempTable) + || ($indexer->isScheduled() && $useTempTable && !$workingState)) { - if ($useTempTable && !$workingState && $indexer->isScheduled()) { - foreach ($this->storeManager->getStores() as $store) { - $this->connection->truncateTable($this->getIndexTable($store->getId())); + $affectedCategories = $this->getCategoryIdsFromIndex($idsToBeReIndexed); + + if ($useTempTable && !$workingState && $indexer->isScheduled()) { + foreach ($this->storeManager->getStores() as $store) { + $this->connection->truncateTable($this->getIndexTable($store->getId())); + } + } else { + $this->removeEntries(); } - } else { - $this->removeEntries(); - } - $this->reindex(); - if ($useTempTable && !$workingState && $indexer->isScheduled()) { - foreach ($this->storeManager->getStores() as $store) { - $this->connection->delete( - $this->tableMaintainer->getMainTable($store->getId()), - ['product_id IN (?)' => $this->limitationByProducts] - ); - $select = $this->connection->select() - ->from($this->tableMaintainer->getMainReplicaTable($store->getId())); - $this->connection->query( - $this->connection->insertFromSelect( - $select, + $this->reindex(); + + // get actual state + $workingState = $this->isWorkingState(); + + if ($useTempTable && !$workingState && $indexer->isScheduled()) { + foreach ($this->storeManager->getStores() as $store) { + $this->connection->delete( $this->tableMaintainer->getMainTable($store->getId()), - [], - AdapterInterface::INSERT_ON_DUPLICATE - ) - ); + ['product_id IN (?)' => $this->limitationByProducts] + ); + $select = $this->connection->select() + ->from($this->tableMaintainer->getMainReplicaTable($store->getId())); + $this->connection->query( + $this->connection->insertFromSelect( + $select, + $this->tableMaintainer->getMainTable($store->getId()), + [], + AdapterInterface::INSERT_ON_DUPLICATE + ) + ); + } } - } - $affectedCategories = array_merge($affectedCategories, $this->getCategoryIdsFromIndex($idsToBeReIndexed)); + $affectedCategories = array_merge($affectedCategories, $this->getCategoryIdsFromIndex($idsToBeReIndexed)); - $this->registerProducts($idsToBeReIndexed); - $this->registerCategories($affectedCategories); - $this->eventManager->dispatch('clean_cache_by_tags', ['object' => $this->cacheContext]); + $this->registerProducts($idsToBeReIndexed); + $this->registerCategories($affectedCategories); + $this->eventManager->dispatch('clean_cache_by_tags', ['object' => $this->cacheContext]); + } return $this; } + /** + * Get state for current and shared indexer + * + * @return bool + */ + private function isWorkingState() : bool + { + $indexer = $this->indexerRegistry->get(CategoryProductIndexer::INDEXER_ID); + $sharedIndexer = $this->indexerRegistry->get(ProductCategoryIndexer::INDEXER_ID); + return $this->workingStateProvider->isWorking($indexer->getId()) + || $this->workingStateProvider->isWorking($sharedIndexer->getId()); + } + /** * Get IDs of parent products by their child IDs. * @@ -270,14 +308,14 @@ private function getCategoryIdsFromIndex(array $productIds): array ); $categoryIds[] = $storeCategories; } - $categoryIds = array_merge(...$categoryIds); + $categoryIds = array_merge([], ...$categoryIds); $parentCategories = [$categoryIds]; foreach ($categoryIds as $categoryId) { $parentIds = explode('/', $this->getPathFromCategoryId($categoryId)); $parentCategories[] = $parentIds; } - $categoryIds = array_unique(array_merge(...$parentCategories)); + $categoryIds = array_unique(array_merge([], ...$parentCategories)); return $categoryIds; } diff --git a/app/code/Magento/Catalog/Model/Indexer/Product/Flat/FlatTableBuilder.php b/app/code/Magento/Catalog/Model/Indexer/Product/Flat/FlatTableBuilder.php index 99d75186eca8c..a0af09edf14c5 100644 --- a/app/code/Magento/Catalog/Model/Indexer/Product/Flat/FlatTableBuilder.php +++ b/app/code/Magento/Catalog/Model/Indexer/Product/Flat/FlatTableBuilder.php @@ -261,7 +261,7 @@ protected function _fillTemporaryFlatTable(array $tables, $storeId, $valueFieldS $select->from( ['et' => $entityTemporaryTableName], - array_merge(...$allColumns) + array_merge([], ...$allColumns) )->joinInner( ['e' => $this->resource->getTableName('catalog_product_entity')], 'e.entity_id = et.entity_id', @@ -306,7 +306,7 @@ protected function _fillTemporaryFlatTable(array $tables, $storeId, $valueFieldS $allColumns[] = $columnValueNames; } } - $sql = $select->insertFromSelect($temporaryFlatTableName, array_merge(...$allColumns), false); + $sql = $select->insertFromSelect($temporaryFlatTableName, array_merge([], ...$allColumns), false); $this->_connection->query($sql); } diff --git a/app/code/Magento/Catalog/Model/Indexer/Product/Flat/TableBuilder.php b/app/code/Magento/Catalog/Model/Indexer/Product/Flat/TableBuilder.php index c14ea4bc363f8..0f3c186eaffd9 100644 --- a/app/code/Magento/Catalog/Model/Indexer/Product/Flat/TableBuilder.php +++ b/app/code/Magento/Catalog/Model/Indexer/Product/Flat/TableBuilder.php @@ -86,12 +86,13 @@ public function build($storeId, $changedIds, $valueFieldSuffix) //Create list of temporary tables based on available attributes attributes $valueTables = []; foreach ($temporaryEavAttributes as $tableName => $columns) { - // phpcs:ignore Magento2.Performance.ForeachArrayMerge - $valueTables = array_merge( - $valueTables, - $this->_createTemporaryTable($this->_getTemporaryTableName($tableName), $columns, $valueFieldSuffix) + $valueTables[] = $this->_createTemporaryTable( + $this->_getTemporaryTableName($tableName), + $columns, + $valueFieldSuffix ); } + $valueTables = array_merge([], ...$valueTables); //Fill "base" table which contains all available products $this->_fillTemporaryEntityTable($entityTableName, $entityTableColumns, $changedIds); diff --git a/app/code/Magento/Catalog/Model/Indexer/Product/Price/Action/Rows.php b/app/code/Magento/Catalog/Model/Indexer/Product/Price/Action/Rows.php index 27b50eea883b0..acbe20721ee9e 100644 --- a/app/code/Magento/Catalog/Model/Indexer/Product/Price/Action/Rows.php +++ b/app/code/Magento/Catalog/Model/Indexer/Product/Price/Action/Rows.php @@ -5,12 +5,82 @@ */ namespace Magento\Catalog\Model\Indexer\Product\Price\Action; +use Magento\Directory\Model\CurrencyFactory; +use Magento\Catalog\Model\Indexer\Product\Price\DimensionCollectionFactory; +use Magento\Catalog\Model\Indexer\Product\Price\TableMaintainer; +use Magento\Catalog\Model\Product\Type; +use Magento\Catalog\Model\ResourceModel\Product\Indexer\Price\DefaultPrice; +use Magento\Catalog\Model\ResourceModel\Product\Indexer\Price\Factory; +use Magento\Catalog\Model\ResourceModel\Product\Indexer\Price\TierPrice; +use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Framework\Stdlib\DateTime; +use Magento\Framework\Stdlib\DateTime\TimezoneInterface; +use Magento\Store\Model\StoreManagerInterface; + /** * Class Rows reindex action for mass actions * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) to preserve compatibility with parent class */ class Rows extends \Magento\Catalog\Model\Indexer\Product\Price\AbstractAction { + /** + * Default batch size + */ + private const BATCH_SIZE = 100; + + /** + * @var int + */ + private $batchSize; + + /** + * @param ScopeConfigInterface $config + * @param StoreManagerInterface $storeManager + * @param CurrencyFactory $currencyFactory + * @param TimezoneInterface $localeDate + * @param DateTime $dateTime + * @param Type $catalogProductType + * @param Factory $indexerPriceFactory + * @param DefaultPrice $defaultIndexerResource + * @param TierPrice|null $tierPriceIndexResource + * @param DimensionCollectionFactory|null $dimensionCollectionFactory + * @param TableMaintainer|null $tableMaintainer + * @param int|null $batchSize + * @SuppressWarnings(PHPMD.NPathComplexity) Added to backward compatibility with abstract class + * @SuppressWarnings(PHPMD.CyclomaticComplexity) Added to backward compatibility with abstract class + * @SuppressWarnings(PHPMD.ExcessiveParameterList) Added to backward compatibility with abstract class + */ + public function __construct( + ScopeConfigInterface $config, + StoreManagerInterface $storeManager, + CurrencyFactory $currencyFactory, + TimezoneInterface $localeDate, + DateTime $dateTime, + Type $catalogProductType, + Factory $indexerPriceFactory, + DefaultPrice $defaultIndexerResource, + TierPrice $tierPriceIndexResource = null, + DimensionCollectionFactory $dimensionCollectionFactory = null, + TableMaintainer $tableMaintainer = null, + ?int $batchSize = null + ) { + parent::__construct( + $config, + $storeManager, + $currencyFactory, + $localeDate, + $dateTime, + $catalogProductType, + $indexerPriceFactory, + $defaultIndexerResource, + $tierPriceIndexResource, + $dimensionCollectionFactory, + $tableMaintainer + ); + $this->batchSize = $batchSize ?? self::BATCH_SIZE; + } + /** * Execute Rows reindex * @@ -24,10 +94,28 @@ public function execute($ids) if (empty($ids)) { throw new \Magento\Framework\Exception\InputException(__('Bad value was supplied.')); } - try { - $this->_reindexRows($ids); - } catch (\Exception $e) { - throw new \Magento\Framework\Exception\LocalizedException(__($e->getMessage()), $e); + $currentBatch = []; + $i = 0; + + foreach ($ids as $id) { + $currentBatch[] = $id; + if (++$i === $this->batchSize) { + try { + $this->_reindexRows($currentBatch); + } catch (\Exception $e) { + throw new \Magento\Framework\Exception\LocalizedException(__($e->getMessage()), $e); + } + $i = 0; + $currentBatch = []; + } + } + + if (!empty($currentBatch)) { + try { + $this->_reindexRows($currentBatch); + } catch (\Exception $e) { + throw new \Magento\Framework\Exception\LocalizedException(__($e->getMessage()), $e); + } } } } diff --git a/app/code/Magento/Catalog/Model/Product.php b/app/code/Magento/Catalog/Model/Product.php index 7c463267e5a58..82d252acd9909 100644 --- a/app/code/Magento/Catalog/Model/Product.php +++ b/app/code/Magento/Catalog/Model/Product.php @@ -10,6 +10,7 @@ use Magento\Catalog\Api\Data\ProductInterface; use Magento\Catalog\Api\ProductLinkRepositoryInterface; use Magento\Catalog\Model\Product\Attribute\Backend\Media\EntryConverterPool; +use Magento\Catalog\Model\Product\Attribute\Source\Status; use Magento\Catalog\Model\Product\Configuration\Item\Option\OptionInterface; use Magento\Framework\Api\AttributeValueFactory; use Magento\Framework\App\Filesystem\DirectoryList; @@ -202,7 +203,7 @@ class Product extends \Magento\Catalog\Model\AbstractModel implements /** * Catalog product status * - * @var \Magento\Catalog\Model\Product\Attribute\Source\Status + * @var Status */ protected $_catalogProductStatus; @@ -408,7 +409,7 @@ public function __construct( \Magento\CatalogInventory\Api\Data\StockItemInterfaceFactory $stockItemFactory, \Magento\Catalog\Model\Product\OptionFactory $catalogProductOptionFactory, \Magento\Catalog\Model\Product\Visibility $catalogProductVisibility, - \Magento\Catalog\Model\Product\Attribute\Source\Status $catalogProductStatus, + Status $catalogProductStatus, \Magento\Catalog\Model\Product\Media\Config $catalogProductMediaConfig, Product\Type $catalogProductType, \Magento\Framework\Module\Manager $moduleManager, @@ -668,7 +669,7 @@ public function getTypeId() public function getStatus() { $status = $this->_getData(self::STATUS); - return $status !== null ? $status : \Magento\Catalog\Model\Product\Attribute\Source\Status::STATUS_ENABLED; + return $status !== null ? $status : Status::STATUS_ENABLED; } /** @@ -835,10 +836,7 @@ public function getStoreIds() $storeIds[] = $websiteStores; } } - if ($storeIds) { - $storeIds = array_merge(...$storeIds); - } - $this->setStoreIds($storeIds); + $this->setStoreIds(array_merge([], ...$storeIds)); } return $this->getData('store_ids'); } @@ -1033,7 +1031,7 @@ public function priceReindexCallback() */ public function eavReindexCallback() { - if ($this->isObjectNew() || $this->isDataChanged($this)) { + if ($this->isObjectNew() || $this->isDataChanged()) { $this->_productEavIndexerProcessor->reindexRow($this->getEntityId()); } } @@ -1103,7 +1101,7 @@ public function afterDeleteCommit() protected function _afterLoad() { if (!$this->hasData(self::STATUS)) { - $this->setData(self::STATUS, \Magento\Catalog\Model\Product\Attribute\Source\Status::STATUS_ENABLED); + $this->setData(self::STATUS, Status::STATUS_ENABLED); } parent::_afterLoad(); return $this; @@ -1179,7 +1177,7 @@ public function getTierPrice($qty = null) /** * Get formatted by currency product price * - * @return array|double + * @return array|double * @since 102.0.6 */ public function getFormattedPrice() @@ -1780,7 +1778,7 @@ public function isSaleable() */ public function isInStock() { - return $this->getStatus() == \Magento\Catalog\Model\Product\Attribute\Source\Status::STATUS_ENABLED; + return $this->getStatus() == Status::STATUS_ENABLED; } /** @@ -2341,7 +2339,7 @@ public function getProductEntitiesInfo($columns = null) */ public function isDisabled() { - return $this->getStatus() == \Magento\Catalog\Model\Product\Attribute\Source\Status::STATUS_DISABLED; + return $this->getStatus() == Status::STATUS_DISABLED; } /** @@ -2357,6 +2355,22 @@ public function getImage() return parent::getImage(); } + /** + * Get identities for related to product categories + * + * @param array $categoryIds + * @return array + */ + private function getProductCategoryIdentities(array $categoryIds): array + { + $identities = []; + foreach ($categoryIds as $categoryId) { + $identities[] = self::CACHE_PRODUCT_CATEGORY_TAG . '_' . $categoryId; + } + + return $identities; + } + /** * Get identities * @@ -2365,17 +2379,24 @@ public function getImage() public function getIdentities() { $identities = [self::CACHE_TAG . '_' . $this->getId()]; - if ($this->getIsChangedCategories()) { - foreach ($this->getAffectedCategoryIds() as $categoryId) { - $identities[] = self::CACHE_PRODUCT_CATEGORY_TAG . '_' . $categoryId; + + $isStatusChanged = $this->getOrigData(self::STATUS) != $this->getData(self::STATUS) && !$this->isObjectNew(); + if ($isStatusChanged || $this->getStatus() == Status::STATUS_ENABLED) { + if ($this->getIsChangedCategories()) { + $identities = array_merge( + $identities, + $this->getProductCategoryIdentities($this->getAffectedCategoryIds()) + ); } - } - if (($this->getOrigData('status') != $this->getData('status')) || $this->isStockStatusChanged()) { - foreach ($this->getCategoryIds() as $categoryId) { - $identities[] = self::CACHE_PRODUCT_CATEGORY_TAG . '_' . $categoryId; + if ($isStatusChanged || $this->isStockStatusChanged()) { + $identities = array_merge( + $identities, + $this->getProductCategoryIdentities($this->getCategoryIds()) + ); } } + if ($this->_appState->getAreaCode() == \Magento\Framework\App\Area::AREA_FRONTEND) { $identities[] = self::CACHE_TAG; } diff --git a/app/code/Magento/Catalog/Model/Product/Attribute/Repository.php b/app/code/Magento/Catalog/Model/Product/Attribute/Repository.php index 99edfe5bc7208..4d148f078ae48 100644 --- a/app/code/Magento/Catalog/Model/Product/Attribute/Repository.php +++ b/app/code/Magento/Catalog/Model/Product/Attribute/Repository.php @@ -7,6 +7,7 @@ namespace Magento\Catalog\Model\Product\Attribute; use Magento\Eav\Api\Data\AttributeInterface; +use Magento\Eav\Model\Entity\Attribute; use Magento\Framework\Exception\InputException; use Magento\Framework\Exception\NoSuchEntityException; @@ -218,11 +219,16 @@ public function getCustomAttributesMetadata($dataObjectClassName = null) */ protected function generateCode($label) { - $code = substr(preg_replace('/[^a-z_0-9]/', '_', $this->filterManager->translitUrl($label)), 0, 30); + $code = substr( + preg_replace('/[^a-z_0-9]/', '_', $this->filterManager->translitUrl($label)), + 0, + Attribute::ATTRIBUTE_CODE_MAX_LENGTH + ); $validatorAttrCode = new \Zend_Validate_Regex(['pattern' => '/^[a-z][a-z_0-9]{0,29}[a-z0-9]$/']); if (!$validatorAttrCode->isValid($code)) { - $code = 'attr_' . ($code ?: substr(md5(time()), 0, 8)); + $code = 'attr_' . ($code ?: substr(hash('sha256', time()), 0, 8)); } + return $code; } @@ -235,7 +241,9 @@ protected function generateCode($label) */ protected function validateCode($code) { - $validatorAttrCode = new \Zend_Validate_Regex(['pattern' => '/^[a-z][a-z_0-9]{0,30}$/']); + $validatorAttrCode = new \Zend_Validate_Regex( + ['pattern' => '/^[a-z][a-z_0-9]{0,' . Attribute::ATTRIBUTE_CODE_MAX_LENGTH . '}$/'] + ); if (!$validatorAttrCode->isValid($code)) { throw InputException::invalidFieldValue('attribute_code', $code); } diff --git a/app/code/Magento/Catalog/Model/Product/Authorization.php b/app/code/Magento/Catalog/Model/Product/Authorization.php index b8aa8f70ba70f..4022eb34e65e3 100644 --- a/app/code/Magento/Catalog/Model/Product/Authorization.php +++ b/app/code/Magento/Catalog/Model/Product/Authorization.php @@ -159,7 +159,7 @@ public function authorizeSavingOf(ProductInterface $product): void if (!$savedProduct->getSku()) { throw NoSuchEntityException::singleField('id', $product->getId()); } - $oldData = $product->getOrigData(); + $oldData = $savedProduct->getData(); } } if ($this->hasProductChanged($product, $oldData)) { diff --git a/app/code/Magento/Catalog/Model/Product/Gallery/CreateHandler.php b/app/code/Magento/Catalog/Model/Product/Gallery/CreateHandler.php index 5fefcf995e0c7..5a9d53ce80cf8 100644 --- a/app/code/Magento/Catalog/Model/Product/Gallery/CreateHandler.php +++ b/app/code/Magento/Catalog/Model/Product/Gallery/CreateHandler.php @@ -8,10 +8,12 @@ namespace Magento\Catalog\Model\Product\Gallery; use Magento\Catalog\Api\Data\ProductInterface; +use Magento\Catalog\Model\Product; use Magento\Framework\App\Filesystem\DirectoryList; use Magento\Framework\App\ObjectManager; use Magento\Framework\EntityManager\Operation\ExtensionInterface; use Magento\MediaStorage\Model\File\Uploader as FileUploader; +use Magento\Store\Model\Store; use Magento\Store\Model\StoreManagerInterface; /** @@ -89,6 +91,15 @@ class CreateHandler implements ExtensionInterface */ private $storeManager; + /** + * @var string[] + */ + private $mediaAttributesWithLabels = [ + 'image', + 'small_image', + 'thumbnail' + ]; + /** * @param \Magento\Framework\EntityManager\MetadataPool $metadataPool * @param \Magento\Catalog\Api\ProductAttributeRepositoryInterface $attributeRepository @@ -190,27 +201,8 @@ public function execute($product, $arguments = []) $value['duplicate'] = $duplicate; } - /* @var $mediaAttribute \Magento\Catalog\Api\Data\ProductAttributeInterface */ - foreach ($this->getMediaAttributeCodes() as $mediaAttrCode) { - $attrData = $product->getData($mediaAttrCode); - if (empty($attrData) && empty($clearImages) && empty($newImages) && empty($existImages)) { - continue; - } - $this->processMediaAttribute( - $product, - $mediaAttrCode, - $clearImages, - $newImages - ); - if (in_array($mediaAttrCode, ['image', 'small_image', 'thumbnail'])) { - $this->processMediaAttributeLabel( - $product, - $mediaAttrCode, - $clearImages, - $newImages, - $existImages - ); - } + if (!empty($value['images'])) { + $this->processMediaAttributes($product, $existImages, $newImages, $clearImages); } $product->setData($attrCode, $value); @@ -492,30 +484,39 @@ private function getMediaAttributeCodes() /** * Process media attribute * - * @param \Magento\Catalog\Model\Product $product + * @param Product $product * @param string $mediaAttrCode * @param array $clearImages * @param array $newImages */ private function processMediaAttribute( - \Magento\Catalog\Model\Product $product, - $mediaAttrCode, + Product $product, + string $mediaAttrCode, array $clearImages, array $newImages - ) { - $attrData = $product->getData($mediaAttrCode); - if (in_array($attrData, $clearImages)) { - $product->setData($mediaAttrCode, 'no_selection'); - } - - if (in_array($attrData, array_keys($newImages))) { - $product->setData($mediaAttrCode, $newImages[$attrData]['new_file']); - } - if (!empty($product->getData($mediaAttrCode))) { + ): void { + $storeId = $product->isObjectNew() ? Store::DEFAULT_STORE_ID : (int) $product->getStoreId(); + /*** + * Attributes values are saved as default value in single store mode + * @see \Magento\Catalog\Model\ResourceModel\AbstractResource::_saveAttributeValue + */ + if ($storeId === Store::DEFAULT_STORE_ID + || $this->storeManager->hasSingleStore() + || $this->getMediaAttributeStoreValue($product, $mediaAttrCode, $storeId) !== null + ) { + $value = $product->getData($mediaAttrCode); + $newValue = $value; + if (in_array($value, $clearImages)) { + $newValue = 'no_selection'; + } + if (in_array($value, array_keys($newImages))) { + $newValue = $newImages[$value]['new_file']; + } + $product->setData($mediaAttrCode, $newValue); $product->addAttributeUpdate( $mediaAttrCode, - $product->getData($mediaAttrCode), - $product->getStoreId() + $newValue, + $storeId ); } } @@ -523,19 +524,19 @@ private function processMediaAttribute( /** * Process media attribute label * - * @param \Magento\Catalog\Model\Product $product + * @param Product $product * @param string $mediaAttrCode * @param array $clearImages * @param array $newImages * @param array $existImages */ private function processMediaAttributeLabel( - \Magento\Catalog\Model\Product $product, - $mediaAttrCode, + Product $product, + string $mediaAttrCode, array $clearImages, array $newImages, array $existImages - ) { + ): void { $resetLabel = false; $attrData = $product->getData($mediaAttrCode); if (in_array($attrData, $clearImages)) { @@ -618,4 +619,57 @@ private function canRemoveImage(ProductInterface $product, string $imageFile) :b return $canRemoveImage; } + + /** + * Get media attribute value for store view + * + * @param Product $product + * @param string $attributeCode + * @param int|null $storeId + * @return string|null + */ + private function getMediaAttributeStoreValue(Product $product, string $attributeCode, int $storeId = null): ?string + { + $gallery = $this->getImagesForAllStores($product); + $storeId = $storeId === null ? (int) $product->getStoreId() : $storeId; + foreach ($gallery as $image) { + if ($image['attribute_code'] === $attributeCode && ((int)$image['store_id']) === $storeId) { + return $image['filepath']; + } + } + return null; + } + + /** + * Update media attributes + * + * @param Product $product + * @param array $existImages + * @param array $newImages + * @param array $clearImages + */ + private function processMediaAttributes( + Product $product, + array $existImages, + array $newImages, + array $clearImages + ): void { + foreach ($this->getMediaAttributeCodes() as $mediaAttrCode) { + $this->processMediaAttribute( + $product, + $mediaAttrCode, + $clearImages, + $newImages + ); + if (in_array($mediaAttrCode, $this->mediaAttributesWithLabels)) { + $this->processMediaAttributeLabel( + $product, + $mediaAttrCode, + $clearImages, + $newImages, + $existImages + ); + } + } + } } diff --git a/app/code/Magento/Catalog/Model/Product/Gallery/UpdateHandler.php b/app/code/Magento/Catalog/Model/Product/Gallery/UpdateHandler.php index 8061422d84288..6a1392d776d31 100644 --- a/app/code/Magento/Catalog/Model/Product/Gallery/UpdateHandler.php +++ b/app/code/Magento/Catalog/Model/Product/Gallery/UpdateHandler.php @@ -77,26 +77,26 @@ protected function processDeletedImages($product, array &$images) { $filesToDelete = []; $recordsToDelete = []; - $picturesInOtherStores = []; $imagesToDelete = []; - - foreach ($this->resourceModel->getProductImages($product, $this->extractStoreIds($product)) as $image) { - $picturesInOtherStores[$image['filepath']] = true; + $imagesToNotDelete = []; + foreach ($images as $image) { + if (empty($image['removed'])) { + $imagesToNotDelete[] = $image['file']; + } } - foreach ($images as &$image) { + foreach ($images as $image) { if (!empty($image['removed'])) { if (!empty($image['value_id'])) { if (preg_match('/\.\.(\\\|\/)/', $image['file'])) { continue; } $recordsToDelete[] = $image['value_id']; - $imagesToDelete[] = $image['file']; - $catalogPath = $this->mediaConfig->getBaseMediaPath(); - $isFile = $this->mediaDirectory->isFile($catalogPath . $image['file']); - // only delete physical files if they are not used by any other products and if this file exist - if ($isFile && !($this->resourceModel->countImageUses($image['file']) > 1)) { - $filesToDelete[] = ltrim($image['file'], '/'); + if (!in_array($image['file'], $imagesToNotDelete)) { + $imagesToDelete[] = $image['file']; + if ($this->canDeleteImage($image['file'])) { + $filesToDelete[] = ltrim($image['file'], '/'); + } } } } @@ -107,6 +107,19 @@ protected function processDeletedImages($product, array &$images) $this->removeDeletedImages($filesToDelete); } + /** + * Check if image exists and is not used by any other products + * + * @param string $file + * @return bool + */ + private function canDeleteImage(string $file): bool + { + $catalogPath = $this->mediaConfig->getBaseMediaPath(); + return $this->mediaDirectory->isFile($catalogPath . $file) + && $this->resourceModel->countImageUses($file) <= 1; + } + /** * @inheritdoc * diff --git a/app/code/Magento/Catalog/Model/Product/Image.php b/app/code/Magento/Catalog/Model/Product/Image.php index 3c60d81e9a4d8..842ee197f83fe 100644 --- a/app/code/Magento/Catalog/Model/Product/Image.php +++ b/app/code/Magento/Catalog/Model/Product/Image.php @@ -10,9 +10,11 @@ use Magento\Catalog\Model\View\Asset\PlaceholderFactory; use Magento\Framework\App\Filesystem\DirectoryList; use Magento\Framework\App\ObjectManager; +use Magento\Framework\Exception\FileSystemException; use Magento\Framework\Image as MagentoImage; use Magento\Framework\Serialize\SerializerInterface; use Magento\Catalog\Model\Product\Image\ParamsBuilder; +use Magento\Framework\Filesystem\Driver\File as FilesystemDriver; /** * Image operations @@ -101,6 +103,7 @@ class Image extends \Magento\Framework\Model\AbstractModel /** * @var int + * @deprecated unused */ protected $_angle; @@ -199,6 +202,11 @@ class Image extends \Magento\Framework\Model\AbstractModel */ private $serializer; + /** + * @var FilesystemDriver + */ + private $filesystemDriver; + /** * Constructor * @@ -219,6 +227,8 @@ class Image extends \Magento\Framework\Model\AbstractModel * @param array $data * @param SerializerInterface $serializer * @param ParamsBuilder $paramsBuilder + * @param FilesystemDriver $filesystemDriver + * @throws \Magento\Framework\Exception\FileSystemException * @SuppressWarnings(PHPMD.ExcessiveParameterList) * @SuppressWarnings(PHPMD.UnusedLocalVariable) */ @@ -239,7 +249,8 @@ public function __construct( \Magento\Framework\Data\Collection\AbstractDb $resourceCollection = null, array $data = [], SerializerInterface $serializer = null, - ParamsBuilder $paramsBuilder = null + ParamsBuilder $paramsBuilder = null, + FilesystemDriver $filesystemDriver = null ) { $this->_storeManager = $storeManager; $this->_catalogProductMediaConfig = $catalogProductMediaConfig; @@ -254,6 +265,7 @@ public function __construct( $this->viewAssetPlaceholderFactory = $viewAssetPlaceholderFactory; $this->serializer = $serializer ?: ObjectManager::getInstance()->get(SerializerInterface::class); $this->paramsBuilder = $paramsBuilder ?: ObjectManager::getInstance()->get(ParamsBuilder::class); + $this->filesystemDriver = $filesystemDriver ?: ObjectManager::getInstance()->get(FilesystemDriver::class); } /** @@ -663,7 +675,12 @@ public function getDestinationSubdir() public function isCached() { $path = $this->imageAsset->getPath(); - return is_array($this->loadImageInfoFromCache($path)) || file_exists($path); + try { + $isCached = is_array($this->loadImageInfoFromCache($path)) || $this->filesystemDriver->isExists($path); + } catch (FileSystemException $e) { + $isCached = false; + } + return $isCached; } /** diff --git a/app/code/Magento/Catalog/Model/Product/Media/Config.php b/app/code/Magento/Catalog/Model/Product/Media/Config.php index 33af93db13b4c..71e29515791a7 100644 --- a/app/code/Magento/Catalog/Model/Product/Media/Config.php +++ b/app/code/Magento/Catalog/Model/Product/Media/Config.php @@ -7,6 +7,7 @@ namespace Magento\Catalog\Model\Product\Media; use Magento\Eav\Model\Entity\Attribute; +use Magento\Framework\UrlInterface; use Magento\Store\Model\StoreManagerInterface; /** @@ -76,7 +77,7 @@ public function getBaseMediaPath() public function getBaseMediaUrl() { return $this->storeManager->getStore() - ->getBaseUrl(\Magento\Framework\UrlInterface::URL_TYPE_MEDIA) . 'catalog/product'; + ->getBaseUrl(UrlInterface::URL_TYPE_MEDIA) . $this->getBaseMediaUrlAddition(); } /** @@ -97,7 +98,7 @@ public function getBaseTmpMediaPath() public function getBaseTmpMediaUrl() { return $this->storeManager->getStore()->getBaseUrl( - \Magento\Framework\UrlInterface::URL_TYPE_MEDIA + UrlInterface::URL_TYPE_MEDIA ) . 'tmp/' . $this->getBaseMediaUrlAddition(); } diff --git a/app/code/Magento/Catalog/Model/Product/Option.php b/app/code/Magento/Catalog/Model/Product/Option.php index e83982b8ce672..44d6fb04b01b0 100644 --- a/app/code/Magento/Catalog/Model/Product/Option.php +++ b/app/code/Magento/Catalog/Model/Product/Option.php @@ -16,8 +16,11 @@ use Magento\Catalog\Model\Product\Option\Type\File; use Magento\Catalog\Model\Product\Option\Type\Select; use Magento\Catalog\Model\Product\Option\Type\Text; +use Magento\Catalog\Model\Product\Option\Value; use Magento\Catalog\Model\ResourceModel\Product\Option\Value\Collection; use Magento\Catalog\Pricing\Price\BasePrice; +use Magento\Catalog\Pricing\Price\CalculateCustomOptionCatalogRule; +use Magento\Framework\App\ObjectManager; use Magento\Framework\EntityManager\MetadataPool; use Magento\Framework\Exception\LocalizedException; use Magento\Framework\Model\AbstractExtensibleModel; @@ -123,6 +126,11 @@ class Option extends AbstractExtensibleModel implements ProductCustomOptionInter */ private $customOptionValuesFactory; + /** + * @var CalculateCustomOptionCatalogRule + */ + private $calculateCustomOptionCatalogRule; + /** * @param \Magento\Framework\Model\Context $context * @param \Magento\Framework\Registry $registry @@ -138,6 +146,7 @@ class Option extends AbstractExtensibleModel implements ProductCustomOptionInter * @param ProductCustomOptionValuesInterfaceFactory|null $customOptionValuesFactory * @param array $optionGroups * @param array $optionTypesToGroups + * @param CalculateCustomOptionCatalogRule|null $calculateCustomOptionCatalogRule * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -154,14 +163,17 @@ public function __construct( array $data = [], ProductCustomOptionValuesInterfaceFactory $customOptionValuesFactory = null, array $optionGroups = [], - array $optionTypesToGroups = [] + array $optionTypesToGroups = [], + CalculateCustomOptionCatalogRule $calculateCustomOptionCatalogRule = null ) { $this->productOptionValue = $productOptionValue; $this->optionTypeFactory = $optionFactory; $this->string = $string; $this->validatorPool = $validatorPool; $this->customOptionValuesFactory = $customOptionValuesFactory ?: - \Magento\Framework\App\ObjectManager::getInstance()->get(ProductCustomOptionValuesInterfaceFactory::class); + ObjectManager::getInstance()->get(ProductCustomOptionValuesInterfaceFactory::class); + $this->calculateCustomOptionCatalogRule = $calculateCustomOptionCatalogRule ?? + ObjectManager::getInstance()->get(CalculateCustomOptionCatalogRule::class); $this->optionGroups = $optionGroups ?: [ self::OPTION_GROUP_DATE => Date::class, self::OPTION_GROUP_FILE => File::class, @@ -462,11 +474,21 @@ public function afterSave() */ public function getPrice($flag = false) { - if ($flag && $this->getPriceType() == self::$typePercent) { - $basePrice = $this->getProduct()->getPriceInfo()->getPrice(BasePrice::PRICE_CODE)->getValue(); - $price = $basePrice * ($this->_getData(self::KEY_PRICE) / 100); + if ($flag && $this->getPriceType() === self::$typePercent) { + $price = $this->calculateCustomOptionCatalogRule->execute( + $this->getProduct(), + (float)$this->getData(self::KEY_PRICE), + $this->getPriceType() === Value::TYPE_PERCENT + ); + + if ($price === null) { + $basePrice = $this->getProduct()->getPriceInfo()->getPrice(BasePrice::PRICE_CODE)->getValue(); + $price = $basePrice * ($this->_getData(self::KEY_PRICE) / 100); + } + return $price; } + return $this->_getData(self::KEY_PRICE); } diff --git a/app/code/Magento/Catalog/Model/Product/Option/Type/Date.php b/app/code/Magento/Catalog/Model/Product/Option/Type/Date.php index 6ac48c565e842..725635bf4fc45 100644 --- a/app/code/Magento/Catalog/Model/Product/Option/Type/Date.php +++ b/app/code/Magento/Catalog/Model/Product/Option/Type/Date.php @@ -72,8 +72,21 @@ public function validateUserValue($values) $dateValid = true; if ($this->_dateExists()) { if ($this->useCalendar()) { + if (is_array($value) && $this->checkDateWithoutJSCalendar($value)) { + $value['date'] = sprintf("%s/%s/%s", $value['day'], $value['month'], $value['year']); + } + /* Fixed validation if the date was not saved correctly after re-saved the order + for example: "09\/24\/2020,2020-09-24 00:00:00" */ + if (is_string($value) && preg_match('/^\d{1,4}.+\d{1,4}.+\d{1,4},+(\w|\W)*$/', $value)) { + $value = [ + 'date' => preg_replace('/,([^,]+),?$/', '', $value), + ]; + } $dateValid = isset($value['date']) && preg_match('/^\d{1,4}.+\d{1,4}.+\d{1,4}$/', $value['date']); } else { + if (is_array($value)) { + $value = $this->prepareDateByDateInternal($value); + } $dateValid = isset( $value['day'] ) && isset( @@ -184,8 +197,10 @@ public function prepareForCart() $date = (new \DateTime())->setTimestamp($timestamp); $result = $date->format('Y-m-d H:i:s'); + $originDate = (isset($value['date']) && $value['date'] != '') ? $value['date'] : null; + // Save date in internal format to avoid locale date bugs - $this->_setInternalInRequest($result); + $this->_setInternalInRequest($result, $originDate); return $result; } else { @@ -352,9 +367,10 @@ public function getYearEnd() * Save internal value of option in infoBuy_request * * @param string $internalValue Datetime value in internal format + * @param string|null $originDate date value in origin format * @return void */ - protected function _setInternalInRequest($internalValue) + protected function _setInternalInRequest($internalValue, $originDate = null) { $requestOptions = $this->getRequest()->getOptions(); if (!isset($requestOptions[$this->getOption()->getId()])) { @@ -364,6 +380,9 @@ protected function _setInternalInRequest($internalValue) $requestOptions[$this->getOption()->getId()] = []; } $requestOptions[$this->getOption()->getId()]['date_internal'] = $internalValue; + if ($originDate) { + $requestOptions[$this->getOption()->getId()]['date'] = $originDate; + } $this->getRequest()->setOptions($requestOptions); } @@ -398,4 +417,38 @@ protected function _timeExists() ] ); } + + /** + * Check is date without JS Calendar + * + * @param array $value + * + * @return bool + */ + private function checkDateWithoutJSCalendar(array $value): bool + { + return empty($value['date']) + && !empty($value['day']) + && !empty($value['month']) + && !empty($value['year']); + } + + /** + * Prepare date by date internal + * + * @param array $value + * @return array + */ + private function prepareDateByDateInternal(array $value): array + { + if (!empty($value['date']) && !empty($value['date_internal'])) { + $formatDate = explode(' ', $value['date_internal']); + $date = explode('-', $formatDate[0]); + $value['year'] = $date[0]; + $value['month'] = $date[1]; + $value['day'] = $date[2]; + } + + return $value; + } } diff --git a/app/code/Magento/Catalog/Model/Product/Option/Type/DefaultType.php b/app/code/Magento/Catalog/Model/Product/Option/Type/DefaultType.php index 16fdd4cdeeb1c..e819f36b5cf7d 100644 --- a/app/code/Magento/Catalog/Model/Product/Option/Type/DefaultType.php +++ b/app/code/Magento/Catalog/Model/Product/Option/Type/DefaultType.php @@ -13,6 +13,8 @@ use Magento\Catalog\Model\Product\Configuration\Item\Option\OptionInterface; use Magento\Catalog\Model\Product\Configuration\Item\ItemInterface; use Magento\Catalog\Model\Product\Option\Value; +use Magento\Catalog\Pricing\Price\CalculateCustomOptionCatalogRule; +use Magento\Framework\App\ObjectManager; /** * Catalog product option default type @@ -60,21 +62,30 @@ class DefaultType extends \Magento\Framework\DataObject */ protected $_checkoutSession; + /** + * @var CalculateCustomOptionCatalogRule + */ + private $calculateCustomOptionCatalogRule; + /** * Construct * * @param \Magento\Checkout\Model\Session $checkoutSession * @param \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig * @param array $data + * @param CalculateCustomOptionCatalogRule|null $calculateCustomOptionCatalogRule */ public function __construct( \Magento\Checkout\Model\Session $checkoutSession, \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig, - array $data = [] + array $data = [], + CalculateCustomOptionCatalogRule $calculateCustomOptionCatalogRule = null ) { $this->_checkoutSession = $checkoutSession; parent::__construct($data); $this->_scopeConfig = $scopeConfig; + $this->calculateCustomOptionCatalogRule = $calculateCustomOptionCatalogRule ?? ObjectManager::getInstance() + ->get(CalculateCustomOptionCatalogRule::class); } /** @@ -341,7 +352,20 @@ public function getOptionPrice($optionValue, $basePrice) { $option = $this->getOption(); - return $this->_getChargeableOptionPrice($option->getPrice(), $option->getPriceType() == 'percent', $basePrice); + $catalogPriceValue = $this->calculateCustomOptionCatalogRule->execute( + $option->getProduct(), + (float)$option->getPrice(), + $option->getPriceType() === Value::TYPE_PERCENT + ); + if ($catalogPriceValue !== null) { + return $catalogPriceValue; + } else { + return $this->_getChargeableOptionPrice( + $option->getPrice(), + $option->getPriceType() === Value::TYPE_PERCENT, + $basePrice + ); + } } /** diff --git a/app/code/Magento/Catalog/Model/Product/Option/Type/File/ValidatorFile.php b/app/code/Magento/Catalog/Model/Product/Option/Type/File/ValidatorFile.php index fef4999a1174a..934ff48045097 100644 --- a/app/code/Magento/Catalog/Model/Product/Option/Type/File/ValidatorFile.php +++ b/app/code/Magento/Catalog/Model/Product/Option/Type/File/ValidatorFile.php @@ -12,6 +12,7 @@ use Magento\Framework\Exception\LocalizedException; use Magento\Framework\Math\Random; use Magento\Framework\App\ObjectManager; +use Magento\MediaStorage\Model\File\Uploader; /** * Validator class. Represents logic for validation file given from product option @@ -173,15 +174,11 @@ public function validate($processingParams, $option) $userValue = []; if ($upload->isUploaded($file) && $upload->isValid($file)) { - $fileName = \Magento\MediaStorage\Model\File\Uploader::getCorrectFileName($fileInfo['name']); - $dispersion = \Magento\MediaStorage\Model\File\Uploader::getDispersionPath($fileName); - - $filePath = $dispersion; - $tmpDirectory = $this->filesystem->getDirectoryRead(DirectoryList::SYS_TMP); - $fileHash = md5($tmpDirectory->readFile($tmpDirectory->getRelativePath($fileInfo['tmp_name']))); $fileRandomName = $this->random->getRandomString(32); - $filePath .= '/' .$fileRandomName; + $fileName = Uploader::getCorrectFileName($fileRandomName); + $dispersion = Uploader::getDispersionPath($fileName); + $filePath = $dispersion . '/' . $fileName; $fileFullPath = $this->mediaDirectory->getAbsolutePath($this->quotePath . $filePath); $upload->addFilter(new \Zend_Filter_File_Rename(['target' => $fileFullPath, 'overwrite' => true])); @@ -216,6 +213,8 @@ public function validate($processingParams, $option) } } + $fileHash = md5($tmpDirectory->readFile($tmpDirectory->getRelativePath($fileInfo['tmp_name']))); + $userValue = [ 'type' => $fileInfo['type'], 'title' => $fileInfo['name'], diff --git a/app/code/Magento/Catalog/Model/Product/Option/Type/Select.php b/app/code/Magento/Catalog/Model/Product/Option/Type/Select.php index d2766b1bbb054..580ef7689ff4e 100644 --- a/app/code/Magento/Catalog/Model/Product/Option/Type/Select.php +++ b/app/code/Magento/Catalog/Model/Product/Option/Type/Select.php @@ -6,7 +6,11 @@ namespace Magento\Catalog\Model\Product\Option\Type; +use Magento\Catalog\Model\Product\Option\Value; +use Magento\Catalog\Pricing\Price\CalculateCustomOptionCatalogRule; +use Magento\Framework\App\ObjectManager; use Magento\Framework\Exception\LocalizedException; +use Magento\Catalog\Model\Product\Option; /** * Catalog product option select type @@ -37,6 +41,11 @@ class Select extends \Magento\Catalog\Model\Product\Option\Type\DefaultType */ private $singleSelectionTypes; + /** + * @var CalculateCustomOptionCatalogRule + */ + private $calculateCustomOptionCatalogRule; + /** * @param \Magento\Checkout\Model\Session $checkoutSession * @param \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig @@ -44,6 +53,7 @@ class Select extends \Magento\Catalog\Model\Product\Option\Type\DefaultType * @param \Magento\Framework\Escaper $escaper * @param array $data * @param array $singleSelectionTypes + * @param CalculateCustomOptionCatalogRule|null $calculateCustomOptionCatalogRule */ public function __construct( \Magento\Checkout\Model\Session $checkoutSession, @@ -51,7 +61,8 @@ public function __construct( \Magento\Framework\Stdlib\StringUtils $string, \Magento\Framework\Escaper $escaper, array $data = [], - array $singleSelectionTypes = [] + array $singleSelectionTypes = [], + CalculateCustomOptionCatalogRule $calculateCustomOptionCatalogRule = null ) { $this->string = $string; $this->_escaper = $escaper; @@ -61,6 +72,8 @@ public function __construct( 'drop_down' => \Magento\Catalog\Api\Data\ProductCustomOptionInterface::OPTION_TYPE_DROP_DOWN, 'radio' => \Magento\Catalog\Api\Data\ProductCustomOptionInterface::OPTION_TYPE_RADIO, ]; + $this->calculateCustomOptionCatalogRule = $calculateCustomOptionCatalogRule ?? ObjectManager::getInstance() + ->get(CalculateCustomOptionCatalogRule::class); } /** @@ -248,11 +261,7 @@ public function getOptionPrice($optionValue, $basePrice) foreach (explode(',', $optionValue) as $value) { $_result = $option->getValueById($value); if ($_result) { - $result += $this->_getChargeableOptionPrice( - $_result->getPrice(), - $_result->getPriceType() == 'percent', - $basePrice - ); + $result += $this->getCalculatedOptionValue($option, $_result, $basePrice); } else { if ($this->getListener()) { $this->getListener()->setHasError(true)->setMessage($this->_getWrongConfigurationMessage()); @@ -263,11 +272,20 @@ public function getOptionPrice($optionValue, $basePrice) } elseif ($this->_isSingleSelection()) { $_result = $option->getValueById($optionValue); if ($_result) { - $result = $this->_getChargeableOptionPrice( - $_result->getPrice(), - $_result->getPriceType() == 'percent', - $basePrice + $catalogPriceValue = $this->calculateCustomOptionCatalogRule->execute( + $option->getProduct(), + (float)$_result->getPrice(), + $_result->getPriceType() === Value::TYPE_PERCENT ); + if ($catalogPriceValue !== null) { + $result = $catalogPriceValue; + } else { + $result = $this->_getChargeableOptionPrice( + $_result->getPrice(), + $_result->getPriceType() == 'percent', + $basePrice + ); + } } else { if ($this->getListener()) { $this->getListener()->setHasError(true)->setMessage($this->_getWrongConfigurationMessage()); @@ -329,4 +347,31 @@ protected function _isSingleSelection() { return in_array($this->getOption()->getType(), $this->singleSelectionTypes, true); } + + /** + * Returns calculated price of option + * + * @param Option $option + * @param Option\Value $result + * @param float $basePrice + * @return float + */ + protected function getCalculatedOptionValue(Option $option, Value $result, float $basePrice) : float + { + $catalogPriceValue = $this->calculateCustomOptionCatalogRule->execute( + $option->getProduct(), + (float)$result->getPrice(), + $result->getPriceType() === Value::TYPE_PERCENT + ); + if ($catalogPriceValue !== null) { + $optionCalculatedValue = $catalogPriceValue; + } else { + $optionCalculatedValue = $this->_getChargeableOptionPrice( + $result->getPrice(), + $result->getPriceType() === Value::TYPE_PERCENT, + $basePrice + ); + } + return $optionCalculatedValue; + } } diff --git a/app/code/Magento/Catalog/Model/Product/Option/Value.php b/app/code/Magento/Catalog/Model/Product/Option/Value.php index 313513a9151dc..12b418c33deec 100644 --- a/app/code/Magento/Catalog/Model/Product/Option/Value.php +++ b/app/code/Magento/Catalog/Model/Product/Option/Value.php @@ -10,6 +10,8 @@ use Magento\Catalog\Model\Product\Option; use Magento\Framework\Model\AbstractModel; use Magento\Catalog\Pricing\Price\BasePrice; +use Magento\Catalog\Pricing\Price\CalculateCustomOptionCatalogRule; +use Magento\Framework\App\ObjectManager; use Magento\Catalog\Pricing\Price\CustomOptionPriceCalculator; use Magento\Catalog\Pricing\Price\RegularPrice; @@ -69,6 +71,11 @@ class Value extends AbstractModel implements \Magento\Catalog\Api\Data\ProductCu */ private $customOptionPriceCalculator; + /** + * @var CalculateCustomOptionCatalogRule + */ + private $calculateCustomOptionCatalogRule; + /** * @param \Magento\Framework\Model\Context $context * @param \Magento\Framework\Registry $registry @@ -77,6 +84,7 @@ class Value extends AbstractModel implements \Magento\Catalog\Api\Data\ProductCu * @param \Magento\Framework\Data\Collection\AbstractDb $resourceCollection * @param array $data * @param CustomOptionPriceCalculator|null $customOptionPriceCalculator + * @param CalculateCustomOptionCatalogRule|null $calculateCustomOptionCatalogRule */ public function __construct( \Magento\Framework\Model\Context $context, @@ -85,11 +93,14 @@ public function __construct( \Magento\Framework\Model\ResourceModel\AbstractResource $resource = null, \Magento\Framework\Data\Collection\AbstractDb $resourceCollection = null, array $data = [], - CustomOptionPriceCalculator $customOptionPriceCalculator = null + CustomOptionPriceCalculator $customOptionPriceCalculator = null, + CalculateCustomOptionCatalogRule $calculateCustomOptionCatalogRule = null ) { $this->_valueCollectionFactory = $valueCollectionFactory; $this->customOptionPriceCalculator = $customOptionPriceCalculator - ?? \Magento\Framework\App\ObjectManager::getInstance()->get(CustomOptionPriceCalculator::class); + ?? ObjectManager::getInstance()->get(CustomOptionPriceCalculator::class); + $this->calculateCustomOptionCatalogRule = $calculateCustomOptionCatalogRule + ?? ObjectManager::getInstance()->get(CalculateCustomOptionCatalogRule::class); parent::__construct( $context, @@ -253,7 +264,16 @@ public function saveValues() public function getPrice($flag = false) { if ($flag) { - return $this->customOptionPriceCalculator->getOptionPriceByPriceCode($this, BasePrice::PRICE_CODE); + $catalogPriceValue = $this->calculateCustomOptionCatalogRule->execute( + $this->getProduct(), + (float)$this->getData(self::KEY_PRICE), + $this->getPriceType() === self::TYPE_PERCENT + ); + if ($catalogPriceValue!==null) { + return $catalogPriceValue; + } else { + return $this->customOptionPriceCalculator->getOptionPriceByPriceCode($this, BasePrice::PRICE_CODE); + } } return $this->_getData(self::KEY_PRICE); } diff --git a/app/code/Magento/Catalog/Model/Product/Price/TierPriceStorage.php b/app/code/Magento/Catalog/Model/Product/Price/TierPriceStorage.php index 36ef1826462b0..7d458401c950e 100644 --- a/app/code/Magento/Catalog/Model/Product/Price/TierPriceStorage.php +++ b/app/code/Magento/Catalog/Model/Product/Price/TierPriceStorage.php @@ -12,9 +12,6 @@ use Magento\Catalog\Model\Product\Price\Validation\TierPriceValidator; use Magento\Catalog\Model\ProductIdLocatorInterface; -/** - * Tier price storage. - */ class TierPriceStorage implements TierPriceStorageInterface { /** @@ -220,7 +217,7 @@ private function retrieveAffectedIds(array $skus): array $affectedIds[] = array_keys($productId); } - return $affectedIds ? array_unique(array_merge(...$affectedIds)) : []; + return array_unique(array_merge([], ...$affectedIds)); } /** diff --git a/app/code/Magento/Catalog/Model/Product/Type/AbstractType.php b/app/code/Magento/Catalog/Model/Product/Type/AbstractType.php index eb4a71cb90a8c..f90b097415661 100644 --- a/app/code/Magento/Catalog/Model/Product/Type/AbstractType.php +++ b/app/code/Magento/Catalog/Model/Product/Type/AbstractType.php @@ -3,13 +3,14 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); namespace Magento\Catalog\Model\Product\Type; use Magento\Catalog\Api\ProductRepositoryInterface; -use Magento\Framework\App\Filesystem\DirectoryList; use Magento\Framework\Exception\LocalizedException; use Magento\Framework\App\ObjectManager; +use Magento\Framework\File\UploaderFactory; /** * Abstract model for product type implementation @@ -113,6 +114,11 @@ abstract class AbstractType */ protected $_cacheProductSetAttributes = '_cache_instance_product_set_attributes'; + /** + * @var UploaderFactory + */ + private $uploaderFactory; + /** * Delete data specific for this product type * @@ -175,8 +181,6 @@ abstract public function deleteTypeSpecificData(\Magento\Catalog\Model\Product $ protected $serializer; /** - * Construct - * * @param \Magento\Catalog\Model\Product\Option $catalogProductOption * @param \Magento\Eav\Model\Config $eavConfig * @param \Magento\Catalog\Model\Product\Type $catalogProductType @@ -187,6 +191,7 @@ abstract public function deleteTypeSpecificData(\Magento\Catalog\Model\Product $ * @param \Psr\Log\LoggerInterface $logger * @param ProductRepositoryInterface $productRepository * @param \Magento\Framework\Serialize\Serializer\Json|null $serializer + * @param UploaderFactory $uploaderFactory * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -199,7 +204,8 @@ public function __construct( \Magento\Framework\Registry $coreRegistry, \Psr\Log\LoggerInterface $logger, ProductRepositoryInterface $productRepository, - \Magento\Framework\Serialize\Serializer\Json $serializer = null + \Magento\Framework\Serialize\Serializer\Json $serializer = null, + UploaderFactory $uploaderFactory = null ) { $this->_catalogProductOption = $catalogProductOption; $this->_eavConfig = $eavConfig; @@ -212,6 +218,7 @@ public function __construct( $this->productRepository = $productRepository; $this->serializer = $serializer ?: ObjectManager::getInstance() ->get(\Magento\Framework\Serialize\Serializer\Json::class); + $this->uploaderFactory = $uploaderFactory ?: ObjectManager::getInstance()->get(UploaderFactory::class); } /** @@ -493,28 +500,20 @@ public function processFileQueue() if (isset($queueOptions['operation']) && ($operation = $queueOptions['operation'])) { switch ($operation) { case 'receive_uploaded_file': - $src = isset($queueOptions['src_name']) ? $queueOptions['src_name'] : ''; - $dst = isset($queueOptions['dst_name']) ? $queueOptions['dst_name'] : ''; + $src = $queueOptions['src_name'] ?? ''; + $dst = $queueOptions['dst_name'] ?? ''; /** @var $uploader \Zend_File_Transfer_Adapter_Http */ - $uploader = isset($queueOptions['uploader']) ? $queueOptions['uploader'] : null; - - // phpcs:ignore Magento2.Functions.DiscouragedFunction - $path = dirname($dst); - - try { - $rootDir = $this->_filesystem->getDirectoryWrite( - DirectoryList::ROOT - ); - $rootDir->create($rootDir->getRelativePath($path)); - } catch (\Magento\Framework\Exception\FileSystemException $e) { - throw new \Magento\Framework\Exception\LocalizedException( - __('We can\'t create the "%1" writeable directory.', $path) - ); + $uploader = $queueOptions['uploader'] ?? null; + $isUploaded = false; + if ($uploader && $uploader->isValid()) { + $path = pathinfo($dst, PATHINFO_DIRNAME); + $uploader = $this->uploaderFactory->create(['fileId' => $src]); + $uploader->setFilesDispersion(false); + $uploader->setAllowRenameFiles(true); + $isUploaded = $uploader->save($path, pathinfo($dst, PATHINFO_FILENAME)); } - $uploader->setDestination($path); - - if (empty($src) || empty($dst) || !$uploader->receive($src)) { + if (empty($src) || empty($dst) || !$isUploaded) { /** * @todo: show invalid option */ @@ -620,7 +619,7 @@ protected function _prepareOptions(\Magento\Framework\DataObject $buyRequest, $p } } if (count($results) > 0) { - throw new LocalizedException(__(implode("\n", $results))); + throw new LocalizedException(__(implode("\n", array_unique($results)))); } } diff --git a/app/code/Magento/Catalog/Model/ProductLink/ProductLinkQuery.php b/app/code/Magento/Catalog/Model/ProductLink/ProductLinkQuery.php index 4bc400605a429..1d5ef722db8b1 100644 --- a/app/code/Magento/Catalog/Model/ProductLink/ProductLinkQuery.php +++ b/app/code/Magento/Catalog/Model/ProductLink/ProductLinkQuery.php @@ -103,7 +103,7 @@ private function extractRequestedLinkTypes(array $criteria): array if (count($linkTypesToLoad) === 1) { $linkTypesToLoad = $linkTypesToLoad[0]; } else { - $linkTypesToLoad = array_merge(...$linkTypesToLoad); + $linkTypesToLoad = array_merge([], ...$linkTypesToLoad); } $linkTypesToLoad = array_flip($linkTypesToLoad); $linkTypes = array_filter( diff --git a/app/code/Magento/Catalog/Model/ProductOptionProcessor.php b/app/code/Magento/Catalog/Model/ProductOptionProcessor.php index a5e1d05409e43..db9f4de142956 100644 --- a/app/code/Magento/Catalog/Model/ProductOptionProcessor.php +++ b/app/code/Magento/Catalog/Model/ProductOptionProcessor.php @@ -5,13 +5,15 @@ */ namespace Magento\Catalog\Model; -use Magento\Catalog\Api\Data\ProductOptionExtensionFactory; use Magento\Catalog\Api\Data\ProductOptionInterface; use Magento\Catalog\Model\CustomOptions\CustomOption; use Magento\Catalog\Model\CustomOptions\CustomOptionFactory; use Magento\Framework\DataObject; use Magento\Framework\DataObject\Factory as DataObjectFactory; +/** + * Processor for product options + */ class ProductOptionProcessor implements ProductOptionProcessorInterface { /** @@ -88,7 +90,8 @@ public function convertToProductOption(DataObject $request) if (!empty($options) && is_array($options)) { $data = []; foreach ($options as $optionId => $optionValue) { - if (is_array($optionValue)) { + + if (is_array($optionValue) && !$this->isDateWithDateInternal($optionValue)) { $optionValue = $this->processFileOptionValue($optionValue); $optionValue = implode(',', $optionValue); } @@ -126,6 +129,8 @@ private function processFileOptionValue(array $optionValue) } /** + * Get url builder + * * @return \Magento\Catalog\Model\Product\Option\UrlBuilder * * @deprecated 101.0.0 @@ -138,4 +143,15 @@ private function getUrlBuilder() } return $this->urlBuilder; } + + /** + * Check if the option has a date_internal and date + * + * @param array $optionValue + * @return bool + */ + private function isDateWithDateInternal(array $optionValue): bool + { + return array_key_exists('date_internal', $optionValue) && array_key_exists('date', $optionValue); + } } diff --git a/app/code/Magento/Catalog/Model/ProductRepository.php b/app/code/Magento/Catalog/Model/ProductRepository.php index 59a92656abf84..fefeafe46e1c4 100644 --- a/app/code/Magento/Catalog/Model/ProductRepository.php +++ b/app/code/Magento/Catalog/Model/ProductRepository.php @@ -30,7 +30,8 @@ use Magento\Framework\Exception\ValidatorException; /** - * Product Repository. + * @inheritdoc + * * @SuppressWarnings(PHPMD.CouplingBetweenObjects) * @SuppressWarnings(PHPMD.TooManyFields) */ @@ -543,7 +544,9 @@ public function save(ProductInterface $product, $saveOptions = false) if (!$ignoreLinksFlag && $ignoreLinksFlag !== null) { $productLinks = $product->getProductLinks(); } - $productDataArray['store_id'] = (int)$this->storeManager->getStore()->getId(); + if (!isset($productDataArray['store_id'])) { + $productDataArray['store_id'] = (int) $this->storeManager->getStore()->getId(); + } $product = $this->initializeProductData($productDataArray, empty($existingProduct)); $this->processLinks($product, $productLinks); @@ -735,6 +738,7 @@ private function getCollectionProcessor() { if (!$this->collectionProcessor) { $this->collectionProcessor = \Magento\Framework\App\ObjectManager::getInstance()->get( + // phpstan:ignore "Class Magento\Catalog\Model\Api\SearchCriteria\ProductCollectionProcessor not found." \Magento\Catalog\Model\Api\SearchCriteria\ProductCollectionProcessor::class ); } diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Attribute.php b/app/code/Magento/Catalog/Model/ResourceModel/Attribute.php index 8457e5d0eaa5c..203126cf1fd8c 100644 --- a/app/code/Magento/Catalog/Model/ResourceModel/Attribute.php +++ b/app/code/Magento/Catalog/Model/ResourceModel/Attribute.php @@ -5,13 +5,12 @@ */ namespace Magento\Catalog\Model\ResourceModel; -use Magento\Catalog\Api\Data\ProductInterface; use Magento\Catalog\Model\Attribute\LockValidatorInterface; +use Magento\Catalog\Model\ResourceModel\Attribute\RemoveProductAttributeData; +use Magento\Framework\App\ObjectManager; /** * Catalog attribute resource model - * - * @author Magento Core Team <core@magentocommerce.com> */ class Attribute extends \Magento\Eav\Model\ResourceModel\Entity\Attribute { @@ -28,9 +27,9 @@ class Attribute extends \Magento\Eav\Model\ResourceModel\Entity\Attribute protected $attrLockValidator; /** - * @var \Magento\Framework\EntityManager\MetadataPool + * @var RemoveProductAttributeData|null */ - protected $metadataPool; + private $removeProductAttributeData; /** * @param \Magento\Framework\Model\ResourceModel\Db\Context $context @@ -38,7 +37,8 @@ class Attribute extends \Magento\Eav\Model\ResourceModel\Entity\Attribute * @param \Magento\Eav\Model\ResourceModel\Entity\Type $eavEntityType * @param \Magento\Eav\Model\Config $eavConfig * @param LockValidatorInterface $lockValidator - * @param string $connectionName + * @param string|null $connectionName + * @param RemoveProductAttributeData|null $removeProductAttributeData */ public function __construct( \Magento\Framework\Model\ResourceModel\Db\Context $context, @@ -46,10 +46,14 @@ public function __construct( \Magento\Eav\Model\ResourceModel\Entity\Type $eavEntityType, \Magento\Eav\Model\Config $eavConfig, LockValidatorInterface $lockValidator, - $connectionName = null + $connectionName = null, + RemoveProductAttributeData $removeProductAttributeData = null ) { $this->attrLockValidator = $lockValidator; $this->_eavConfig = $eavConfig; + $this->removeProductAttributeData = $removeProductAttributeData ?? ObjectManager::getInstance() + ->get(RemoveProductAttributeData::class); + parent::__construct($context, $storeManager, $eavEntityType, $connectionName); } @@ -135,24 +139,7 @@ public function deleteEntity(\Magento\Framework\Model\AbstractModel $object) ); } - $backendTable = $attribute->getBackend()->getTable(); - if ($backendTable) { - $linkField = $this->getMetadataPool() - ->getMetadata(ProductInterface::class) - ->getLinkField(); - - $backendLinkField = $attribute->getBackend()->getEntityIdField(); - - $select = $this->getConnection()->select() - ->from(['b' => $backendTable]) - ->join( - ['e' => $attribute->getEntity()->getEntityTable()], - "b.$backendLinkField = e.$linkField" - )->where('b.attribute_id = ?', $attribute->getId()) - ->where('e.attribute_set_id = ?', $result['attribute_set_id']); - - $this->getConnection()->query($select->deleteFromSelect('b')); - } + $this->removeProductAttributeData->removeData($object, (int)$result['attribute_set_id']); } $condition = ['entity_attribute_id = ?' => $object->getEntityAttributeId()]; @@ -160,16 +147,4 @@ public function deleteEntity(\Magento\Framework\Model\AbstractModel $object) return $this; } - - /** - * @return \Magento\Framework\EntityManager\MetadataPool - */ - private function getMetadataPool() - { - if (null === $this->metadataPool) { - $this->metadataPool = \Magento\Framework\App\ObjectManager::getInstance() - ->get(\Magento\Framework\EntityManager\MetadataPool::class); - } - return $this->metadataPool; - } } diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Attribute/RemoveProductAttributeData.php b/app/code/Magento/Catalog/Model/ResourceModel/Attribute/RemoveProductAttributeData.php new file mode 100644 index 0000000000000..2782047902048 --- /dev/null +++ b/app/code/Magento/Catalog/Model/ResourceModel/Attribute/RemoveProductAttributeData.php @@ -0,0 +1,70 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Catalog\Model\ResourceModel\Attribute; + +use Magento\Catalog\Api\Data\ProductInterface; +use Magento\Framework\App\ResourceConnection; +use Magento\Framework\EntityManager\MetadataPool; +use Magento\Framework\Model\AbstractModel; + +/** + * Class for deleting data from attribute additional table by attribute set id. + */ +class RemoveProductAttributeData +{ + /** + * @var ResourceConnection + */ + private $resourceConnection; + + /** + * @var MetadataPool + */ + private $metadataPool; + + /** + * @param ResourceConnection $resourceConnection + * @param MetadataPool $metadataPool + */ + public function __construct( + ResourceConnection $resourceConnection, + MetadataPool $metadataPool + ) { + $this->resourceConnection = $resourceConnection; + $this->metadataPool = $metadataPool; + } + + /** + * Deletes data from attribute table by attribute set id. + * + * @param AbstractModel $object + * @param int $attributeSetId + * @return void + */ + public function removeData(AbstractModel $object, int $attributeSetId): void + { + $backendTable = $object->getBackend()->getTable(); + if ($backendTable) { + $linkField = $this->metadataPool + ->getMetadata(ProductInterface::class) + ->getLinkField(); + + $backendLinkField = $object->getBackend()->getEntityIdField(); + + $select = $this->resourceConnection->getConnection()->select() + ->from(['b' => $backendTable]) + ->join( + ['e' => $object->getEntity()->getEntityTable()], + "b.$backendLinkField = e.$linkField" + )->where('b.attribute_id = ?', $object->getId()) + ->where('e.attribute_set_id = ?', $attributeSetId); + + $this->resourceConnection->getConnection()->query($select->deleteFromSelect('b')); + } + } +} diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Category.php b/app/code/Magento/Catalog/Model/ResourceModel/Category.php index 917aafb643b47..e19286efc38c0 100644 --- a/app/code/Magento/Catalog/Model/ResourceModel/Category.php +++ b/app/code/Magento/Catalog/Model/ResourceModel/Category.php @@ -13,7 +13,7 @@ namespace Magento\Catalog\Model\ResourceModel; -use Magento\Catalog\Api\Data\ProductInterface; +use Magento\Catalog\Api\Data\CategoryInterface; use Magento\Catalog\Model\Indexer\Category\Product\Processor; use Magento\Catalog\Setup\CategorySetup; use Magento\Framework\App\ObjectManager; @@ -1172,11 +1172,11 @@ public function getCategoryWithChildren(int $categoryId): array return []; } - $linkField = $this->metadataPool->getMetadata(ProductInterface::class)->getLinkField(); + $linkField = $this->metadataPool->getMetadata(CategoryInterface::class)->getLinkField(); $select = $connection->select() ->from( ['cce' => $this->getTable('catalog_category_entity')], - [$linkField, 'parent_id', 'path'] + [$linkField, 'entity_id', 'parent_id', 'path'] )->join( ['cce_int' => $this->getTable('catalog_category_entity_int')], 'cce.' . $linkField . ' = cce_int.' . $linkField, diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Category/Tree.php b/app/code/Magento/Catalog/Model/ResourceModel/Category/Tree.php index 972f11db7aae3..bcfa6ba3f8d0c 100644 --- a/app/code/Magento/Catalog/Model/ResourceModel/Category/Tree.php +++ b/app/code/Magento/Catalog/Model/ResourceModel/Category/Tree.php @@ -24,6 +24,11 @@ class Tree extends Dbp const LEVEL_FIELD = 'level'; + /** + * @var array + */ + private $_inactiveItems; + /** * @var \Magento\Framework\Event\ManagerInterface */ @@ -290,7 +295,7 @@ protected function _getDisabledIds($collection, $allIds) foreach ($allIds as $id) { $parents = $this->getNodeById($id)->getPath(); foreach ($parents as $parent) { - if (!$this->_getItemIsActive($parent->getId(), $storeId)) { + if (!$this->_getItemIsActive($parent->getId())) { $disabledIds[] = $id; continue; } @@ -680,6 +685,8 @@ public function getExistingCategoryIdsBySpecifiedIds($ids) } /** + * Get entity methadata pool. + * * @return \Magento\Framework\EntityManager\MetadataPool */ private function getMetadataPool() diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Eav/Attribute.php b/app/code/Magento/Catalog/Model/ResourceModel/Eav/Attribute.php index d1769ded93d29..07ce84c7cd62e 100644 --- a/app/code/Magento/Catalog/Model/ResourceModel/Eav/Attribute.php +++ b/app/code/Magento/Catalog/Model/ResourceModel/Eav/Attribute.php @@ -6,7 +6,9 @@ namespace Magento\Catalog\Model\ResourceModel\Eav; +use Magento\Catalog\Model\Attribute\Backend\DefaultBackend; use Magento\Catalog\Model\Attribute\LockValidatorInterface; +use Magento\Eav\Model\Entity; use Magento\Framework\Api\AttributeValueFactory; use Magento\Framework\Stdlib\DateTime\DateTimeFormatterInterface; @@ -902,4 +904,17 @@ public function setIsFilterableInGrid($isFilterableInGrid) $this->setData(self::IS_FILTERABLE_IN_GRID, $isFilterableInGrid); return $this; } + + /** + * @inheritDoc + */ + protected function _getDefaultBackendModel() + { + $backend = parent::_getDefaultBackendModel(); + if ($backend === Entity::DEFAULT_BACKEND_MODEL) { + $backend = DefaultBackend::class; + } + + return $backend; + } } diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Product/Action.php b/app/code/Magento/Catalog/Model/ResourceModel/Product/Action.php index 71ab9413a0d09..85f6269f63af0 100644 --- a/app/code/Magento/Catalog/Model/ResourceModel/Product/Action.php +++ b/app/code/Magento/Catalog/Model/ResourceModel/Product/Action.php @@ -160,7 +160,7 @@ protected function _saveAttributeValue($object, $attribute, $value) $storeId = (int) $this->_storeManager->getStore($object->getStoreId())->getId(); $table = $attribute->getBackend()->getTable(); - $entityId = $this->resolveEntityId($object->getId(), $table); + $entityId = $this->resolveEntityId($object->getId()); /** * If we work in single store mode all values should be saved just diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Product/Collection.php b/app/code/Magento/Catalog/Model/ResourceModel/Product/Collection.php index 7dbfe0d5fccea..3f908663c8e5e 100644 --- a/app/code/Magento/Catalog/Model/ResourceModel/Product/Collection.php +++ b/app/code/Magento/Catalog/Model/ResourceModel/Product/Collection.php @@ -7,6 +7,7 @@ namespace Magento\Catalog\Model\ResourceModel\Product; +use Magento\Catalog\Api\Data\CategoryInterface; use Magento\Catalog\Api\Data\ProductInterface; use Magento\Catalog\Model\Indexer\Category\Product\TableMaintainer; use Magento\Catalog\Model\Indexer\Product\Price\PriceTableResolver; @@ -2130,16 +2131,17 @@ private function getChildrenCategories(int $categoryId): array $firstCategory = array_shift($categories); if ($firstCategory['is_anchor'] == 1) { - $linkField = $this->getProductEntityMetadata()->getLinkField(); - $anchorCategory[] = (int)$firstCategory[$linkField]; + //category hierarchy can not be modified by staging updates + $entityField = $this->metadataPool->getMetadata(CategoryInterface::class)->getIdentifierField(); + $anchorCategory[] = (int)$firstCategory[$entityField]; foreach ($categories as $category) { if (in_array($category['parent_id'], $categoryIds) && in_array($category['parent_id'], $anchorCategory)) { - $categoryIds[] = (int)$category[$linkField]; + $categoryIds[] = (int)$category[$entityField]; // Storefront approach is to treat non-anchor children of anchor category as anchors. - // Adding their's IDs to $anchorCategory for consistency. + // Adding theirs IDs to $anchorCategory for consistency. if ($category['is_anchor'] == 1 || in_array($category['parent_id'], $anchorCategory)) { - $anchorCategory[] = (int)$category[$linkField]; + $anchorCategory[] = (int)$category[$entityField]; } } } diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Product/Gallery.php b/app/code/Magento/Catalog/Model/ResourceModel/Product/Gallery.php index ef274b1bef55e..5ff6207074b62 100644 --- a/app/code/Magento/Catalog/Model/ResourceModel/Product/Gallery.php +++ b/app/code/Magento/Catalog/Model/ResourceModel/Product/Gallery.php @@ -6,6 +6,8 @@ namespace Magento\Catalog\Model\ResourceModel\Product; +use Magento\Catalog\Model\Product\Media\Config; +use Magento\Framework\App\ObjectManager; use Magento\Store\Model\Store; /** @@ -31,22 +33,30 @@ class Gallery extends \Magento\Framework\Model\ResourceModel\Db\AbstractDb * @since 101.0.0 */ protected $metadata; + /** + * @var Config|null + */ + private $mediaConfig; /** * @param \Magento\Framework\Model\ResourceModel\Db\Context $context * @param \Magento\Framework\EntityManager\MetadataPool $metadataPool * @param string $connectionName + * @param Config|null $mediaConfig + * @throws \Exception */ public function __construct( \Magento\Framework\Model\ResourceModel\Db\Context $context, \Magento\Framework\EntityManager\MetadataPool $metadataPool, - $connectionName = null + $connectionName = null, + ?Config $mediaConfig = null ) { $this->metadata = $metadataPool->getMetadata( \Magento\Catalog\Api\Data\ProductInterface::class ); parent::__construct($context, $connectionName); + $this->mediaConfig = $mediaConfig ?? ObjectManager::getInstance()->get(Config::class); } /** @@ -491,7 +501,7 @@ public function getProductImages($product, $storeIds) \Zend_Db::INT_TYPE )->where( 'attribute_code IN (?)', - ['small_image', 'thumbnail', 'image'] + $this->mediaConfig->getMediaAttributeCodes() ); return $this->getConnection()->fetchAll($select); diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Product/LinkedProductSelectBuilderComposite.php b/app/code/Magento/Catalog/Model/ResourceModel/Product/LinkedProductSelectBuilderComposite.php index 17ca389777c5b..c7c08bc805a1d 100644 --- a/app/code/Magento/Catalog/Model/ResourceModel/Product/LinkedProductSelectBuilderComposite.php +++ b/app/code/Magento/Catalog/Model/ResourceModel/Product/LinkedProductSelectBuilderComposite.php @@ -33,7 +33,7 @@ public function build(int $productId, int $storeId) : array foreach ($this->linkedProductSelectBuilder as $productSelectBuilder) { $selects[] = $productSelectBuilder->build($productId, $storeId); } - $selects = array_merge(...$selects); + $selects = array_merge([], ...$selects); return $selects; } diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Product/Option/Value/Collection.php b/app/code/Magento/Catalog/Model/ResourceModel/Product/Option/Value/Collection.php index 5ea71176429fc..58e6290a820cd 100644 --- a/app/code/Magento/Catalog/Model/ResourceModel/Product/Option/Value/Collection.php +++ b/app/code/Magento/Catalog/Model/ResourceModel/Product/Option/Value/Collection.php @@ -14,6 +14,20 @@ */ class Collection extends \Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection { + /** + * Name prefix of events that are dispatched by model + * + * @var string + */ + protected $_eventPrefix = 'catalog_product_option_value_collection'; + + /** + * Name of event parameter + * + * @var string + */ + protected $_eventObject = 'product_option_value_collection'; + /** * Resource initialization * diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Product/Relation.php b/app/code/Magento/Catalog/Model/ResourceModel/Product/Relation.php index c855fc5371b46..392a4aeedfeb3 100644 --- a/app/code/Magento/Catalog/Model/ResourceModel/Product/Relation.php +++ b/app/code/Magento/Catalog/Model/ResourceModel/Product/Relation.php @@ -5,13 +5,38 @@ */ namespace Magento\Catalog\Model\ResourceModel\Product; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\Model\ResourceModel\Db\AbstractDb; +use Magento\Framework\Model\ResourceModel\Db\Context; +use Magento\Framework\EntityManager\MetadataPool; +use Magento\Catalog\Api\Data\ProductInterface; + /** * Catalog Product Relations Resource model * * @author Magento Core Team <core@magentocommerce.com> */ -class Relation extends \Magento\Framework\Model\ResourceModel\Db\AbstractDb +class Relation extends AbstractDb { + /** + * @var MetadataPool + */ + private $metadataPool; + + /** + * @param Context $context + * @param string $connectionName + * @param MetadataPool $metadataPool + */ + public function __construct( + Context $context, + $connectionName = null, + MetadataPool $metadataPool = null + ) { + parent::__construct($context, $connectionName); + $this->metadataPool = $metadataPool ?: ObjectManager::getInstance()->get(MetadataPool::class); + } + /** * Initialize resource model and define main table * @@ -109,4 +134,27 @@ public function removeRelations($parentId, $childIds) } return $this; } + + /** + * Finds parent relations by given children ids. + * + * @param array $childrenIds Child products entity ids. + * @return array Parent products entity ids. + */ + public function getRelationsByChildren(array $childrenIds): array + { + $connection = $this->getConnection(); + $linkField = $this->metadataPool->getMetadata(ProductInterface::class) + ->getLinkField(); + $select = $connection->select() + ->from( + ['cpe' => $this->getTable('catalog_product_entity')], + 'entity_id' + )->join( + ['relation' => $this->getTable('catalog_product_relation')], + 'relation.parent_id = cpe.' . $linkField + )->where('relation.child_id IN(?)', $childrenIds); + + return $connection->fetchCol($select); + } } diff --git a/app/code/Magento/Catalog/Model/Template/Filter.php b/app/code/Magento/Catalog/Model/Template/Filter.php index 0a46af3ef021d..bf624c3435103 100644 --- a/app/code/Magento/Catalog/Model/Template/Filter.php +++ b/app/code/Magento/Catalog/Model/Template/Filter.php @@ -108,7 +108,7 @@ public function viewDirective($construction) * The original intent of _absolute parameter was to simply append specified path to a base URL * bypassing any kind of processing. * For example, normally you would use {{view url="css/styles.css"}} directive which would automatically resolve - * into something like http://example.com/pub/static/area/theme/en_US/css/styles.css + * into something like http://example.com/static/area/theme/en_US/css/styles.css * But with _absolute, the expected behavior is this: {{view url="favicon.ico" _absolute=true}} should resolve * into something like http://example.com/favicon.ico * diff --git a/app/code/Magento/Catalog/Model/View/Asset/Image.php b/app/code/Magento/Catalog/Model/View/Asset/Image.php index c547ec612bb94..0f7082f9df154 100644 --- a/app/code/Magento/Catalog/Model/View/Asset/Image.php +++ b/app/code/Magento/Catalog/Model/View/Asset/Image.php @@ -6,11 +6,16 @@ namespace Magento\Catalog\Model\View\Asset; +use Magento\Catalog\Model\Config\CatalogMediaConfig; use Magento\Catalog\Model\Product\Media\ConfigInterface; use Magento\Framework\Encryption\Encryptor; use Magento\Framework\Encryption\EncryptorInterface; +use Magento\Framework\Exception\LocalizedException; use Magento\Framework\View\Asset\ContextInterface; use Magento\Framework\View\Asset\LocalInterface; +use Magento\Catalog\Helper\Image as ImageHelper; +use Magento\Framework\App\ObjectManager; +use Magento\Store\Model\StoreManagerInterface; /** * A locally available image file asset that can be referred with a file path @@ -58,6 +63,21 @@ class Image implements LocalInterface */ private $encryptor; + /** + * @var ImageHelper + */ + private $imageHelper; + + /** + * @var StoreManagerInterface + */ + private $storeManager; + + /** + * @var string + */ + private $mediaFormatUrl; + /** * Image constructor. * @@ -66,13 +86,19 @@ class Image implements LocalInterface * @param EncryptorInterface $encryptor * @param string $filePath * @param array $miscParams + * @param ImageHelper $imageHelper + * @param CatalogMediaConfig $catalogMediaConfig + * @param StoreManagerInterface $storeManager */ public function __construct( ConfigInterface $mediaConfig, ContextInterface $context, EncryptorInterface $encryptor, $filePath, - array $miscParams + array $miscParams, + ImageHelper $imageHelper = null, + CatalogMediaConfig $catalogMediaConfig = null, + StoreManagerInterface $storeManager = null ) { if (isset($miscParams['image_type'])) { $this->sourceContentType = $miscParams['image_type']; @@ -85,14 +111,73 @@ public function __construct( $this->filePath = $filePath; $this->miscParams = $miscParams; $this->encryptor = $encryptor; + $this->imageHelper = $imageHelper ?: ObjectManager::getInstance()->get(ImageHelper::class); + $this->storeManager = $storeManager ?: ObjectManager::getInstance()->get(StoreManagerInterface::class); + + $catalogMediaConfig = $catalogMediaConfig ?: ObjectManager::getInstance()->get(CatalogMediaConfig::class); + $this->mediaFormatUrl = $catalogMediaConfig->getMediaUrlFormat(); } /** - * @inheritdoc + * Get catalog image URL. + * + * @return string + * @throws LocalizedException */ public function getUrl() { - return $this->context->getBaseUrl() . DIRECTORY_SEPARATOR . $this->getImageInfo(); + switch ($this->mediaFormatUrl) { + case CatalogMediaConfig::IMAGE_OPTIMIZATION_PARAMETERS: + return $this->getUrlWithTransformationParameters(); + case CatalogMediaConfig::HASH: + return $this->context->getBaseUrl() . DIRECTORY_SEPARATOR . $this->getImageInfo(); + default: + throw new LocalizedException( + __("The specified Catalog media URL format '$this->mediaFormatUrl' is not supported.") + ); + } + } + + /** + * Get image URL with transformation parameters + * + * @return string + */ + private function getUrlWithTransformationParameters() + { + return $this->getOriginalImageUrl() . '?' . http_build_query($this->getImageTransformationParameters()); + } + + /** + * The list of parameters to be used during image transformations (e.g. resizing or applying watermarks). + * + * This method can be used as an extension point. + * + * @return string[] + */ + public function getImageTransformationParameters() + { + return [ + 'width' => $this->miscParams['image_width'], + 'height' => $this->miscParams['image_height'], + 'store' => $this->storeManager->getStore()->getCode(), + 'image-type' => $this->sourceContentType + ]; + } + + /** + * Get URL to the original version of the product image. + * + * @return string + */ + private function getOriginalImageUrl() + { + $originalImageFile = $this->getSourceFile(); + if (!$originalImageFile) { + return $this->imageHelper->getDefaultPlaceholderUrl(); + } else { + return $this->context->getBaseUrl() . $this->getFilePath(); + } } /** diff --git a/app/code/Magento/Catalog/Observer/ImageResizeAfterProductSave.php b/app/code/Magento/Catalog/Observer/ImageResizeAfterProductSave.php index 91d2868afab8c..54b655a217a08 100644 --- a/app/code/Magento/Catalog/Observer/ImageResizeAfterProductSave.php +++ b/app/code/Magento/Catalog/Observer/ImageResizeAfterProductSave.php @@ -10,7 +10,11 @@ use Magento\Framework\Event\ObserverInterface; use Magento\Framework\App\State; use Magento\MediaStorage\Service\ImageResize; +use Magento\Catalog\Model\Config\CatalogMediaConfig; +/** + * Resize product images after the product is saved + */ class ImageResizeAfterProductSave implements ObserverInterface { /** @@ -23,17 +27,26 @@ class ImageResizeAfterProductSave implements ObserverInterface */ private $state; + /** + * @var CatalogMediaConfig + */ + private $catalogMediaConfig; + /** * Product constructor. + * * @param ImageResize $imageResize * @param State $state + * @param CatalogMediaConfig $catalogMediaConfig */ public function __construct( ImageResize $imageResize, - State $state + State $state, + CatalogMediaConfig $catalogMediaConfig ) { $this->imageResize = $imageResize; $this->state = $state; + $this->catalogMediaConfig = $catalogMediaConfig; } /** @@ -44,6 +57,12 @@ public function __construct( */ public function execute(\Magento\Framework\Event\Observer $observer) { + $catalogMediaUrlFormat = $this->catalogMediaConfig->getMediaUrlFormat(); + if ($catalogMediaUrlFormat == CatalogMediaConfig::IMAGE_OPTIMIZATION_PARAMETERS) { + // Skip image resizing on the Magento side when it is offloaded to a web server or CDN + return; + } + /** @var $product \Magento\Catalog\Model\Product */ $product = $observer->getEvent()->getProduct(); diff --git a/app/code/Magento/Catalog/Pricing/Price/CalculateCustomOptionCatalogRule.php b/app/code/Magento/Catalog/Pricing/Price/CalculateCustomOptionCatalogRule.php new file mode 100644 index 0000000000000..1090867aa51a5 --- /dev/null +++ b/app/code/Magento/Catalog/Pricing/Price/CalculateCustomOptionCatalogRule.php @@ -0,0 +1,87 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Catalog\Pricing\Price; + +use Magento\Catalog\Model\Product; +use Magento\Catalog\Model\Product\PriceModifierInterface; +use Magento\Framework\Pricing\PriceCurrencyInterface; + +/** + * Calculates prices of custom options of the product with catalog rules applied. + */ +class CalculateCustomOptionCatalogRule +{ + /** + * @var PriceCurrencyInterface + */ + private $priceCurrency; + + /** + * @var PriceModifierInterface + */ + private $priceModifier; + + /** + * @param PriceCurrencyInterface $priceCurrency + * @param PriceModifierInterface $priceModifier + */ + public function __construct( + PriceCurrencyInterface $priceCurrency, + PriceModifierInterface $priceModifier + ) { + $this->priceModifier = $priceModifier; + $this->priceCurrency = $priceCurrency; + } + + /** + * Calculate prices of custom options of the product with catalog rules applied. + * + * @param Product $product + * @param float $optionPriceValue + * @param bool $isPercent + * @return float|null + */ + public function execute( + Product $product, + float $optionPriceValue, + bool $isPercent + ): ?float { + $regularPrice = (float)$product->getPriceInfo() + ->getPrice(RegularPrice::PRICE_CODE) + ->getValue(); + $catalogRulePrice = $this->priceModifier->modifyPrice( + $regularPrice, + $product + ); + // Apply catalog price rules to product options only if catalog price rules are applied to product. + if ($catalogRulePrice < $regularPrice) { + $optionPrice = $this->getOptionPriceWithoutPriceRule($optionPriceValue, $isPercent, $regularPrice); + $totalCatalogRulePrice = $this->priceModifier->modifyPrice( + $regularPrice + $optionPrice, + $product + ); + $finalOptionPrice = $totalCatalogRulePrice - $catalogRulePrice; + return $this->priceCurrency->convertAndRound($finalOptionPrice); + } + + return null; + } + + /** + * Calculate option price without catalog price rule discount. + * + * @param float $optionPriceValue + * @param bool $isPercent + * @param float $basePrice + * @return float + */ + private function getOptionPriceWithoutPriceRule(float $optionPriceValue, bool $isPercent, float $basePrice): float + { + return $isPercent ? $basePrice * $optionPriceValue / 100 : $optionPriceValue; + } +} diff --git a/app/code/Magento/Catalog/Pricing/Price/TierPrice.php b/app/code/Magento/Catalog/Pricing/Price/TierPrice.php index 1aa43a39af442..0434f0572135b 100644 --- a/app/code/Magento/Catalog/Pricing/Price/TierPrice.php +++ b/app/code/Magento/Catalog/Pricing/Price/TierPrice.php @@ -9,19 +9,27 @@ use Magento\Catalog\Model\Product; use Magento\Customer\Api\GroupManagementInterface; use Magento\Customer\Model\Session; +use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Framework\App\ObjectManager; use Magento\Framework\Pricing\Adjustment\CalculatorInterface; use Magento\Framework\Pricing\Amount\AmountInterface; use Magento\Framework\Pricing\Price\AbstractPrice; use Magento\Framework\Pricing\Price\BasePriceProviderInterface; +use Magento\Framework\Pricing\PriceCurrencyInterface; use Magento\Framework\Pricing\PriceInfoInterface; use Magento\Customer\Model\Group\RetrieverInterface as CustomerGroupRetrieverInterface; +use Magento\Tax\Model\Config; /** * @api * @since 100.0.2 + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + * @SuppressWarnings(PHPMD.CookieAndSessionMisuse) */ class TierPrice extends AbstractPrice implements TierPriceInterface, BasePriceProviderInterface { + private const XML_PATH_TAX_DISPLAY_TYPE = 'tax/display/type'; + /** * Price type tier */ @@ -62,35 +70,43 @@ class TierPrice extends AbstractPrice implements TierPriceInterface, BasePricePr */ private $customerGroupRetriever; + /** + * @var ScopeConfigInterface + */ + private $scopeConfig; + /** * @param Product $saleableItem * @param float $quantity * @param CalculatorInterface $calculator - * @param \Magento\Framework\Pricing\PriceCurrencyInterface $priceCurrency + * @param PriceCurrencyInterface $priceCurrency * @param Session $customerSession * @param GroupManagementInterface $groupManagement * @param CustomerGroupRetrieverInterface|null $customerGroupRetriever + * @param ScopeConfigInterface|null $scopeConfig */ public function __construct( Product $saleableItem, $quantity, CalculatorInterface $calculator, - \Magento\Framework\Pricing\PriceCurrencyInterface $priceCurrency, + PriceCurrencyInterface $priceCurrency, Session $customerSession, GroupManagementInterface $groupManagement, - CustomerGroupRetrieverInterface $customerGroupRetriever = null + CustomerGroupRetrieverInterface $customerGroupRetriever = null, + ?ScopeConfigInterface $scopeConfig = null ) { $quantity = (float)$quantity ? $quantity : 1; parent::__construct($saleableItem, $quantity, $calculator, $priceCurrency); $this->customerSession = $customerSession; $this->groupManagement = $groupManagement; $this->customerGroupRetriever = $customerGroupRetriever - ?? \Magento\Framework\App\ObjectManager::getInstance()->get(CustomerGroupRetrieverInterface::class); + ?? ObjectManager::getInstance()->get(CustomerGroupRetrieverInterface::class); if ($saleableItem->hasCustomerGroupId()) { $this->customerGroup = (int) $saleableItem->getCustomerGroupId(); } else { $this->customerGroup = (int) $this->customerGroupRetriever->getCustomerGroupId(); } + $this->scopeConfig = $scopeConfig ?: ObjectManager::getInstance()->get(ScopeConfigInterface::class); } /** @@ -136,6 +152,8 @@ protected function isFirstPriceBetter($firstPrice, $secondPrice) } /** + * Returns tier price count + * * @return int */ public function getTierPriceCount() @@ -144,6 +162,8 @@ public function getTierPriceCount() } /** + * Returns tier price list + * * @return array */ public function getTierPriceList() @@ -155,15 +175,32 @@ public function getTierPriceList() $this->priceList, function (&$priceData) { /* convert string value to float */ - $priceData['price_qty'] = $priceData['price_qty'] * 1; + $priceData['price_qty'] *= 1; + if ($this->getConfigTaxDisplayType() === Config::DISPLAY_TYPE_BOTH) { + $exclTaxPrice = $this->calculator->getAmount($priceData['price'], $this->product, true); + $priceData['excl_tax_price'] = $exclTaxPrice; + } $priceData['price'] = $this->applyAdjustment($priceData['price']); } ); } + return $this->priceList; } /** + * Returns config tax display type + * + * @return int + */ + private function getConfigTaxDisplayType(): int + { + return (int) $this->scopeConfig->getValue(self::XML_PATH_TAX_DISPLAY_TYPE); + } + + /** + * Filters tier prices + * * @param array $priceList * @return array */ @@ -204,6 +241,8 @@ protected function filterTierPrices(array $priceList) } /** + * Returns base price + * * @return float */ protected function getBasePrice() @@ -213,25 +252,22 @@ protected function getBasePrice() } /** - * Calculates savings percentage according to the given tier price amount - * and related product price amount. + * Calculates savings percentage according to the given tier price amount and related product price amount. * * @param AmountInterface $amount - * * @return float */ public function getSavePercent(AmountInterface $amount) { - $productPriceAmount = $this->priceInfo->getPrice( - FinalPrice::PRICE_CODE - )->getAmount(); + $productPriceAmount = $this->priceInfo->getPrice(FinalPrice::PRICE_CODE) + ->getAmount(); - return round( - 100 - ((100 / $productPriceAmount->getValue()) * $amount->getValue()) - ); + return round(100 - ((100 / $productPriceAmount->getValue()) * $amount->getValue())); } /** + * Apply adjustment to price + * * @param float|string $price * @return \Magento\Framework\Pricing\Amount\AmountInterface */ @@ -314,6 +350,8 @@ protected function getStoredTierPrices() } /** + * Return is percentage discount + * * @return bool */ public function isPercentageDiscount() diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminAddOptionForDropdownAttributeActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminAddOptionForDropdownAttributeActionGroup.xml new file mode 100644 index 0000000000000..189370b03fcee --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminAddOptionForDropdownAttributeActionGroup.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminAddOptionForDropdownAttributeActionGroup"> + <annotations> + <description>Click on new value of selector attribute and fill the values for storefront view, and admin product edit page</description> + </annotations> + <arguments> + <argument name="storefrontViewAttributeValue" defaultValue="{{ProductAttributeOption8.label}}" type="string" /> + <argument name="adminAttributeValue" defaultValue="{{ProductAttributeOption8.label}}" type="string" /> + <argument name="rowNumber" defaultValue="0" type="string"/> + </arguments> + <scrollTo selector="{{AdminCreateNewProductAttributeSection.addValue}}" stepKey="scrollToOption"/> + <click selector="{{AdminCreateNewProductAttributeSection.addValue}}" stepKey="clickOnAddValueButton"/> + <waitForElementVisible selector="{{AdminCreateNewProductAttributeSection.defaultStoreView(rowNumber)}}" stepKey="waitForDefaultStoreViewToVisible"/> + <fillField selector="{{AdminCreateNewProductAttributeSection.defaultStoreView(rowNumber)}}" userInput="{{storefrontViewAttributeValue}}" stepKey="fillDefaultStoreView"/> + <fillField selector="{{AdminCreateNewProductAttributeSection.adminOption(rowNumber)}}" userInput="{{adminAttributeValue}}" stepKey="fillAdminField"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminAssertProductAttributeInAttributeGridActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminAssertProductAttributeInAttributeGridActionGroup.xml new file mode 100644 index 0000000000000..d00a0a01c78b9 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminAssertProductAttributeInAttributeGridActionGroup.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminAssertProductAttributeInAttributeGridActionGroup"> + <annotations> + <description>Assert columns label, visible, searchable and comparable for attribute on the product attribute grid</description> + </annotations> + <arguments> + <argument name="productAttributeLabel" type="string"/> + <argument name="productAttributeVisible" defaultValue="Yes" type="string"/> + <argument name="productAttributeSearch" defaultValue="Yes" type="string"/> + <argument name="productAttributeCompare" defaultValue="No" type="string"/> + </arguments> + <see selector="{{AdminProductAttributeGridSection.defaultLabelColumn}}" userInput="{{productAttributeLabel}}" stepKey="seeDefaultLabel"/> + <see selector="{{AdminProductAttributeGridSection.isVisibleColumn}}" userInput="{{productAttributeVisible}}" stepKey="seeIsVisibleColumn"/> + <see selector="{{AdminProductAttributeGridSection.isSearchableColumn}}" userInput="{{productAttributeSearch}}" stepKey="seeSearchableColumn"/> + <see selector="{{AdminProductAttributeGridSection.isComparableColumn}}" userInput="{{productAttributeCompare}}" stepKey="seeComparableColumn"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminAssertProductAttributeOnProductEditPageActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminAssertProductAttributeOnProductEditPageActionGroup.xml new file mode 100644 index 0000000000000..faf9d4f40648d --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminAssertProductAttributeOnProductEditPageActionGroup.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="UTF-8"?> + <!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + --> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminAssertProductAttributeOnProductEditPageActionGroup"> + <annotations> + <description>Assert if product attribute present on the product Create/Edit page</description> + </annotations> + <arguments> + <argument name="attributeCode" defaultValue="{{newProductAttribute.attribute_code}}" type="string"/> + <argument name="attributeLabel" defaultValue="{{ProductAttributeFrontendLabel.label}}" type="string"/> + </arguments> + <conditionalClick selector="{{AdminProductFormSection.attributeTab}}" dependentSelector="{{AdminProductFormSection.attributeTabOpened}}" visible="false" stepKey="clickToOpen"/> + <scrollTo selector="{{AdminProductFormSection.attributeTab}}" stepKey="scrollToAttributeTab"/> + <seeElement selector="{{AdminProductFormSection.attributeLabelByText(attributeLabel)}}" stepKey="seeAttributeLabelInProductForm"/> + <seeElement selector="{{AdminProductFormSection.newAddedAttribute(attributeCode)}}" stepKey="seeProductAttributeIsAdded"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminCategoryOpenDesignSectionActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminCategoryOpenDesignSectionActionGroup.xml new file mode 100644 index 0000000000000..7fd42fd4925e1 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminCategoryOpenDesignSectionActionGroup.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminCategoryOpenDesignSectionActionGroup"> + <waitForElementVisible selector="{{CategoryDesignSection.DesignTab}}" stepKey="waitForDesignSection"/> + <conditionalClick selector="{{CategoryDesignSection.DesignTab}}" dependentSelector="{{CategoryDesignSection.LayoutDropdown}}" visible="false" stepKey="openDesignSection"/> + <waitForPageLoad stepKey="waitForDesignSectionLoad"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminCategoryOpenScheduleDesignUpdateSectionActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminCategoryOpenScheduleDesignUpdateSectionActionGroup.xml new file mode 100644 index 0000000000000..e0606d159e357 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminCategoryOpenScheduleDesignUpdateSectionActionGroup.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminCategoryOpenScheduleDesignUpdateSectionActionGroup"> + <waitForElementVisible selector="{{AdminCategoryScheduleDesignUpdateSection.sectionHeader}}" stepKey="waitForScheduleDesignUpdateSection"/> + <conditionalClick selector="{{AdminCategoryScheduleDesignUpdateSection.sectionHeader}}" dependentSelector="{{AdminCategoryScheduleDesignUpdateSection.customDesignFrom}}" visible="false" stepKey="openScheduleDesignUpdateSection"/> + <waitForPageLoad stepKey="waitForScheduleDesignUpdateSectionLoad"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminClickMassUpdateProductAttributesActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminClickMassUpdateProductAttributesActionGroup.xml new file mode 100644 index 0000000000000..90cc7666eb92f --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminClickMassUpdateProductAttributesActionGroup.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminClickMassUpdateProductAttributesActionGroup"> + <annotations> + <description>Clicks on 'Update attributes' from dropdown actions list on product grid page. Products should be selected via mass action before</description> + </annotations> + <click selector="{{AdminProductGridSection.bulkActionDropdown}}" stepKey="clickDropdown"/> + <click selector="{{AdminProductGridSection.bulkActionOption('Update attributes')}}" stepKey="clickOption"/> + <seeInCurrentUrl url="catalog/product_action_attribute/edit/" stepKey="seeInUrl"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminCreateAttributeFromProductPageActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminCreateAttributeFromProductPageActionGroup.xml index ae8e8a84149e1..2100fbded90c5 100644 --- a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminCreateAttributeFromProductPageActionGroup.xml +++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminCreateAttributeFromProductPageActionGroup.xml @@ -18,8 +18,9 @@ </arguments> <click selector="{{AdminProductFormSection.addAttributeBtn}}" stepKey="clickAddAttributeBtn"/> - <see userInput="Select Attribute" stepKey="checkNewAttributePopUpAppeared"/> + <waitForText userInput="Select Attribute" stepKey="checkNewAttributePopUpAppeared"/> <click selector="{{AdminProductFormAttributeSection.createNewAttribute}}" stepKey="clickCreateNewAttribute"/> + <waitForElementVisible selector="{{AdminProductFormNewAttributeSection.attributeLabel}}" stepKey="waitForAttrLabel" /> <fillField selector="{{AdminProductFormNewAttributeSection.attributeLabel}}" userInput="{{attributeName}}" stepKey="fillAttributeLabel"/> <selectOption selector="{{AdminProductFormNewAttributeSection.attributeType}}" userInput="{{attributeType}}" stepKey="selectAttributeType"/> <click selector="{{AdminProductFormNewAttributeSection.saveAttribute}}" stepKey="saveAttribute"/> diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminDisableActiveCategoryActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminDisableActiveCategoryActionGroup.xml new file mode 100644 index 0000000000000..cadef1a23f0c9 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminDisableActiveCategoryActionGroup.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminDisableActiveCategoryActionGroup"> + <annotations> + <description>Disable an active category</description> + </annotations> + + <click selector="{{AdminCategoryBasicFieldSection.enableCategoryLabel}}" stepKey="disableActiveCategory"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminDisableIncludeInMenuConfigActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminDisableIncludeInMenuConfigActionGroup.xml new file mode 100644 index 0000000000000..c83e83e9d064e --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminDisableIncludeInMenuConfigActionGroup.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminDisableIncludeInMenuConfigActionGroup"> + <annotations> + <description>Set "Include in Menu" option to No for Category</description> + </annotations> + + <click selector="{{AdminCategoryBasicFieldSection.includeInMenuLabel}}" stepKey="setIncludeInMenuSelectToNo"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminIncludeInMenuExcludedCategoryActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminIncludeInMenuExcludedCategoryActionGroup.xml new file mode 100644 index 0000000000000..dceac49f58f63 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminIncludeInMenuExcludedCategoryActionGroup.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminIncludeInMenuExcludedCategoryActionGroup"> + <annotations> + <description>Include to menu the excluded category</description> + </annotations> + + <click selector="{{AdminCategoryBasicFieldSection.includeInMenuLabel}}" stepKey="includeToMenuCategory"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminMassUpdateProductQtyAndStockStatusActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminMassUpdateProductQtyAndStockStatusActionGroup.xml new file mode 100644 index 0000000000000..4b5aca5050858 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminMassUpdateProductQtyAndStockStatusActionGroup.xml @@ -0,0 +1,45 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <!--Update Product Name and Description attribute--> + <actionGroup name="AdminMassUpdateProductQtyAndStockStatusActionGroup"> + <arguments> + <argument name="attributes"/> + <argument name="product"/> + </arguments> + <!--Filter product in product grid--> + <amOnPage url="{{AdminProductIndexPage.url}}" stepKey="visitAdminProductPageFirstTime"/> + <waitForPageLoad stepKey="waitForProductGridPageLoad"/> + <conditionalClick selector="{{AdminProductGridFilterSection.clearFilters}}" dependentSelector="{{AdminProductGridFilterSection.clearFilters}}" visible="true" stepKey="clickClearFiltersInitial"/> + <click selector="{{AdminProductGridFilterSection.filters}}" stepKey="openProductFilters"/> + <fillField selector="{{AdminProductGridFilterSection.nameFilter}}" userInput="{{product.name}}" stepKey="fillProductNameFilter"/> + <fillField selector="{{AdminProductGridFilterSection.skuFilter}}" userInput="{{product.sku}}" stepKey="fillProductSkuFilter"/> + <selectOption selector="{{AdminProductGridFilterSection.typeFilter}}" userInput="{{product.type_id}}" stepKey="selectionProductType"/> + <click selector="{{AdminProductGridFilterSection.applyFilters}}" stepKey="clickApplyFilters"/> + <!--Select first product from grid and open mass action--> + <click selector="{{AdminProductGridSection.productGridCheckboxOnRow('1')}}" stepKey="clickCheckbox"/> + <click selector="{{AdminProductGridSection.bulkActionDropdown}}" stepKey="clickDropdown"/> + <click selector="{{AdminProductGridSection.bulkActionOption('Update attributes')}}" stepKey="clickOption"/> + <waitForPageLoad stepKey="waitForUploadPage"/> + <seeInCurrentUrl url="{{ProductAttributesEditPage.url}}" stepKey="seeAttributePageEditUrl"/> + <!--Update inventory attributes and save--> + <click selector="{{AdminUpdateAttributesAdvancedInventorySection.inventory}}" stepKey="openInvetoryTab"/> + <click selector="{{AdminUpdateAttributesAdvancedInventorySection.changeQty}}" stepKey="uncheckChangeQty"/> + <fillField selector="{{AdminUpdateAttributesAdvancedInventorySection.qty}}" userInput="{{attributes.qty}}" stepKey="fillFieldName"/> + <click selector="{{AdminUpdateAttributesAdvancedInventorySection.changeStockAvailability}}" stepKey="uncheckChangeStockAvailability"/> + <selectOption selector="{{AdminUpdateAttributesAdvancedInventorySection.stockAvailability}}" userInput="{{attributes.stockAvailability}}" stepKey="selectStatus"/> + <click selector="{{AdminUpdateAttributesSection.saveButton}}" stepKey="save"/> + <waitForElementVisible selector="{{AdminMessagesSection.success}}" stepKey="waitVisibleSuccessMessage"/> + <see selector="{{AdminMessagesSection.success}}" userInput="Message is added to queue" stepKey="seeSuccessMessage"/> + <amOnPage url="{{AdminProductIndexPage.url}}" stepKey="visitAdminProductPageSecondTime"/> + <waitForPageLoad stepKey="waitForProductGridPage"/> + <conditionalClick selector="{{AdminProductGridFilterSection.clearFilters}}" dependentSelector="{{AdminProductGridFilterSection.clearFilters}}" visible="true" stepKey="clickClearFiltersAfterMassAction"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminOpenAttributeSetGridPageActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminOpenAttributeSetGridPageActionGroup.xml index c6f0c3332b1d5..38193fe547e52 100644 --- a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminOpenAttributeSetGridPageActionGroup.xml +++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminOpenAttributeSetGridPageActionGroup.xml @@ -8,6 +8,10 @@ <actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> <actionGroup name="AdminOpenAttributeSetGridPageActionGroup"> + <annotations> + <description>Open the Attribute Sets grid page.</description> + </annotations> + <amOnPage url="{{AdminProductAttributeSetGridPage.url}}" stepKey="goToAttributeSetPage"/> <waitForPageLoad stepKey="waitForAttributeSetPageLoad"/> </actionGroup> diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminOpenCatalogProductPageActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminOpenCatalogProductPageActionGroup.xml new file mode 100644 index 0000000000000..0e606b00d5913 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminOpenCatalogProductPageActionGroup.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminOpenCatalogProductPageActionGroup"> + <annotations> + <description>Open page with product grid.</description> + </annotations> + + <amOnPage url="{{AdminCatalogProductPage.url}}" stepKey="openCatalogProductPage"/> + <waitForPageLoad stepKey="waitForPageToLoad"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminSaveProductsMassAttributesUpdateActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminSaveProductsMassAttributesUpdateActionGroup.xml new file mode 100644 index 0000000000000..811eb6ee5f1f7 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminSaveProductsMassAttributesUpdateActionGroup.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminSaveProductsMassAttributesUpdateActionGroup"> + <annotations> + <description>Clicks on 'Save' button on products mass attributes update page.</description> + </annotations> + <click selector="{{AdminEditProductAttributesSection.Save}}" stepKey="save"/> + <waitForElementVisible selector="{{AdminMessagesSection.success}}" time="60" stepKey="waitForSuccessMessage"/> + <see selector="{{AdminMessagesSection.success}}" userInput="Message is added to queue" stepKey="assertSuccessMessage"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminSetEnableQtyIncrementsActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminSetEnableQtyIncrementsActionGroup.xml new file mode 100644 index 0000000000000..2e211dad6dc81 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminSetEnableQtyIncrementsActionGroup.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminSetEnableQtyIncrementsActionGroup"> + <annotations> + <description>Set "Enable Qty Increments" config in 'Advanced Inventory' panel on the Admin Product creation/edit page.</description> + </annotations> + <arguments> + <argument name="value" type="string"/> + </arguments> + <scrollTo selector="{{AdminProductFormAdvancedInventorySection.enableQtyIncrements}}" stepKey="scrollToEnableQtyIncrements"/> + <click selector="{{AdminProductFormAdvancedInventorySection.enableQtyIncrementsUseConfigSettings}}" stepKey="clickOnEnableQtyIncrementsUseConfigSettingsCheckbox"/> + <selectOption selector="{{AdminProductFormAdvancedInventorySection.enableQtyIncrements}}" userInput="{{value}}" + stepKey="setEnableQtyIncrements"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminSetQtyIncrementsForProductActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminSetQtyIncrementsForProductActionGroup.xml new file mode 100644 index 0000000000000..6ea82a2f2a490 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminSetQtyIncrementsForProductActionGroup.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminSetQtyIncrementsForProductActionGroup"> + <annotations> + <description>Fills in the "Qty Increments" option in 'Advanced Inventory' panel on the Admin Product creation/edit page.</description> + </annotations> + <arguments> + <argument name="qty" type="string"/> + </arguments> + <scrollTo selector="{{AdminProductFormAdvancedInventorySection.qtyIncrementsUseConfigSettings}}" stepKey="scrollToQtyIncrementsUseConfigSettings"/> + <click selector="{{AdminProductFormAdvancedInventorySection.qtyIncrementsUseConfigSettings}}" stepKey="clickOnQtyIncrementsUseConfigSettings"/> + <scrollTo selector="{{AdminProductFormAdvancedInventorySection.qtyIncrements}}" stepKey="scrollToQtyIncrements"/> + <fillField selector="{{AdminProductFormAdvancedInventorySection.qtyIncrements}}" userInput="{{qty}}" stepKey="fillQtyIncrements"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminStartCreateProductAttributeOnProductPageActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminStartCreateProductAttributeOnProductPageActionGroup.xml new file mode 100644 index 0000000000000..214a062704282 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminStartCreateProductAttributeOnProductPageActionGroup.xml @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminStartCreateProductAttributeOnProductPageActionGroup"> + <annotations> + <description>On the Create/Edit product page create new Attribute</description> + </annotations> + <arguments> + <argument name="attributeCode" defaultValue="{{newProductAttribute.attribute_code}}" type="string" /> + <argument name="attributeLabel" defaultValue="{{ProductAttributeFrontendLabel.label}}" type="string" /> + <argument name="inputType" defaultValue="Dropdown" type="string" /> + </arguments> + <click selector="{{AdminProductFormSection.addAttributeBtn}}" stepKey="clickOnAddAttribute"/> + <waitForElementVisible selector="{{AdminProductFormSection.createNewAttributeBtn}}" stepKey="waitForCreateBtn"/> + <click selector="{{AdminProductFormSection.createNewAttributeBtn}}" stepKey="clickCreateNewAttributeButton"/> + <waitForElementVisible selector="{{AdminCreateNewProductAttributeSection.defaultLabel}}" stepKey="waitForLabelInput"/> + <fillField selector="{{AdminCreateNewProductAttributeSection.defaultLabel}}" userInput="{{attributeLabel}}" stepKey="fillAttributeLabel"/> + <selectOption selector="{{AdminCreateNewProductAttributeSection.inputType}}" userInput="{{inputType}}" stepKey="setInputType"/> + <click selector="{{AdminCreateNewProductAttributeSection.advancedAttributeProperties}}" stepKey="clickOnAdvancedAttributeProperties"/> + <waitForElementVisible selector="{{AdminCreateNewProductAttributeSection.attributeCode}}" stepKey="waitForAttributeCodeToVisible"/> + <fillField selector="{{AdminCreateNewProductAttributeSection.attributeCode}}" userInput="{{attributeCode}}" stepKey="fillAttributeCode"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AssertAdminCategoryIncludedToMenuActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AssertAdminCategoryIncludedToMenuActionGroup.xml new file mode 100644 index 0000000000000..978af87b9f1c3 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AssertAdminCategoryIncludedToMenuActionGroup.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AssertAdminCategoryIncludedToMenuActionGroup"> + <annotations> + <description>Verify the category is included to menu</description> + </annotations> + + <seeCheckboxIsChecked selector="{{AdminCategoryBasicFieldSection.IncludeInMenu}}" stepKey="seeCheckboxIncludeInMenuIsChecked"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AssertAdminCategoryIsEnabledActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AssertAdminCategoryIsEnabledActionGroup.xml new file mode 100644 index 0000000000000..1cea3c5dfa041 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AssertAdminCategoryIsEnabledActionGroup.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AssertAdminCategoryIsEnabledActionGroup"> + <annotations> + <description>Verify the category is enabled</description> + </annotations> + + <seeCheckboxIsChecked selector="{{AdminCategoryBasicFieldSection.EnableCategory}}" stepKey="seeCategoryIsEnabled"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AssertAdminCategoryIsNotIncludeInMenuActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AssertAdminCategoryIsNotIncludeInMenuActionGroup.xml new file mode 100644 index 0000000000000..704cf2eca6209 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AssertAdminCategoryIsNotIncludeInMenuActionGroup.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AssertAdminCategoryIsNotIncludeInMenuActionGroup"> + <annotations> + <description>Verify the category is not included in menu</description> + </annotations> + + <dontSeeCheckboxIsChecked selector="{{AdminCategoryBasicFieldSection.IncludeInMenu}}" stepKey="dontSeeCategoryIncludeInMenu"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AssertAdminCategoryPageTitleActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AssertAdminCategoryPageTitleActionGroup.xml new file mode 100644 index 0000000000000..acbda5720191f --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AssertAdminCategoryPageTitleActionGroup.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AssertAdminCategoryPageTitleActionGroup"> + <arguments> + <argument name="categoryName" type="string"/> + </arguments> + <see selector="{{AdminCategoryContentSection.categoryPageTitle}}" userInput="{{categoryName}}" stepKey="seeProperPageTitle"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/GoToAttributeGridPageActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/GoToAttributeGridPageActionGroup.xml index 2b5fe9d76875c..6e44c33d81ba4 100644 --- a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/GoToAttributeGridPageActionGroup.xml +++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/GoToAttributeGridPageActionGroup.xml @@ -8,7 +8,7 @@ <actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> - <actionGroup name="GoToAttributeGridPageActionGroup"> + <actionGroup name="GoToAttributeGridPageActionGroup" deprecated="Use AdminOpenAttributeSetGridPageActionGroup instead."> <annotations> <description>Goes to the Attribute Sets grid page.</description> </annotations> diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/GoToProductCatalogPageActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/GoToProductCatalogPageActionGroup.xml index 08bf948c2223b..7e64dd520844d 100644 --- a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/GoToProductCatalogPageActionGroup.xml +++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/GoToProductCatalogPageActionGroup.xml @@ -8,7 +8,7 @@ <actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> - <actionGroup name="GoToProductCatalogPageActionGroup"> + <actionGroup name="GoToProductCatalogPageActionGroup" deprecated="Use AdminOpenCatalogProductPageActionGroup instead."> <annotations> <description>Goes to the Admin Products grid page.</description> </annotations> diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/StorefrontCheckPresentSubCategoryActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/StorefrontCheckPresentSubCategoryActionGroup.xml new file mode 100644 index 0000000000000..1799f6339a84d --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/StorefrontCheckPresentSubCategoryActionGroup.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="StorefrontCheckPresentSubCategoryActionGroup"> + <annotations> + <description>Checks for a subcategory in topmenu</description> + </annotations> + <arguments> + <argument name="parenCategoryName" type="string"/> + <argument name="childCategoryName" type="string"/> + </arguments> + + <waitForElementVisible selector="{{StorefrontHeaderSection.NavigationCategoryByName(parenCategoryName)}}" stepKey="waitForTopMenuLoaded"/> + <moveMouseOver selector="{{StorefrontHeaderSection.NavigationCategoryByName(parenCategoryName)}}" stepKey="moveMouseToParentCategory"/> + <seeElement selector="{{StorefrontHeaderSection.NavigationCategoryByName(childCategoryName)}}" stepKey="seeSubcategoryInTree"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Catalog/Test/Mftf/Data/NonexistentProductData.xml b/app/code/Magento/Catalog/Test/Mftf/Data/NonexistentProductData.xml new file mode 100644 index 0000000000000..73b0765394333 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/Data/NonexistentProductData.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<entities xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:DataGenerator/etc/dataProfileSchema.xsd"> + <entity name="NonexistentProduct" type="product"> + <data key="sku">NonexistentProductSku</data> + <data key="qty">1</data> + </entity> + <entity name="SecondNonexistentProduct" type="product"> + <data key="sku">SecondNonexistentProductSku</data> + <data key="qty">1</data> + </entity> +</entities> diff --git a/app/code/Magento/Catalog/Test/Mftf/Data/ProductAttributeData.xml b/app/code/Magento/Catalog/Test/Mftf/Data/ProductAttributeData.xml index ffee02080503e..51658dca78c6a 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Data/ProductAttributeData.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Data/ProductAttributeData.xml @@ -385,6 +385,20 @@ <data key="default_store_label" unique="suffix">Attribute Store label <span> </data> <data key="frontend_input">text</data> </entity> + <entity name="ProductTypeIdAttribute" type="ProductAttribute"> + <data key="frontend_label">Type id</data> + <data key="attribute_code">type_id</data> + <data key="frontend_input">text</data> + <data key="is_required">No</data> + <data key="is_required_admin">No</data> + </entity> + <entity name="ProductProductTypeAttribute" type="ProductAttribute"> + <data key="frontend_label">Product type</data> + <data key="attribute_code">product_type</data> + <data key="frontend_input">text</data> + <data key="is_required">No</data> + <data key="is_required_admin">No</data> + </entity> <!-- Product attribute from file "export_import_configurable_product.csv" --> <entity name="ProductAttributeWithTwoOptionsForExportImport" extends="productAttributeDropdownTwoOptions" type="ProductAttribute"> <data key="attribute_code">attribute</data> diff --git a/app/code/Magento/Catalog/Test/Mftf/Data/ProductAttributeMassUpdateData.xml b/app/code/Magento/Catalog/Test/Mftf/Data/ProductAttributeMassUpdateData.xml index 99908f1c9df5f..22557972b991f 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Data/ProductAttributeMassUpdateData.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Data/ProductAttributeMassUpdateData.xml @@ -12,4 +12,12 @@ <data key="name" unique="suffix">New Bundle Product Name</data> <data key="description" unique="suffix">This is the description</data> </entity> + <entity name="UpdateAttributeQtyAndStockToInStock" type="productAttributeMassUpdate"> + <data key="qty">10</data> + <data key="stockAvailability">In Stock</data> + </entity> + <entity name="UpdateAttributeQtyAndStockToOutOfStock" type="productAttributeMassUpdate"> + <data key="qty">0</data> + <data key="stockAvailability">Out of Stock</data> + </entity> </entities> diff --git a/app/code/Magento/Catalog/Test/Mftf/Page/AdminCategoryEditPage.xml b/app/code/Magento/Catalog/Test/Mftf/Page/AdminCategoryEditPage.xml index e1c8e5c75e9ac..15fcf5f7d4000 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Page/AdminCategoryEditPage.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Page/AdminCategoryEditPage.xml @@ -19,5 +19,6 @@ <section name="AdminCategoryModalSection"/> <section name="AdminCategoryMessagesSection"/> <section name="AdminCategoryContentSection"/> + <section name="AdminCategoryScheduleDesignUpdateSection"/> </page> </pages> diff --git a/app/code/Magento/Catalog/Test/Mftf/Section/AdminCategoryBasicFieldSection/AdminCategoryBasicFieldSection.xml b/app/code/Magento/Catalog/Test/Mftf/Section/AdminCategoryBasicFieldSection/AdminCategoryBasicFieldSection.xml index aff7ffe4d5763..1b041c5ca306f 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Section/AdminCategoryBasicFieldSection/AdminCategoryBasicFieldSection.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Section/AdminCategoryBasicFieldSection/AdminCategoryBasicFieldSection.xml @@ -22,5 +22,6 @@ <element name="FieldError" type="text" selector=".admin__field-error[data-bind='attr: {for: {{field}}}, text: error']" parameterized="true"/> <element name="panelFieldControl" type="input" selector="//aside//div[@data-index="{{arg1}}"]/descendant::*[@name="{{arg2}}"]" parameterized="true"/> <element name="productsInCategory" type="input" selector="div[data-index='assign_products']" timeout="30"/> + <element name="scheduleDesignUpdateTab" type="block" selector="div[data-index='schedule_design_update']" timeout="15"/> </section> </sections> diff --git a/app/code/Magento/Catalog/Test/Mftf/Section/AdminCategoryContentSection.xml b/app/code/Magento/Catalog/Test/Mftf/Section/AdminCategoryContentSection.xml index 1cb095974d0fd..034150ef45460 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Section/AdminCategoryContentSection.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Section/AdminCategoryContentSection.xml @@ -14,6 +14,7 @@ <element name="selectFromGalleryButton" type="button" selector="//*[@class='file-uploader-area']/label[text()='Select from Gallery']"/> <element name="uploadImageFile" type="input" selector=".file-uploader-area>input"/> <element name="imageFileName" type="text" selector=".file-uploader-filename"/> + <element name="imageFileMeta" type="text" selector=".file-uploader-meta"/> <element name="removeImageButton" type="button" selector=".file-uploader-summary .action-remove"/> <element name="AddCMSBlock" type="select" selector="//*[@name='landing_page']"/> <element name="description" type="input" selector="//*[@name='description']"/> diff --git a/app/code/Magento/Catalog/Test/Mftf/Section/AdminCategoryScheduleDesignUpdateSection.xml b/app/code/Magento/Catalog/Test/Mftf/Section/AdminCategoryScheduleDesignUpdateSection.xml new file mode 100644 index 0000000000000..9d9a7f204544d --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/Section/AdminCategoryScheduleDesignUpdateSection.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="AdminCategoryScheduleDesignUpdateSection"> + <element name="sectionHeader" type="button" selector="div[data-index='schedule_design_update'] .fieldset-wrapper-title" timeout="30"/> + <element name="sectionBody" type="text" selector="div[data-index='schedule_design_update'] .admin__fieldset-wrapper-content"/> + <element name="customDesignFrom" type="input" selector="div[data-index='schedule_design_update'] input[name='custom_design_from']"/> + </section> +</sections> diff --git a/app/code/Magento/Catalog/Test/Mftf/Section/AdminCreateNewProductAttributeSection.xml b/app/code/Magento/Catalog/Test/Mftf/Section/AdminCreateNewProductAttributeSection.xml index 2de7bf19fd378..447fb186d3a7c 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Section/AdminCreateNewProductAttributeSection.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Section/AdminCreateNewProductAttributeSection.xml @@ -9,7 +9,8 @@ <sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> <section name="AdminCreateNewProductAttributeSection"> - <element name="saveAttribute" type="button" selector="#save"/> + <element name="saveAttribute" type="button" selector="#save" timeout="30"/> + <element name="closeAttribute" type="button" selector="#cancel" timeout="30"/> <element name="defaultLabel" type="input" selector="input[name='frontend_label[0]']"/> <element name="inputType" type="select" selector="select[name='frontend_input']" timeout="30"/> <element name="addValue" type="button" selector="//button[contains(@data-action,'add_new_row')]" timeout="30"/> diff --git a/app/code/Magento/Catalog/Test/Mftf/Section/AdminEditProductAttributesSection.xml b/app/code/Magento/Catalog/Test/Mftf/Section/AdminEditProductAttributesSection.xml index ad4ab57f8de2c..d7dc38a4a88f6 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Section/AdminEditProductAttributesSection.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Section/AdminEditProductAttributesSection.xml @@ -23,5 +23,7 @@ <element name="defaultLabel" type="text" selector="//td[contains(text(), '{{attributeName}}')]/following-sibling::td[contains(@class, 'col-frontend_label')]" parameterized="true"/> <element name="formByStoreId" type="block" selector="//form[contains(@action,'store/{{store_id}}')]" parameterized="true"/> <element name="tabButton" type="text" selector="#product_attribute_tabs a[title='{{tabName}}']" parameterized="true"/> + <element name="attributeShortDescription" type="text" selector="#short_description"/> + <element name="changeAttributeShortDescriptionToggle" type="checkbox" selector="#toggle_short_description"/> </section> </sections> diff --git a/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductAttributeGridSection.xml b/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductAttributeGridSection.xml index 5efd04eacb719..e4b33ac795559 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductAttributeGridSection.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductAttributeGridSection.xml @@ -18,10 +18,11 @@ <element name="FilterByAttributeCode" type="input" selector="#attributeGrid_filter_attribute_code"/> <element name="attributeLabelFilter" type="input" selector="//input[@name='frontend_label']"/> <element name="attributeCodeColumn" type="text" selector="//div[@id='attributeGrid']//td[contains(@class,'col-attr-code col-attribute_code')]"/> - <element name="defaultLabelColumn" type="text" selector="//div[@id='attributeGrid']//td[contains(@class,'col-label col-frontend_label')]"/> + <element name="defaultLabelColumn" type="text" selector="//div[@id='attributeGrid']//table[@id='attributeGrid_table']//tbody//td[contains(@class,'col-label col-frontend_label')]"/> <element name="isVisibleColumn" type="text" selector="//div[@id='attributeGrid']//td[contains(@class,'a-center col-is_visible')]"/> <element name="scopeColumn" type="text" selector="//div[@id='attributeGrid']//td[contains(@class,'a-center col-is_global')]"/> <element name="isSearchableColumn" type="text" selector="//div[@id='attributeGrid']//td[contains(@class,'a-center col-is_searchable')]"/> <element name="isComparableColumn" type="text" selector="//div[@id='attributeGrid']//td[contains(@class,'a-center col-is_comparable')]"/> </section> </sections> + diff --git a/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductFormSection/AdminProductFormSection.xml b/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductFormSection/AdminProductFormSection.xml index 5bdd3bd5abcc6..1ca051e2f6669 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductFormSection/AdminProductFormSection.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductFormSection/AdminProductFormSection.xml @@ -48,12 +48,13 @@ <element name="contentTab" type="button" selector="//strong[contains(@class, 'admin__collapsible-title')]/span[text()='Content']"/> <element name="fieldError" type="text" selector="//input[@name='product[{{fieldName}}]']/following-sibling::label[@class='admin__field-error']" parameterized="true"/> <element name="priceFieldError" type="text" selector="//input[@name='product[price]']/parent::div/parent::div/label[@class='admin__field-error']"/> - <element name="addAttributeBtn" type="button" selector="#addAttribute"/> - <element name="createNewAttributeBtn" type="button" selector="button[data-index='add_new_attribute_button']"/> + <element name="addAttributeBtn" type="button" selector="#addAttribute" timeout="30"/> + <element name="createNewAttributeBtn" type="button" selector="button[data-index='add_new_attribute_button']" timeout="30"/> <element name="save" type="button" selector="#save-button" timeout="30"/> <element name="saveNewAttribute" type="button" selector="//aside[contains(@class, 'create_new_attribute_modal')]//button[@id='save']"/> <element name="successMessage" type="text" selector="#messages"/> <element name="attributeTab" type="button" selector="//strong[contains(@class, 'admin__collapsible-title')]/span[text()='Attributes']"/> + <element name="attributeTabOpened" type="button" selector="//div[contains(@class, 'admin__collapsible-block-wrapper') and contains(@class, '_show') ]//span[text()='Attributes']"/> <element name="attributeLabel" type="input" selector="//input[@name='frontend_label[0]']"/> <element name="frontendInput" type="select" selector="select[name = 'frontend_input']"/> <element name="productFormTab" type="button" selector="//strong[@class='admin__collapsible-title']/span[contains(text(), '{{tabName}}')]" parameterized="true"/> diff --git a/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductGridSection.xml b/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductGridSection.xml index 540db609f550b..8f2b789639e7f 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductGridSection.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductGridSection.xml @@ -26,8 +26,8 @@ <element name="productGridHeaderCell" type="text" selector="//div[@data-role='grid-wrapper']//tr//th[contains(., '{{column}}')]" parameterized="true"/> <element name="multicheckDropdown" type="button" selector="div[data-role='grid-wrapper'] th.data-grid-multicheck-cell button.action-multicheck-toggle"/> <element name="multicheckOption" type="button" selector="//div[@data-role='grid-wrapper']//th[contains(@class, data-grid-multicheck-cell)]//li//span[text() = '{{label}}']" parameterized="true"/> - <element name="bulkActionDropdown" type="button" selector="div.admin__data-grid-header-row.row div.action-select-wrap button.action-select"/> - <element name="bulkActionOption" type="button" selector="//div[contains(@class,'admin__data-grid-header-row') and contains(@class, 'row')]//div[contains(@class, 'action-select-wrap')]//ul/li/span[text() = '{{label}}']" parameterized="true"/> + <element name="bulkActionDropdown" type="button" selector="div.admin__data-grid-header-row.row div.action-select-wrap button.action-select" timeout="30"/> + <element name="bulkActionOption" type="button" selector="//div[contains(@class,'admin__data-grid-header-row') and contains(@class, 'row')]//div[contains(@class, 'action-select-wrap')]//ul/li/span[text() = '{{label}}']" parameterized="true" timeout="30"/> <element name="productGridXRowYColumnButton" type="input" selector="table.data-grid tr.data-row:nth-child({{row}}) td:nth-child({{column}})" parameterized="true" timeout="30"/> <element name="table" type="text" selector="#container > div > div.admin__data-grid-wrap > table"/> <element name="firstRow" type="button" selector="tr.data-row:nth-of-type(1)"/> diff --git a/app/code/Magento/Catalog/Test/Mftf/Section/AdminUpdateAttributesSection/AdminUpdateAttributesAdvancedInventorySection.xml b/app/code/Magento/Catalog/Test/Mftf/Section/AdminUpdateAttributesSection/AdminUpdateAttributesAdvancedInventorySection.xml new file mode 100644 index 0000000000000..92dadbdd26c2d --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/Section/AdminUpdateAttributesSection/AdminUpdateAttributesAdvancedInventorySection.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="AdminUpdateAttributesAdvancedInventorySection"> + <element name="inventory" type="button" selector="#attributes_update_tabs_inventory"/> + <element name="changeQty" type="checkbox" selector="#inventory_qty_checkbox"/> + <element name="qty" type="input" selector="#inventory_qty"/> + <element name="changeStockAvailability" type="checkbox" selector="#inventory_stock_availability_checkbox"/> + <element name="stockAvailability" type="select" selector="//select[@name='inventory[is_in_stock]']"/> + </section> +</sections> diff --git a/app/code/Magento/Catalog/Test/Mftf/Section/StorefrontCategorySidebarSection/StorefrontCategorySidebarSection.xml b/app/code/Magento/Catalog/Test/Mftf/Section/StorefrontCategorySidebarSection/StorefrontCategorySidebarSection.xml index 5ec493aef0cea..848035b911aab 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Section/StorefrontCategorySidebarSection/StorefrontCategorySidebarSection.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Section/StorefrontCategorySidebarSection/StorefrontCategorySidebarSection.xml @@ -14,10 +14,10 @@ <element name="filterOption" type="text" selector=".filter-options-content .item"/> <element name="optionQty" type="text" selector=".filter-options-content .item .count"/> <element name="filterOptionByLabel" type="button" selector=" div.filter-options-item div[data-option-label='{{optionLabel}}']" parameterized="true"/> - <element name="removeFilter" type="button" selector="div.filter-current .remove"/> + <element name="removeFilter" type="button" selector="div.filter-current .remove" timeout="30"/> <element name="activeFilterOptions" type="text" selector=".filter-options-item.active .items"/> <element name="activeFilterOptionItemByPosition" type="text" selector=".filter-options-item.active .items li:nth-child({{itemPosition}}) a" parameterized="true"/> - <element name="enabledFilterOptionItemByLabel" type="text" selector="//div[@class='filter-options']//li[@class='item']//a[contains(text(), '{{optionLabel}}')]" parameterized="true"/> - <element name="disabledFilterOptionItemByLabel" type="text" selector="//div[@class='filter-options']//li[@class='item' and contains(text(), '{{optionLabel}}')]" parameterized="true"/> + <element name="enabledFilterOptionItemByLabel" type="text" selector="//div[@class='filter-options']//li[@class='item']//a[contains(text(), '{{optionLabel}}')]" parameterized="true" timeout="30"/> + <element name="disabledFilterOptionItemByLabel" type="text" selector="//div[@class='filter-options']//li[@class='item' and contains(text(), '{{optionLabel}}')]" parameterized="true" timeout="30"/> </section> </sections> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/AdminCreateAndSwitchProductType/AdminCreateSimpleProductSwitchToVirtualTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/AdminCreateAndSwitchProductType/AdminCreateSimpleProductSwitchToVirtualTest.xml index 3c7900f37d36f..6e607ca012ba0 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Test/AdminCreateAndSwitchProductType/AdminCreateSimpleProductSwitchToVirtualTest.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Test/AdminCreateAndSwitchProductType/AdminCreateSimpleProductSwitchToVirtualTest.xml @@ -22,7 +22,7 @@ <createData entity="_defaultCategory" stepKey="createPreReqCategory"/> </before> <after> - <actionGroup ref="GoToProductCatalogPageActionGroup" stepKey="goToProductCatalogPage"/> + <actionGroup ref="AdminOpenCatalogProductPageActionGroup" stepKey="goToProductCatalogPage"/> <actionGroup ref="DeleteProductUsingProductGridActionGroup" stepKey="deleteSimpleProduct"> <argument name="product" value="_defaultProduct"/> </actionGroup> @@ -49,7 +49,7 @@ <actionGroup ref="SaveProductFormActionGroup" stepKey="saveProductForm"/> <!-- Check that product was added with implicit type change --> <comment stepKey="beforeVerify" userInput="Verify Product Type Assigned Correctly"/> - <actionGroup ref="GoToProductCatalogPageActionGroup" stepKey="goToProductCatalogPage"/> + <actionGroup ref="AdminOpenCatalogProductPageActionGroup" stepKey="goToProductCatalogPage"/> <actionGroup ref="ResetProductGridToDefaultViewActionGroup" stepKey="resetSearch"/> <actionGroup ref="FilterProductGridByNameActionGroup" stepKey="searchForProduct"> <argument name="product" value="_defaultProduct"/> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/AdminCreateCategoryTest/AdminUploadCategoryImageTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/AdminCreateCategoryTest/AdminUploadCategoryImageTest.xml new file mode 100644 index 0000000000000..d431a0c3e40ed --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/Test/AdminCreateCategoryTest/AdminUploadCategoryImageTest.xml @@ -0,0 +1,44 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminUploadCategoryImageTest"> + <annotations> + <features value="Catalog"/> + <stories value="Add/remove images and videos for all product types and category"/> + <title value="Upload Category Image"/> + <description value="The test verifies uploading images including a special case of image name with spaces"/> + <severity value="MAJOR"/> + <testCaseId value="MC-26112"/> + <group value="catalog"/> + </annotations> + + <before> + <createData entity="_defaultCategory" stepKey="createCategory"/> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + </before> + + <after> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + <actionGroup ref="AdminLogoutActionGroup" stepKey="adminLogout"/> + </after> + + <!--Go to created category admin page and upload image--> + <actionGroup ref="GoToAdminCategoryPageByIdActionGroup" stepKey="goToAdminCategoryPage"> + <argument name="id" value="$createCategory.id$"/> + </actionGroup> + <actionGroup ref="AddCategoryImageActionGroup" stepKey="addCategoryImage"/> + <actionGroup ref="CheckCategoryImageInAdminActionGroup" stepKey="checkCategoryImageInAdmin"/> + <!--Remove and upload new image--> + <actionGroup ref="RemoveCategoryImageActionGroup" stepKey="removeCategoryImage"/> + <actionGroup ref="AddCategoryImageActionGroup" stepKey="addCategoryImageAgain"> + <argument name="image" value="ImageUploadPngTwo"/> + </actionGroup> + <actionGroup ref="CheckCategoryImageInAdminActionGroup" stepKey="checkCategoryImageInAdminAgain"/> + </test> +</tests> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/AdminCreateCustomProductAttributeWithDropdownFieldTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/AdminCreateCustomProductAttributeWithDropdownFieldTest.xml index 758dcee69525e..10cba3ab209ef 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Test/AdminCreateCustomProductAttributeWithDropdownFieldTest.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Test/AdminCreateCustomProductAttributeWithDropdownFieldTest.xml @@ -8,16 +8,16 @@ <tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> - <test name="AdminCreateCustomProductAttributeWithDropdownFieldTest"> + <test name="AdminCreateCustomProductAttributeWithDropdownFieldTest" deprecated="Use AdminVerifyCreateCustomProductAttributeTest"> <annotations> <stories value="Create product Attribute"/> - <title value="Create Custom Product Attribute Dropdown Field (Not Required) from Product Page"/> + <title value="DEPRECATED: Create Custom Product Attribute Dropdown Field (Not Required) from Product Page"/> <description value="login as admin and create configurable product attribute with Dropdown field"/> <severity value="BLOCKER"/> <testCaseId value="MC-10905"/> <group value="mtf_migrated"/> <skip> - <issueId value="MC-15474"/> + <issueId value="DEPRECATED">Use AdminVerifyCreateCustomProductAttributeTest</issueId> </skip> </annotations> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/AdminCreateNewAttributeFromProductPageTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/AdminCreateNewAttributeFromProductPageTest.xml index e99643deed11d..52cac23574b53 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Test/AdminCreateNewAttributeFromProductPageTest.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Test/AdminCreateNewAttributeFromProductPageTest.xml @@ -26,7 +26,7 @@ <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> </after> - <actionGroup ref="GoToProductCatalogPageActionGroup" stepKey="goToProductCatalogPage"/> + <actionGroup ref="AdminOpenCatalogProductPageActionGroup" stepKey="goToProductCatalogPage"/> <actionGroup ref="GoToCreateProductPageActionGroup" stepKey="goToCreateProduct"> <argument name="product" value="SimpleProduct"/> </actionGroup> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/AdminDeletedCategoryNotShownAsAvailableOnProductPageTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/AdminDeletedCategoryNotShownAsAvailableOnProductPageTest.xml new file mode 100644 index 0000000000000..4207f88ecd97d --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/Test/AdminDeletedCategoryNotShownAsAvailableOnProductPageTest.xml @@ -0,0 +1,66 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminDeletedCategoryNotShownAsAvailableOnProductPageTest"> + <annotations> + <features value="Catalog"/> + <stories value="Delete categories"/> + <title value="Deleted Category not shown as available on Product page"/> + <description value="Deleted category not shown as available Category on Product edit page"/> + <severity value="MAJOR"/> + <testCaseId value="MC-37121"/> + <group value="Catalog"/> + </annotations> + <before> + <createData entity="_defaultCategory" stepKey="createCategory"/> + <createData entity="_defaultProduct" stepKey="createProduct"> + <requiredEntity createDataKey="createCategory"/> + </createData> + + <actionGroup ref="AdminLoginActionGroup" stepKey="logInAsAdmin"/> + </before> + <after> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logoutFromAdmin"/> + + <deleteData createDataKey="createProduct" stepKey="deleteProduct"/> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + </after> + + <!-- Create Category --> + <actionGroup ref="GoToCreateCategoryPageActionGroup" stepKey="goToCreateCategoryPage"/> + <actionGroup ref="FillCategoryNameAndUrlKeyAndSaveActionGroup" stepKey="fillCategoryForm"> + <argument name="categoryName" value="additional"/> + <argument name="categoryUrlKey" value=""/> + </actionGroup> + + <!-- Check if Category present on Product page --> + <actionGroup ref="AdminProductPageOpenByIdActionGroup" stepKey="navigateToProductPage"> + <argument name="productId" value="$$createProduct.id$$"/> + </actionGroup> + <waitForPageLoad time="60" stepKey="waitForProductPageLoad"/> + <actionGroup ref="AdminProductFormCategoryExistInCategoryListActionGroup" stepKey="checkExistCategoryInList"> + <argument name="categoryName" value="$$createCategory.name$$"/> + </actionGroup> + + <!-- Delete Category --> + <actionGroup ref="AdminDeleteCategoryByNameActionGroup" stepKey="deleteAdditionalCategory"> + <argument name="categoryName" value="additional"/> + </actionGroup> + + <!-- Check if Category absent on Product page --> + <actionGroup ref="AdminProductPageOpenByIdActionGroup" stepKey="navigateToProductPageAfterDelete"> + <argument name="productId" value="$$createProduct.id$$"/> + </actionGroup> + <waitForPageLoad time="60" stepKey="waitForProductPageLoadAfterDelete"/> + <actionGroup ref="AdminProductFormCategoryNotExistInCategoryListActionGroup" stepKey="checkNotExistCategoryInList"> + <argument name="categoryName" value="additional"/> + </actionGroup> + </test> +</tests> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/AdminFilterByNameByStoreViewOnProductGridTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/AdminFilterByNameByStoreViewOnProductGridTest.xml index eb9fe693f8b3b..7cf2e132c016d 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Test/AdminFilterByNameByStoreViewOnProductGridTest.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Test/AdminFilterByNameByStoreViewOnProductGridTest.xml @@ -15,33 +15,39 @@ <title value="Product grid filtering by store view level attribute"/> <description value="Verify that products grid can be filtered on all store view level by attribute"/> <severity value="MAJOR"/> - <testCaseId value="MAGETWO-98755"/> - <useCaseId value="MAGETWO-98335"/> + <testCaseId value="MC-28534"/> + <useCaseId value="MC-37347"/> <group value="catalog"/> </annotations> + <before> - <createData entity="SimpleProduct2" stepKey="createSimpleProduct"/> + <createData entity="SimpleProduct2" stepKey="createSimpleProduct1"/> + <createData entity="SimpleProduct2" stepKey="createSimpleProduct2"/> <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> </before> + <after> - <deleteData createDataKey="createSimpleProduct" stepKey="deleteSimpleProduct"/> + <deleteData createDataKey="createSimpleProduct1" stepKey="deleteSimpleProduct1"/> + <deleteData createDataKey="createSimpleProduct2" stepKey="deleteSimpleProduct2"/> <actionGroup ref="ClearProductsFilterActionGroup" stepKey="clearProductsFilter"/> <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> </after> + <actionGroup ref="AdminProductPageOpenByIdActionGroup" stepKey="goToEditPage"> - <argument name="productId" value="$$createSimpleProduct.id$$"/> + <argument name="productId" value="$$createSimpleProduct1.id$$"/> </actionGroup> <actionGroup ref="AdminSwitchStoreViewActionGroup" stepKey="switchToDefaultStoreView"> <argument name="storeView" value="_defaultStore.name"/> </actionGroup> <scrollToTopOfPage stepKey="scrollToTopOfAdminProductFormSection"/> <click selector="{{AdminProductFormSection.productNameUseDefault}}" stepKey="uncheckUseDefault"/> - <fillField selector="{{AdminProductFormSection.productName}}" userInput="{{SimpleProduct.name}}" stepKey="fillNewName"/> + <fillField selector="{{AdminProductFormSection.productName}}" userInput="$$createSimpleProduct2.name$$" stepKey="fillNewName"/> <actionGroup ref="SaveProductFormActionGroup" stepKey="saveSimpleProduct"/> <actionGroup ref="AdminOpenProductIndexPageActionGroup" stepKey="navigateToProductIndex"/> - <actionGroup ref="FilterProductGridByNameActionGroup" stepKey="filterGridByName"> - <argument name="product" value="SimpleProduct"/> + <actionGroup ref="FilterProductGridByName2ActionGroup" stepKey="filterGridByName"> + <argument name="name" value="$$createSimpleProduct2.name$$"/> </actionGroup> - <see selector="{{AdminProductGridSection.firstProductRow}}" userInput="{{SimpleProduct2.name}}" stepKey="seeProductNameInGrid"/> + <seeElement selector="{{AdminProductGridSection.productRowBySku('$$createSimpleProduct2.sku$$')}}" stepKey="seeProduct2InGrid"/> + <dontSeeElement selector="{{AdminProductGridSection.productRowBySku('$$createSimpleProduct1.sku$$')}}" stepKey="dontSeeProduct1InGrid"/> </test> </tests> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/AdminMassProductAttributeUpdateAddedToQueueTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/AdminMassProductAttributeUpdateAddedToQueueTest.xml new file mode 100644 index 0000000000000..dc34607f2a771 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/Test/AdminMassProductAttributeUpdateAddedToQueueTest.xml @@ -0,0 +1,58 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminMassProductAttributeUpdateAddedToQueueTest"> + <annotations> + <features value="Catalog"/> + <stories value="Mass update product attributes"/> + <title value="Check functionality of RabbitMQ"/> + <description value="Mass product attribute update task should be added to the queue"/> + <severity value="CRITICAL"/> + <testCaseId value="MC-28990"/> + <useCaseId value="MC-29179"/> + <group value="catalog"/> + <group value="asynchronousOperations"/> + </annotations> + <before> + <createData entity="ApiProductWithDescription" stepKey="createFirstProduct"/> + <createData entity="ApiProductWithDescription" stepKey="createSecondProduct"/> + <createData entity="ApiProductNameWithNoSpaces" stepKey="createThirdProduct"/> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + </before> + <after> + <deleteData createDataKey="createFirstProduct" stepKey="deleteFirstProduct"/> + <deleteData createDataKey="createSecondProduct" stepKey="deleteSecondProduct"/> + <deleteData createDataKey="createThirdProduct" stepKey="deleteThirdProduct"/> + <actionGroup ref="ClearProductsFilterActionGroup" stepKey="clearProductFilter"/> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logoutFromAdmin"/> + </after> + <actionGroup ref="AdminOpenProductIndexPageActionGroup" stepKey="navigateToProductIndex"/> + <actionGroup ref="SearchProductGridByKeyword2ActionGroup" stepKey="searchByKeyword"> + <argument name="keyword" value="api-simple-product"/> + </actionGroup> + <actionGroup ref="SortProductsByIdDescendingActionGroup" stepKey="sortProductsByIdDescending"/> + <click selector="{{AdminProductGridSection.productGridCheckboxOnRow('1')}}" stepKey="selectThirdProduct"/> + <click selector="{{AdminProductGridSection.productGridCheckboxOnRow('2')}}" stepKey="selectSecondProduct"/> + <click selector="{{AdminProductGridSection.productGridCheckboxOnRow('3')}}" stepKey="selectFirstProduct"/> + <actionGroup ref="AdminClickMassUpdateProductAttributesActionGroup" stepKey="goToUpdateProductAttributesPage"/> + <checkOption selector="{{AdminEditProductAttributesSection.changeAttributeShortDescriptionToggle}}" stepKey="toggleToChangeShortDescription"/> + <fillField selector="{{AdminEditProductAttributesSection.attributeShortDescription}}" userInput="Test Update" stepKey="fillShortDescriptionField"/> + <actionGroup ref="AdminSaveProductsMassAttributesUpdateActionGroup" stepKey="saveMassAttributeUpdate"/> + <see selector="{{AdminSystemMessagesSection.info}}" userInput="Task "Update attributes for 3 selected products": 1 item(s) have been scheduled for update." stepKey="seeInfoMessage"/> + <click selector="{{AdminSystemMessagesSection.viewDetailsLink}}" stepKey="seeDetails"/> + <see selector="{{AdminBulkDetailsModalSection.descriptionValue}}" userInput="Update attributes for 3 selected products" stepKey="seeDescription"/> + <see selector="{{AdminBulkDetailsModalSection.summaryValue}}" userInput="Pending, in queue..." stepKey="seeSummary"/> + <grabTextFrom selector="{{AdminBulkDetailsModalSection.startTimeValue}}" stepKey="grabStartTimeValue"/> + <assertRegExp stepKey="assertStartTime"> + <expectedResult type="string">/\d{1,2}\/\d{2}\/\d{4}\s\d{1,2}:\d{2}:\d{2}\s(AM|PM)/</expectedResult> + <actualResult type="variable">grabStartTimeValue</actualResult> + </assertRegExp> + </test> +</tests> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/AdminMoveProductBetweenCategoriesTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/AdminMoveProductBetweenCategoriesTest.xml index ac9c0206f4e24..0af3911474813 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Test/AdminMoveProductBetweenCategoriesTest.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Test/AdminMoveProductBetweenCategoriesTest.xml @@ -33,6 +33,9 @@ <actionGroup ref="AdminSwitchIndexerToActionModeActionGroup" stepKey="switchProductCategory"> <argument name="indexerValue" value="catalog_product_category"/> </actionGroup> + <actionGroup ref="AdminSwitchIndexerToActionModeActionGroup" stepKey="switchCatalogSearch"> + <argument name="indexerValue" value="catalogsearch_fulltext"/> + </actionGroup> </before> <after> @@ -87,8 +90,7 @@ <see selector="{{AdminIndexManagementSection.successMessage}}" userInput="You saved the configuration." stepKey="seeMessage"/> <!-- Navigate to the Catalog > Products --> - <amOnPage url="{{AdminCatalogProductPage.url}}" stepKey="onCatalogProductPage"/> - <waitForPageLoad stepKey="waitForProductPage"/> + <actionGroup ref="AdminOpenCatalogProductPageActionGroup" stepKey="onCatalogProductPage"/> <!-- Click on <product1>: Product page opens--> <actionGroup ref="FilterProductGridByNameActionGroup" stepKey="filterProduct"> @@ -158,8 +160,7 @@ <actionGroup ref="AdminLoginActionGroup" stepKey="LoginAdmin"/> <!-- Navigate to the Catalog > Products: Navigate to the Catalog>Products --> - <amOnPage url="{{AdminCatalogProductPage.url}}" stepKey="amOnProductPage"/> - <waitForPageLoad stepKey="waitForProductsPage"/> + <actionGroup ref="AdminOpenCatalogProductPageActionGroup" stepKey="amOnProductPage"/> <!-- Click on <product1> --> <actionGroup ref="FilterAndSelectProductActionGroup" stepKey="openSimpleProduct"> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/AdminProductCategoryIndexerInUpdateOnScheduleModeTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/AdminProductCategoryIndexerInUpdateOnScheduleModeTest.xml index eebd3472cbd95..51e267a7c166b 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Test/AdminProductCategoryIndexerInUpdateOnScheduleModeTest.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Test/AdminProductCategoryIndexerInUpdateOnScheduleModeTest.xml @@ -10,17 +10,15 @@ xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> <test name="AdminProductCategoryIndexerInUpdateOnScheduleModeTest"> <annotations> + <features value="Catalog"/> <stories value="Product Categories Indexer"/> <title value="Product Categories Indexer in Update on Schedule mode"/> <description value="The test verifies that in Update on Schedule mode if displaying of category products on Storefront changes due to product properties change, the changes are NOT applied immediately, but applied only after cron runs (twice)."/> - <severity value="BLOCKER"/> - <testCaseId value="MC-11146"/> + <severity value="CRITICAL"/> + <testCaseId value="MC-26119"/> <group value="catalog"/> <group value="indexer"/> - <skip> - <issueId value="MC-20392"/> - </skip> </annotations> <before> <actionGroup ref="AdminLoginActionGroup" stepKey="LoginAsAdmin"/> @@ -99,7 +97,7 @@ <magentoCLI command="cron:run" stepKey="runCron"/> <!-- 5. Open category A on Storefront again --> - <reloadPage stepKey="reloadCategoryA"/> + <actionGroup ref="ReloadPageActionGroup" stepKey="reloadCategoryA"/> <!-- Category A displays product A1 now --> <see userInput="$$createCategoryA.name$$" selector="{{StorefrontCategoryMainSection.CategoryTitle}}" stepKey="seeTitleCategoryA1"/> @@ -128,7 +126,7 @@ <magentoCLI command="cron:run" stepKey="runCron1"/> <!-- 9. Open category A on Storefront again --> - <reloadPage stepKey="refreshCategoryAPage"/> + <actionGroup ref="ReloadPageActionGroup" stepKey="refreshCategoryAPage"/> <!-- Category A is empty now --> <see userInput="$$createCategoryA.name$$" selector="{{StorefrontCategoryMainSection.CategoryTitle}}" stepKey="seeOnPageCategoryAName"/> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/AdminProductTypeSwitchingOnEditingTest/AdminDownloadableProductTypeSwitchingToSimpleProductTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/AdminProductTypeSwitchingOnEditingTest/AdminDownloadableProductTypeSwitchingToSimpleProductTest.xml index 6d7de64b47434..6cbf03a02f3b0 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Test/AdminProductTypeSwitchingOnEditingTest/AdminDownloadableProductTypeSwitchingToSimpleProductTest.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Test/AdminProductTypeSwitchingOnEditingTest/AdminDownloadableProductTypeSwitchingToSimpleProductTest.xml @@ -29,7 +29,7 @@ <actionGroup ref="SaveProductFormActionGroup" stepKey="saveProductForm"/> <!--Assert simple product on Admin product page grid--> <comment userInput="Assert simple product in Admin product page grid" stepKey="commentAssertProductOnAdmin"/> - <amOnPage url="{{AdminCatalogProductPage.url}}" stepKey="goToCatalogSimpleProductPage"/> + <actionGroup ref="AdminOpenCatalogProductPageActionGroup" stepKey="goToCatalogSimpleProductPage"/> <actionGroup ref="FilterProductGridBySku2ActionGroup" stepKey="filterSimpleProductGridBySku"> <argument name="sku" value="$$createProduct.sku$$"/> </actionGroup> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/AdminProductTypeSwitchingOnEditingTest/AdminVirtualProductTypeSwitchingToDownloadableProductTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/AdminProductTypeSwitchingOnEditingTest/AdminVirtualProductTypeSwitchingToDownloadableProductTest.xml index de99933c78933..2311369db48f6 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Test/AdminProductTypeSwitchingOnEditingTest/AdminVirtualProductTypeSwitchingToDownloadableProductTest.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Test/AdminProductTypeSwitchingOnEditingTest/AdminVirtualProductTypeSwitchingToDownloadableProductTest.xml @@ -49,7 +49,7 @@ <actionGroup ref="SaveProductFormActionGroup" stepKey="saveDownloadableProductForm"/> <!--Assert downloadable product on Admin product page grid--> <comment userInput="Assert configurable product in Admin product page grid" stepKey="commentAssertDownloadableProductOnAdmin"/> - <amOnPage url="{{AdminCatalogProductPage.url}}" stepKey="goToCatalogProductPage"/> + <actionGroup ref="AdminOpenCatalogProductPageActionGroup" stepKey="goToCatalogProductPage"/> <actionGroup ref="FilterProductGridBySku2ActionGroup" stepKey="filterProductGridBySku"> <argument name="sku" value="$$createProduct.sku$$"/> </actionGroup> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/AdminRestrictedUserAddCategoryFromProductPageTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/AdminRestrictedUserAddCategoryFromProductPageTest.xml index 6ba300b9c5b57..795c2ac77acdd 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Test/AdminRestrictedUserAddCategoryFromProductPageTest.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Test/AdminRestrictedUserAddCategoryFromProductPageTest.xml @@ -36,10 +36,8 @@ <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> <!--Delete created data--> <comment userInput="Delete created data" stepKey="commentDeleteCreatedData"/> - <amOnPage url="{{AdminRolesPage.url}}" stepKey="navigateToUserRoleGrid" /> - <waitForPageLoad stepKey="waitForRolesGridLoad" /> - <actionGroup ref="AdminDeleteRoleActionGroup" stepKey="deleteUserRole"> - <argument name="role" value="adminRole"/> + <actionGroup ref="AdminDeleteUserRoleActionGroup" stepKey="deleteUserRole"> + <argument name="roleName" value="{{adminRole.rolename}}"/> </actionGroup> <amOnPage url="{{AdminUsersPage.url}}" stepKey="goToAllUsersPage"/> <waitForPageLoad stepKey="waitForUsersGridLoad" /> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/AdminSortingByWebsitesTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/AdminSortingByWebsitesTest.xml index f5fb33afd4617..73aeed3af4fb0 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Test/AdminSortingByWebsitesTest.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Test/AdminSortingByWebsitesTest.xml @@ -42,7 +42,7 @@ <actionGroup ref="AdminDeleteWebsiteActionGroup" stepKey="deleteTestWebsite"> <argument name="websiteName" value="{{customWebsite.name}}"/> </actionGroup> - <actionGroup ref="GoToProductCatalogPageActionGroup" stepKey="goToProductCatalogPage"/> + <actionGroup ref="AdminOpenCatalogProductPageActionGroup" stepKey="goToProductCatalogPage"/> <actionGroup ref="ResetProductGridToDefaultViewActionGroup" stepKey="resetProductGridColumnsInitial"/> <actionGroup ref="ResetWebUrlOptionsActionGroup" stepKey="resetUrlOption"/> <actionGroup ref="CliIndexerReindexActionGroup" stepKey="reindex"> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/AdminUpdateCategoryAndMakeInactiveTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/AdminUpdateCategoryAndMakeInactiveTest.xml index 21f2b622c2ebd..ea50a17b47b44 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Test/AdminUpdateCategoryAndMakeInactiveTest.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Test/AdminUpdateCategoryAndMakeInactiveTest.xml @@ -30,26 +30,35 @@ <actionGroup ref="AdminOpenCategoryPageActionGroup" stepKey="openAdminCategoryIndexPage"/> <!--Update category and make category inactive--> - <actionGroup ref="AdminExpandCategoryTreeActionGroup" stepKey="clickOnExpandTree"/> - <click selector="{{AdminCategorySidebarTreeSection.categoryInTree(_defaultCategory.name)}}" stepKey="selectCreatedCategory"/> - <click selector="{{AdminCategoryBasicFieldSection.enableCategoryLabel}}" stepKey="disableCategory"/> + <actionGroup ref="NavigateToCreatedCategoryActionGroup" stepKey="navigateToCreatedCategory"> + <argument name="Category" value="$$createDefaultCategory$$"/> + </actionGroup> + <actionGroup ref="AdminDisableActiveCategoryActionGroup" stepKey="disableCategory"/> <actionGroup ref="AdminSaveCategoryActionGroup" stepKey="clickSaveButton"/> <actionGroup ref="AssertAdminCategorySaveSuccessMessageActionGroup" stepKey="assertSuccessMessage"/> - <see selector="{{AdminCategoryContentSection.categoryPageTitle}}" userInput="{{_defaultCategory.name}}" stepKey="seePageTitle" /> - <dontSeeCheckboxIsChecked selector="{{AdminCategoryBasicFieldSection.EnableCategory}}" stepKey="dontCategoryIsChecked"/> + <actionGroup ref="AssertAdminCategoryPageTitleActionGroup" stepKey="seePageTitle"> + <argument name="categoryName" value="$$createDefaultCategory.name$$"/> + </actionGroup> + <actionGroup ref="AssertAdminCategoryIsInactiveActionGroup" stepKey="seeDisabledCategory"/> <!--Verify Inactive Category is store front page--> - <amOnPage url="{{StorefrontCategoryPage.url(_defaultCategory.name)}}" stepKey="amOnCategoryPage"/> - <waitForPageLoad stepKey="waitForPageToBeLoaded"/> - <dontSeeElement selector="{{StorefrontHeaderSection.NavigationCategoryByName(_defaultCategory.name)}}" stepKey="dontSeeCategoryOnStoreNavigationBar"/> - <waitForPageLoad time="15" stepKey="wait"/> + <actionGroup ref="StorefrontOpenHomePageActionGroup" stepKey="goToStoreFront"/> + <actionGroup ref="StorefrontAssertCategoryNameIsNotShownInMenuActionGroup" stepKey="doNotSeeCategoryNameInMenu"> + <argument name="categoryName" value="$$createDefaultCategory.name$$"/> + </actionGroup> <!--Verify Inactive Category in category page --> - <actionGroup ref="AdminOpenCategoryPageActionGroup" stepKey="openAdminCategoryIndexPage1"/> - <actionGroup ref="AdminExpandCategoryTreeActionGroup" stepKey="clickOnExpandTree1"/> - <seeElement selector="{{AdminCategoryContentSection.categoryInTree(_defaultCategory.name)}}" stepKey="assertCategoryInTree" /> - <click selector="{{AdminCategorySidebarTreeSection.categoryInTree(_defaultCategory.name)}}" stepKey="selectCreatedCategory1"/> - <see selector="{{AdminCategoryContentSection.categoryPageTitle}}" userInput="{{_defaultCategory.name}}" stepKey="seeCategoryPageTitle1" /> - <dontSeeCheckboxIsChecked selector="{{AdminCategoryBasicFieldSection.EnableCategory}}" stepKey="assertCategoryIsInactive"/> + <actionGroup ref="AdminOpenCategoryPageActionGroup" stepKey="goToAdminCategoryIndexPage"/> + <actionGroup ref="AdminExpandCategoryTreeActionGroup" stepKey="expandCategoryTree"/> + <actionGroup ref="AssertAdminCategoryIsListedInCategoriesTreeActionGroup" stepKey="seeCategoryInTree"> + <argument name="categoryName" value="$$createDefaultCategory.name$$"/> + </actionGroup> + <actionGroup ref="AdminCategoriesOpenCategoryActionGroup" stepKey="openCategory"> + <argument name="category" value="$$createDefaultCategory$$"/> + </actionGroup> + <actionGroup ref="AssertAdminCategoryPageTitleActionGroup" stepKey="seeCategoryPageTitle"> + <argument name="categoryName" value="$$createDefaultCategory.name$$"/> + </actionGroup> + <actionGroup ref="AssertAdminCategoryIsInactiveActionGroup" stepKey="assertCategoryIsInactive"/> </test> </tests> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/AdminUpdateCategoryWithProductsDefaultSortingTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/AdminUpdateCategoryWithProductsDefaultSortingTest.xml new file mode 100644 index 0000000000000..051495b257012 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/Test/AdminUpdateCategoryWithProductsDefaultSortingTest.xml @@ -0,0 +1,71 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminUpdateCategoryWithProductsDefaultSortingTest"> + <annotations> + <features value="Catalog"/> + <stories value="Update categories"/> + <title value="Update category, sort products by default sorting"/> + <description value="Login as admin, update category and sort products"/> + <testCaseId value="MC-25667"/> + <severity value="CRITICAL"/> + <group value="catalog"/> + <group value="mtf_migrated"/> + </annotations> + <before> + <createData entity="defaultSimpleProduct" stepKey="simpleProduct" /> + <createData entity="_defaultCategory" stepKey="createCategory"/> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginToAdminPanel"/> + </before> + <after> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + <deleteData createDataKey="simpleProduct" stepKey="deleteSimpleProduct"/> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logoutFromAdmin"/> + <magentoCron groups="index" stepKey="reindexInvalidatedIndices"/> + </after> + + <!--Open Category Page--> + <actionGroup ref="GoToAdminCategoryPageByIdActionGroup" stepKey="goToAdminCategoryPage"> + <argument name="id" value="$createCategory.id$"/> + </actionGroup> + + <!--Update Product Display Setting--> + <waitForElementVisible selector="{{AdminCategoryDisplaySettingsSection.settingsHeader}}" stepKey="waitForDisplaySettingsSection"/> + <conditionalClick selector="{{AdminCategoryDisplaySettingsSection.settingsHeader}}" dependentSelector="{{AdminCategoryDisplaySettingsSection.displayMode}}" visible="false" stepKey="openDisplaySettingsSection"/> + <waitForElementVisible selector="{{CategoryDisplaySettingsSection.productListCheckBox}}" stepKey="waitForAvailableProductListCheckbox"/> + <click selector="{{CategoryDisplaySettingsSection.productListCheckBox}}" stepKey="enableTheAvailableProductList"/> + <selectOption selector="{{CategoryDisplaySettingsSection.productList}}" parameterArray="['Product Name', 'Price']" stepKey="selectPrice"/> + <waitForElementVisible selector="{{CategoryDisplaySettingsSection.defaultProductLisCheckBox}}" stepKey="waitForDefaultProductList"/> + <click selector="{{CategoryDisplaySettingsSection.defaultProductLisCheckBox}}" stepKey="enableTheDefaultProductList"/> + <selectOption selector="{{CategoryDisplaySettingsSection.defaultProductList}}" userInput="name" stepKey="selectProductName"/> + + <!--Add Products in Category--> + <actionGroup ref="AdminCategoryAssignProductActionGroup" stepKey="assignSimpleProductToCategory"> + <argument name="productSku" value="$simpleProduct.sku$"/> + </actionGroup> + <actionGroup ref="AdminSaveCategoryFormActionGroup" stepKey="saveCategory"/> + + <!--Verify Category Title--> + <see selector="{{AdminCategoryContentSection.categoryPageTitle}}" userInput="{{_defaultCategory.name}}" stepKey="seeCategoryNamePageTitle" /> + + <!--Verify Category in store front page--> + <amOnPage url="{{StorefrontCategoryPage.url($createCategory.custom_attributes[url_key]$)}}" stepKey="openStorefrontCategoryPage"/> + + <!--Verify Product in Category--> + <actionGroup ref="AssertStorefrontProductIsPresentOnCategoryPageActionGroup" stepKey="assertSimpleProductOnCategoryPage"> + <argument name="productName" value="$simpleProduct.name$"/> + </actionGroup> + + <!--Verify product name and sku on Store Front--> + <actionGroup ref="AssertProductNameAndSkuInStorefrontProductPageByCustomAttributeUrlKeyActionGroup" stepKey="assertProductOnStorefrontProductPage"> + <argument name="product" value="$simpleProduct$"/> + </actionGroup> + </test> +</tests> + diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/AdminUpdateCategoryWithProductsTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/AdminUpdateCategoryWithProductsTest.xml index b0829d96db4fd..f82294ece6478 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Test/AdminUpdateCategoryWithProductsTest.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Test/AdminUpdateCategoryWithProductsTest.xml @@ -7,15 +7,18 @@ --> <tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> - <test name="AdminUpdateCategoryWithProductsTest"> + <test name="AdminUpdateCategoryWithProductsTest" deprecated="Use AdminUpdateCategoryWithProductsDefaultSortingTest instead"> <annotations> <stories value="Update categories"/> - <title value="Update category, sort products by default sorting"/> + <title value="DEPRECATED. Update category, sort products by default sorting"/> <description value="Login as admin, update category and sort products"/> <testCaseId value="MC-6059"/> <severity value="BLOCKER"/> <group value="Catalog"/> <group value="mtf_migrated"/> + <skip> + <issueId value="DEPRECATED">Use AdminUpdateCategoryWithProductsDefaultSortingTest instead</issueId> + </skip> </annotations> <before> <actionGroup ref="AdminLoginActionGroup" stepKey="loginToAdminPanel"/> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/AdminVerifyCreateCloseCreateCustomProductAttributeTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/AdminVerifyCreateCloseCreateCustomProductAttributeTest.xml new file mode 100644 index 0000000000000..3b0907e3be1f3 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/Test/AdminVerifyCreateCloseCreateCustomProductAttributeTest.xml @@ -0,0 +1,95 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminVerifyCreateCloseCreateCustomProductAttributeTest"> + <annotations> + <features value="Catalog"/> + <stories value="Create product Attribute after closing the new attribute window multiple times"/> + <title value="Create Custom Product Attribute Dropdown Field (Not Required) from Product Page after closing the new attribute window multiple times"/> + <description + value="login as admin and create simple product attribute Dropdown field after closing the new attribute window multiple times"/> + <severity value="MAJOR"/> + <testCaseId value="MC-30362"/> + <group value="catalog"/> + </annotations> + <before> + <createData entity="SimpleSubCategory" stepKey="createCategory"/> + <createData entity="SimpleProduct" stepKey="createProduct"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginToAdminPanel"/> + </before> + <after> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + <deleteData createDataKey="createProduct" stepKey="deleteProduct"/> + <actionGroup ref="AdminDeleteProductAttributeByLabelActionGroup" stepKey="deleteCreatedAttribute"> + <argument name="productAttributeLabel" value="{{ProductAttributeFrontendLabel.label}}"/> + </actionGroup> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logoutFromAdminPanel"/> + </after> + <actionGroup ref="AdminProductPageOpenByIdActionGroup" stepKey="navigateToProductPage"> + <argument name="productId" value="$createProduct.id$"/> + </actionGroup> + <!-- Attribute creation --> + <click selector="{{AdminProductFormSection.addAttributeBtn}}" stepKey="clickOnAddAttribute"/> + <waitForElementVisible selector="{{AdminProductFormSection.createNewAttributeBtn}}" stepKey="waitForCreateBtn"/> + <click selector="{{AdminProductFormSection.createNewAttributeBtn}}" stepKey="clickCreateNewAttributeButton"/> + <waitForElementVisible selector="{{AdminCreateNewProductAttributeSection.defaultLabel}}" + stepKey="waitForLabelInput"/> + <!-- Close creation window few times --> + <click selector="{{AdminCreateNewProductAttributeSection.closeAttribute}}" + stepKey="clickCloseNewAttributeWindowButton"/> + <waitForElementVisible selector="{{AdminProductFormSection.createNewAttributeBtn}}" + stepKey="waitForCreateBtn2"/> + <click selector="{{AdminProductFormSection.createNewAttributeBtn}}" stepKey="clickCreateNewAttributeButton2"/> + <waitForElementVisible selector="{{AdminCreateNewProductAttributeSection.defaultLabel}}" + stepKey="waitForLabelInput2"/> + <click selector="{{AdminCreateNewProductAttributeSection.closeAttribute}}" + stepKey="clickCloseNewAttributeWindowButton2"/> + <waitForElementVisible selector="{{AdminProductFormSection.createNewAttributeBtn}}" + stepKey="waitForCreateBtn3"/> + <click selector="{{AdminProductFormSection.createNewAttributeBtn}}" stepKey="clickCreateNewAttributeButton3"/> + <waitForElementVisible selector="{{AdminCreateNewProductAttributeSection.defaultLabel}}" + stepKey="waitForLabelInput3"/> + <!-- Fill attribute data and save --> + <fillField selector="{{AdminCreateNewProductAttributeSection.defaultLabel}}" + userInput="{{ProductAttributeFrontendLabel.label}}" stepKey="fillAttributeLabel"/> + <selectOption selector="{{AdminCreateNewProductAttributeSection.inputType}}" userInput="Dropdown" + stepKey="setInputType"/> + <click selector="{{AdminCreateNewProductAttributeSection.advancedAttributeProperties}}" + stepKey="clickOnAdvancedAttributeProperties"/> + <waitForElementVisible selector="{{AdminCreateNewProductAttributeSection.attributeCode}}" + stepKey="waitForAttributeCodeToVisible"/> + <fillField selector="{{AdminCreateNewProductAttributeSection.attributeCode}}" + userInput="{{newProductAttribute.attribute_code}}" stepKey="fillAttributeCode"/> + <scrollTo selector="{{AdminCreateNewProductAttributeSection.storefrontProperties}}" + stepKey="scrollToStorefrontProperties"/> + <click selector="{{AdminCreateNewProductAttributeSection.storefrontProperties}}" + stepKey="clickOnStorefrontProperties"/> + <waitForElementVisible selector="{{AdminCreateNewProductAttributeSection.inSearch}}" + stepKey="waitForStoreFrontProperties"/> + <checkOption selector="{{AdminCreateNewProductAttributeSection.inSearch}}" stepKey="enableInSearchOption"/> + <checkOption selector="{{AdminCreateNewProductAttributeSection.advancedSearch}}" + stepKey="enableAdvancedSearch"/> + <checkOption selector="{{AdminCreateNewProductAttributeSection.visibleOnStorefront}}" + stepKey="enableVisibleOnStorefront"/> + <checkOption selector="{{AdminCreateNewProductAttributeSection.sortProductListing}}" + stepKey="enableSortProductListing"/> + <actionGroup ref="AdminAddOptionForDropdownAttributeActionGroup" stepKey="createDropdownOption"> + <argument name="storefrontViewAttributeValue" value="{{ProductAttributeOption8.label}}"/> + <argument name="adminAttributeLabel" value="{{ProductAttributeOption8.label}}"/> + </actionGroup> + <checkOption selector="{{AdminCreateNewProductAttributeSection.defaultRadioButton('1')}}" + stepKey="selectRadioButton"/> + <click selector="{{AdminCreateNewProductAttributeSection.saveAttribute}}" stepKey="clickOnSaveAttribute"/> + <!-- Check if the product page after attribute save--> + <seeInCurrentUrl url="{{AdminProductEditPage.url($createProduct.id$)}}" stepKey="seeRedirectToProductPage"/> + </test> +</tests> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/AdminVerifyCreateCustomProductAttributeTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/AdminVerifyCreateCustomProductAttributeTest.xml new file mode 100644 index 0000000000000..5cf3d9e38ddd4 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/Test/AdminVerifyCreateCustomProductAttributeTest.xml @@ -0,0 +1,92 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminVerifyCreateCustomProductAttributeTest"> + <annotations> + <features value="Catalog"/> + <stories value="Create product Attribute"/> + <title value="Create Custom Product Attribute Dropdown Field (Not Required) from Product Page"/> + <description value="login as admin and create simple product with attribute Dropdown field"/> + <severity value="MAJOR"/> + <testCaseId value="MC-26027"/> + <group value="mtf_migrated"/> + <group value="catalog"/> + </annotations> + <before> + <createData entity="SimpleSubCategory" stepKey="createCategory"/> + <createData entity="SimpleProduct" stepKey="createProduct"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginToAdminPanel"/> + </before> + <after> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + <deleteData createDataKey="createProduct" stepKey="deleteProduct"/> + <actionGroup ref="AdminDeleteProductAttributeByLabelActionGroup" stepKey="deleteCreatedAttribute"> + <argument name="productAttributeLabel" value="{{ProductAttributeFrontendLabel.label}}"/> + </actionGroup> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logoutFromAdminPanel"/> + </after> + <actionGroup ref="AdminProductPageOpenByIdActionGroup" stepKey="navigateToProductPage"> + <argument name="productId" value="$createProduct.id$"/> + </actionGroup> + <actionGroup ref="AdminStartCreateProductAttributeOnProductPageActionGroup" stepKey="createDropdownAttribute"> + <argument name="attributeCode" value="{{newProductAttribute.attribute_code}}" /> + <argument name="attributeLabel" value="{{ProductAttributeFrontendLabel.label}}" /> + <argument name="inputType" value="Dropdown" /> + </actionGroup> + <scrollTo selector="{{AdminCreateNewProductAttributeSection.storefrontProperties}}" stepKey="scrollToStorefrontProperties"/> + <click selector="{{AdminCreateNewProductAttributeSection.storefrontProperties}}" stepKey="clickOnStorefrontProperties"/> + <waitForElementVisible selector="{{AdminCreateNewProductAttributeSection.inSearch}}" stepKey="waitForStoreFrontProperties"/> + <checkOption selector="{{AdminCreateNewProductAttributeSection.inSearch}}" stepKey="enableInSearchOption"/> + <checkOption selector="{{AdminCreateNewProductAttributeSection.advancedSearch}}" stepKey="enableAdvancedSearch"/> + <checkOption selector="{{AdminCreateNewProductAttributeSection.visibleOnStorefront}}" stepKey="enableVisibleOnStorefront"/> + <checkOption selector="{{AdminCreateNewProductAttributeSection.sortProductListing}}" stepKey="enableSortProductListing"/> + <actionGroup ref="AdminAddOptionForDropdownAttributeActionGroup" stepKey="createDropdownOption"> + <argument name="storefrontViewAttributeValue" value="{{ProductAttributeOption8.label}}"/> + <argument name="adminAttributeLabel" value="{{ProductAttributeOption8.label}}"/> + </actionGroup> + <checkOption selector="{{AdminCreateNewProductAttributeSection.defaultRadioButton('1')}}" stepKey="selectRadioButton"/> + <click selector="{{AdminCreateNewProductAttributeSection.saveAttribute}}" stepKey="clickOnSaveAttribute"/> + <actionGroup ref="SaveProductFormActionGroup" stepKey="saveTheProduct"/> + <actionGroup ref="AdminAssertProductAttributeOnProductEditPageActionGroup" stepKey="adminProductAssertAttribute"> + <argument name="attributeLabel" value="{{ProductAttributeFrontendLabel.label}}"/> + <argument name="attributeCode" value="{{newProductAttribute.attribute_code}}"/> + </actionGroup> + <actionGroup ref="SearchAttributeByCodeOnProductAttributeGridActionGroup" stepKey="searchAttributeByCodeOnProductAttributeGrid"> + <argument name="productAttributeCode" value="{{newProductAttribute.attribute_code}}"/> + </actionGroup> + <actionGroup ref="AdminAssertProductAttributeInAttributeGridActionGroup" stepKey="assertAttributeOnProductAttributesGrid"> + <argument name="productAttributeLabel" value="{{ProductAttributeFrontendLabel.label}}"/> + </actionGroup> + <actionGroup ref="StorefrontOpenProductEntityPageActionGroup" stepKey="openProductPageOnStorefront"> + <argument name="product" value="$createProduct$"/> + </actionGroup> + <actionGroup ref="StorefrontAssertUpdatedProductPriceInStorefrontProductPageActionGroup" stepKey="checkProductPriceAndNameInStorefrontProductPage"> + <argument name="productName" value="$createProduct.name$"/> + <argument name="expectedPrice" value="$createProduct.price$"/> + </actionGroup> + <scrollTo selector="{{StorefrontProductMoreInformationSection.moreInformation}}" stepKey="scrollToAttribute"/> + <actionGroup ref="CheckAttributeInMoreInformationTabActionGroup" stepKey="checkAttributeInMoreInformationTab"> + <argument name="attributeLabel" value="{{ProductAttributeFrontendLabel.label}}"/> + <argument name="attributeValue" value="{{ProductAttributeOption8.value}}"/> + </actionGroup> + <actionGroup ref="StorefrontCheckQuickSearchStringActionGroup" stepKey="quickSearchByProductAttribute"> + <argument name="phrase" value="{{ProductAttributeOption8.value}}"/> + </actionGroup> + <actionGroup ref="AssertStorefrontAttributeOptionPresentInLayeredNavigationActionGroup" stepKey="assertAttributeWithOptionInLayeredNavigation"> + <argument name="attributeLabel" value="{{ProductAttributeFrontendLabel.label}}"/> + <argument name="attributeOptionLabel" value="{{ProductAttributeOption8.value}}"/> + </actionGroup> + <actionGroup ref="StorefrontAssertProductNameOnProductMainPageActionGroup" stepKey="assertProductPresentOnSearchPage"> + <argument name="productName" value="$createProduct.name$"/> + </actionGroup> + </test> +</tests> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/AdminVerifyProductOrderTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/AdminVerifyProductOrderTest.xml index 9146ee4d4d579..914e72d51e92a 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Test/AdminVerifyProductOrderTest.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Test/AdminVerifyProductOrderTest.xml @@ -25,7 +25,7 @@ <after> <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> </after> - <actionGroup ref="GoToProductCatalogPageActionGroup" stepKey="goToProductCatalogPage"/> + <actionGroup ref="AdminOpenCatalogProductPageActionGroup" stepKey="goToProductCatalogPage"/> <actionGroup ref="VerifyProductTypeOrder" stepKey="verifyProductTypeOrder"/> </test> </tests> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/CheckTierPricingOfProductsTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/CheckTierPricingOfProductsTest.xml index 55d697e35deba..5f7e9c4225c00 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Test/CheckTierPricingOfProductsTest.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Test/CheckTierPricingOfProductsTest.xml @@ -127,14 +127,12 @@ <waitForPageLoad stepKey="waitForCustomersPage"/> <see userInput="You saved the customer." stepKey="CustomerIsSaved"/> - <amOnPage url="{{AdminCustomerPage.url}}" stepKey="navigateToCustomers"/> - <waitForPageLoad stepKey="waitForPageLoad1" /> + <actionGroup ref="AdminOpenCustomersGridActionGroup" stepKey="navigateToCustomers"/> <click selector="{{AdminCustomerFiltersSection.clearAll}}" stepKey="ClearFilters"/> <waitForPageLoad stepKey="waitForFiltersClear"/> <!--Create Cart Price Rule--> - <amOnPage url="{{AdminCartPriceRulesPage.url}}" stepKey="amOnCartPriceList"/> - <waitForPageLoad stepKey="waitForPriceList"/> + <actionGroup ref="AdminOpenCartPriceRulesPageActionGroup" stepKey="amOnCartPriceList"/> <click selector="{{AdminCartPriceRulesSection.addNewRuleButton}}" stepKey="clickAddNewRule"/> <waitForPageLoad stepKey="waitForPageDiscountPageIsLoaded"/> <fillField selector="{{AdminCartPriceRulesFormSection.ruleName}}" userInput="ship" stepKey="fillRuleName"/> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/CreateProductAttributeEntityTest/CreateProductAttributeEntityWithReservedKeysTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/CreateProductAttributeEntityTest/CreateProductAttributeEntityWithReservedKeysTest.xml new file mode 100644 index 0000000000000..591454ad4421c --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/Test/CreateProductAttributeEntityTest/CreateProductAttributeEntityWithReservedKeysTest.xml @@ -0,0 +1,47 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="CreateProductAttributeEntityWithReservedKeysTest"> + <annotations> + <features value="Catalog"/> + <stories value="Create Product Attributes"/> + <title value="Attributess with reserved codes should not be created"/> + <description value="Admin should not be able to create product attribute with reserved codes"/> + <severity value="MINOR"/> + <testCaseId value="MC-37806"/> + <group value="catalog"/> + </annotations> + + <before> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + </before> + <after> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> + </after> + + <!--Navigate to Stores > Attributes > Product.--> + <amOnPage url="{{AdminProductAttributeGridPage.url}}" stepKey="goToProductAttributesGrid"/> + + <!--Create new Product Attribute as TextField, with type_id code.--> + <actionGroup ref="CreateProductAttributeActionGroup" stepKey="createAttribute"> + <argument name="attribute" value="ProductTypeIdAttribute"/> + </actionGroup> + <see stepKey="seeErrorMessage" selector="{{AdminMessagesSection.errorMessage}}" userInput="Code (type_id) is a reserved key and cannot be used as attribute code."/> + + <!--Navigate to Stores > Attributes > Product.--> + <amOnPage url="{{AdminProductAttributeGridPage.url}}" stepKey="backToProductAttributesGrid"/> + + <!--Create new Product Attribute as TextField, with product_type code.--> + <actionGroup ref="CreateProductAttributeActionGroup" stepKey="createAttribute2"> + <argument name="attribute" value="ProductProductTypeAttribute"/> + </actionGroup> + + <see stepKey="seeErrorMessage2" selector="{{AdminMessagesSection.errorMessage}}" userInput="Code (product_type) is a reserved key and cannot be used as attribute code."/> + </test> +</tests> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontCatalogNavigationMenuUIDesktopTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontCatalogNavigationMenuUIDesktopTest.xml index 2a59be6306a30..fb4bd4d1dcb74 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontCatalogNavigationMenuUIDesktopTest.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontCatalogNavigationMenuUIDesktopTest.xml @@ -40,8 +40,10 @@ <!-- Assert single row - no hover state --> <createData entity="ApiCategoryA" stepKey="createFirstCategoryBlank"/> - <reloadPage stepKey="refreshPage"/> - <waitForPageLoad stepKey="waitForBlankSingleRowAppear"/> + + <actionGroup ref="ReloadPageActionGroup" stepKey="refreshPage"/> + <comment userInput="Replacing reload action and preserve Backward Compatibility" stepKey="waitForBlankSingleRowAppear"/> + <moveMouseOver selector="{{StorefrontHeaderSection.NavigationCategoryByName($$createFirstCategoryBlank.name$$)}}" stepKey="hoverFirstCategoryBlank"/> <dontSeeElement selector="{{StorefrontNavigationMenuSection.subItemLevelHover('level0')}}" stepKey="assertNoHoverState"/> @@ -87,8 +89,9 @@ </createData> <!-- Several rows. Hover on category without children --> - <reloadPage stepKey="reloadPage"/> - <waitForPageLoad stepKey="waitForBlankSeveralRowsAppear"/> + <actionGroup ref="ReloadPageActionGroup" stepKey="reloadPage"/> + <comment userInput="Replacing reload action and preserve Backward Compatibility" stepKey="waitForBlankSeveralRowsAppear"/> + <moveMouseOver selector="{{StorefrontHeaderSection.NavigationCategoryByName($$createCategoryWithoutChildrenBlank.name$$)}}" stepKey="hoverCategoryWithoutChildren"/> <dontSeeElement selector="{{StorefrontNavigationMenuSection.itemByNameAndLevel($$createCategoryWithoutChildrenBlank.name$$, 'level0')}}" stepKey="dontSeeChildrenInCategory"/> @@ -167,8 +170,9 @@ <createData entity="ApiCategory" stepKey="createFourthCategoryLuma"/> <!-- Single row. No hover state --> - <reloadPage stepKey="reload"/> - <waitForPageLoad stepKey="waitForLumaSingleRowAppear"/> + <actionGroup ref="ReloadPageActionGroup" stepKey="reload"/> + <comment userInput="Replacing reload action and preserve Backward Compatibility" stepKey="waitForLumaSingleRowAppear"/> + <dontSeeElement selector="{{StorefrontNavigationMenuSection.itemByNameAndLevel($$createFirstCategoryLuma.name$$, 'level0')}}" stepKey="noHoverStateInFirstCategory"/> <dontSeeElement selector="{{StorefrontNavigationMenuSection.itemByNameAndLevel($$createSecondCategoryLuma.name$$, 'level0')}}" stepKey="noHoverStateInSecondCategory"/> <dontSeeElement selector="{{StorefrontNavigationMenuSection.itemByNameAndLevel($$createThirdCategoryLuma.name$$, 'level0')}}" stepKey="noHoverStateThirdCategory"/> @@ -203,8 +207,9 @@ <createData entity="ApiCategory" stepKey="createEighthCategoryLuma"/> <!-- Several rows. Hover on Category without children --> - <reloadPage stepKey="refresh"/> - <waitForPageLoad stepKey="waitForLumaSeveralRowsAppear"/> + <actionGroup ref="ReloadPageActionGroup" stepKey="refresh"/> + <comment userInput="Replacing reload action and preserve Backward Compatibility" stepKey="waitForLumaSeveralRowsAppear"/> + <moveMouseOver selector="{{StorefrontHeaderSection.NavigationCategoryByName($$createFifthCategoryLuma.name$$)}}" stepKey="hoverOnCategoryWithoutChildren"/> <dontSeeElement selector="{{StorefrontNavigationMenuSection.itemByNameAndLevel($$createFifthCategoryLuma.name$$, 'level0')}}" stepKey="dontSeeSubcategoriesInCategory"/> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/VerifyChildCategoriesShouldNotIncludeInMenuTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/VerifyChildCategoriesShouldNotIncludeInMenuTest.xml index 54a9e5a244427..59e3700acf5c3 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Test/VerifyChildCategoriesShouldNotIncludeInMenuTest.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Test/VerifyChildCategoriesShouldNotIncludeInMenuTest.xml @@ -18,63 +18,83 @@ <testCaseId value="MAGETWO-72238"/> <group value="category"/> </annotations> + <before> + <!-- Create a category --> + <createData entity="ApiCategory" stepKey="simpleCategory"/> + <!-- Create second category, having as parent the 1st one --> + <createData entity="SubCategoryWithParent" stepKey="simpleSubCategory"> + <requiredEntity createDataKey="simpleCategory"/> + </createData> + </before> <after> - <actionGroup ref="AdminOpenCategoryPageActionGroup" stepKey="navigateToCategoryPage2"/> - - <click selector="{{AdminCategorySidebarTreeSection.categoryInTree(SimpleSubCategory.name)}}" stepKey="clickOnCreatedSimpleSubCategoryBeforeDelete"/> - <actionGroup ref="DeleteCategoryActionGroup" stepKey="deleteCategory"> - <argument name="categoryEntity" value="SimpleSubCategory"/> - </actionGroup> + <deleteData createDataKey="simpleSubCategory" stepKey="deleteSubCategory"/> + <deleteData createDataKey="simpleCategory" stepKey="deleteCategory"/> <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> </after> - <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> - <actionGroup ref="AdminOpenCategoryPageActionGroup" stepKey="navigateToCategoryPage1"/> - <scrollToTopOfPage stepKey="scrollToTopOfPage"/> - <!--Create new category under Default Category--> - <actionGroup ref="CreateCategoryActionGroup" stepKey="createSubcategory1"> - <argument name="categoryEntity" value="SimpleSubCategory"/> - </actionGroup> - <!--Create another subcategory under created category--> - <actionGroup ref="CreateCategoryActionGroup" stepKey="createSubcategory2"> - <argument name="categoryEntity" value="SubCategoryWithParent"/> - </actionGroup> + <!--Go to storefront and verify visibility of categories--> <actionGroup ref="StorefrontOpenHomePageActionGroup" stepKey="goToStorefrontPage"/> - <seeElement selector="{{StorefrontHeaderSection.NavigationCategoryByName(SimpleSubCategory.name)}}" stepKey="seeSimpleSubCategoryOnStorefront1"/> - <dontSeeElement selector="{{StorefrontHeaderSection.NavigationCategoryByName(SubCategoryWithParent.name)}}" stepKey="dontSeeSubCategoryWithParentOnStorefront1"/> + <actionGroup ref="StorefrontAssertCategoryNameIsShownInMenuActionGroup" stepKey="seeCreatedCategoryOnStorefront"> + <argument name="categoryName" value="$$simpleCategory.name$$"/> + </actionGroup> + <actionGroup ref="StorefrontAssertCategoryNameIsNotShownInMenuActionGroup" stepKey="doNotSeeSubCategoryOnStorefront"> + <argument name="categoryName" value="$$simpleSubCategory.name$$"/> + </actionGroup> + <!--Set Include in menu to No on created category under Default Category --> - <actionGroup ref="AdminOpenCategoryPageActionGroup" stepKey="navigateToCategoryPage2"/> - <click selector="{{AdminCategorySidebarTreeSection.categoryInTree(SimpleSubCategory.name)}}" stepKey="clickOnCreatedSimpleSubCategory1"/> - <click selector="{{AdminCategoryBasicFieldSection.includeInMenuLabel}}" stepKey="setNoToIncludeInMenuSelect"/> - <click selector="{{AdminCategoryMainActionsSection.SaveButton}}" stepKey="clickSaveButton1"/> - <seeCheckboxIsChecked selector="{{AdminCategoryBasicFieldSection.EnableCategory}}" stepKey="seeCheckboxEnableCategoryIsChecked"/> - <dontSeeCheckboxIsChecked selector="{{AdminCategoryBasicFieldSection.IncludeInMenu}}" stepKey="dontSeeCheckboxIncludeInMenuIsChecked"/> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginToAdminPanel"/> + <actionGroup ref="NavigateToCreatedCategoryActionGroup" stepKey="openParentCategoryViaAdmin"> + <argument name="Category" value="$$simpleCategory$$"/> + </actionGroup> + <actionGroup ref="AdminDisableIncludeInMenuConfigActionGroup" stepKey="setNoToIncludeInMenuSelect"/> + <actionGroup ref="AdminSaveCategoryActionGroup" stepKey="saveCategory"/> + <actionGroup ref="AssertAdminCategoryIsEnabledActionGroup" stepKey="assertParentCategoryIsActive"/> + <actionGroup ref="AssertAdminCategoryIsNotIncludeInMenuActionGroup" stepKey="assertParentCategoryIsNotIncludeInMenu"/> + <!--Go to storefront and verify visibility of categories--> - <actionGroup ref="StorefrontOpenHomePageActionGroup" stepKey="goToStorefrontPage2"/> - <dontSeeElement selector="{{StorefrontHeaderSection.NavigationCategoryByName(SimpleSubCategory.name)}}" stepKey="dontSeeSimpleSubCategoryOnStorefront1"/> - <dontSeeElement selector="{{StorefrontHeaderSection.NavigationCategoryByName(SubCategoryWithParent.name)}}" stepKey="dontSeeSubCategoryWithParentOnStorefront2"/> + <actionGroup ref="StorefrontOpenHomePageActionGroup" stepKey="goToStorefrontPageSecondTime"/> + <actionGroup ref="StorefrontAssertCategoryNameIsNotShownInMenuActionGroup" stepKey="doNotSeeParentCategoryOnStorefront"> + <argument name="categoryName" value="$$simpleCategory.name$$"/> + </actionGroup> + <actionGroup ref="StorefrontAssertCategoryNameIsNotShownInMenuActionGroup" stepKey="doNotSeeSubCategory"> + <argument name="categoryName" value="$$simpleSubCategory.name$$"/> + </actionGroup> + <!--Set Enable category to No and Include in menu to Yes on created category under Default Category --> - <actionGroup ref="AdminOpenCategoryPageActionGroup" stepKey="navigateToCategoryPage3"/> - <click selector="{{AdminCategorySidebarTreeSection.categoryInTree(SimpleSubCategory.name)}}" stepKey="clickOnCreatedSimpleSubCategory2"/> - <click selector="{{AdminCategoryBasicFieldSection.enableCategoryLabel}}" stepKey="SetNoToEnableCategorySelect"/> - <click selector="{{AdminCategoryBasicFieldSection.includeInMenuLabel}}" stepKey="SetYesToIncludeInMenuSelect"/> - <click selector="{{AdminCategoryMainActionsSection.SaveButton}}" stepKey="clickSaveButton2"/> - <dontSeeCheckboxIsChecked selector="{{AdminCategoryBasicFieldSection.EnableCategory}}" stepKey="dontSeeCheckboxEnableCategoryIsChecked"/> - <seeCheckboxIsChecked selector="{{AdminCategoryBasicFieldSection.IncludeInMenu}}" stepKey="seeCheckboxIncludeInMenuIsChecked"/> + <actionGroup ref="NavigateToCreatedCategoryActionGroup" stepKey="openParentCategoryViaAdminSecondTime"> + <argument name="Category" value="$$simpleCategory$$"/> + </actionGroup> + <actionGroup ref="AdminDisableActiveCategoryActionGroup" stepKey="SetNoToEnableCategorySelect"/> + <actionGroup ref="AdminIncludeInMenuExcludedCategoryActionGroup" stepKey="SetToYesIncludeInMenuSelect"/> + <actionGroup ref="AdminSaveCategoryActionGroup" stepKey="saveParentCategory"/> + <actionGroup ref="AssertAdminCategoryIsInactiveActionGroup" stepKey="seeCategoryIsDisabled"/> + <actionGroup ref="AssertAdminCategoryIncludedToMenuActionGroup" stepKey="seeCheckboxIncludeInMenuIsChecked"/> + <!--Go to storefront and verify visibility of categories--> - <actionGroup ref="StorefrontOpenHomePageActionGroup" stepKey="goToStorefrontPage3"/> - <dontSeeElement selector="{{StorefrontHeaderSection.NavigationCategoryByName(SimpleSubCategory.name)}}" stepKey="dontSeeSimpleSubCategoryOnStorefront2"/> - <dontSeeElement selector="{{StorefrontHeaderSection.NavigationCategoryByName(SubCategoryWithParent.name)}}" stepKey="dontSeeSubCategoryWithParentOnStorefront3"/> + <actionGroup ref="StorefrontOpenHomePageActionGroup" stepKey="goToStorefrontPageThirdTime"/> + <actionGroup ref="StorefrontAssertCategoryNameIsNotShownInMenuActionGroup" stepKey="doNotSeeCategoryInMenuOnStorefront"> + <argument name="categoryName" value="$$simpleCategory.name$$"/> + </actionGroup> + <actionGroup ref="StorefrontAssertCategoryNameIsNotShownInMenuActionGroup" stepKey="doNotSeeSubCategoryInMenuOnStorefront"> + <argument name="categoryName" value="$$simpleSubCategory.name$$"/> + </actionGroup> + <!--Set Enable category to No and Include in menu to No on created category under Default Category --> - <actionGroup ref="AdminOpenCategoryPageActionGroup" stepKey="navigateToCategoryPage4"/> - <click selector="{{AdminCategorySidebarTreeSection.categoryInTree(SimpleSubCategory.name)}}" stepKey="clickOnCreatedSimpleSubCategory3"/> - <click selector="{{AdminCategoryBasicFieldSection.includeInMenuLabel}}" stepKey="setNoToIncludeInMenuSelect2"/> - <click selector="{{AdminCategoryMainActionsSection.SaveButton}}" stepKey="clickSaveButton3"/> - <dontSeeCheckboxIsChecked selector="{{AdminCategoryBasicFieldSection.EnableCategory}}" stepKey="dontSeeCheckboxEnableCategoryIsChecked2"/> - <dontSeeCheckboxIsChecked selector="{{AdminCategoryBasicFieldSection.IncludeInMenu}}" stepKey="dontSeeCheckboxIncludeInMenuIsChecked2"/> + <actionGroup ref="NavigateToCreatedCategoryActionGroup" stepKey="openParentCategoryViaAdminThirdTime"> + <argument name="Category" value="$$simpleCategory$$"/> + </actionGroup> + <actionGroup ref="AdminDisableIncludeInMenuConfigActionGroup" stepKey="setNoToIncludeInMenuForParentCategory"/> + <actionGroup ref="AdminSaveCategoryActionGroup" stepKey="saveChanges"/> + <actionGroup ref="AssertAdminCategoryIsInactiveActionGroup" stepKey="assertCategoryIsDisabled"/> + <actionGroup ref="AssertAdminCategoryIsNotIncludeInMenuActionGroup" stepKey="assertParentCategoryIsNotIncludeToMenu"/> + <!--Go to storefront and verify visibility of categories--> - <actionGroup ref="StorefrontOpenHomePageActionGroup" stepKey="goToStorefrontPage4"/> - <dontSeeElement selector="{{StorefrontHeaderSection.NavigationCategoryByName(SimpleSubCategory.name)}}" stepKey="dontSeeSimpleSubCategoryOnStorefront3"/> - <dontSeeElement selector="{{StorefrontHeaderSection.NavigationCategoryByName(SubCategoryWithParent.name)}}" stepKey="dontSeeSubCategoryWithParentOnStorefront4"/> + <actionGroup ref="StorefrontOpenHomePageActionGroup" stepKey="goToStorefrontPageFourthTime"/> + <actionGroup ref="StorefrontAssertCategoryNameIsNotShownInMenuActionGroup" stepKey="doNotSeeCategoryOnStorefront"> + <argument name="categoryName" value="$$simpleCategory.name$$"/> + </actionGroup> + <actionGroup ref="StorefrontAssertCategoryNameIsNotShownInMenuActionGroup" stepKey="doNotSeeSubCategoryInMenu"> + <argument name="categoryName" value="$$simpleSubCategory.name$$"/> + </actionGroup> </test> </tests> diff --git a/app/code/Magento/Catalog/Test/Unit/Block/Adminhtml/Product/Helper/Form/Gallery/ContentTest.php b/app/code/Magento/Catalog/Test/Unit/Block/Adminhtml/Product/Helper/Form/Gallery/ContentTest.php deleted file mode 100644 index 572dbc4ca2732..0000000000000 --- a/app/code/Magento/Catalog/Test/Unit/Block/Adminhtml/Product/Helper/Form/Gallery/ContentTest.php +++ /dev/null @@ -1,454 +0,0 @@ -<?php -/** - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. - */ -declare(strict_types=1); - -namespace Magento\Catalog\Test\Unit\Block\Adminhtml\Product\Helper\Form\Gallery; - -use Magento\Catalog\Block\Adminhtml\Product\Helper\Form\Gallery; -use Magento\Catalog\Block\Adminhtml\Product\Helper\Form\Gallery\Content; -use Magento\Catalog\Helper\Image; -use Magento\Catalog\Model\Entity\Attribute; -use Magento\Catalog\Model\Product; -use Magento\Catalog\Model\Product\Media\Config; -use Magento\Framework\Exception\FileSystemException; -use Magento\Framework\Filesystem; -use Magento\Framework\Filesystem\Directory\Read; -use Magento\Framework\Filesystem\Directory\ReadInterface; -use Magento\Framework\Json\EncoderInterface; -use Magento\Framework\Phrase; -use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; -use Magento\MediaStorage\Helper\File\Storage\Database; -use PHPUnit\Framework\MockObject\MockObject; -use PHPUnit\Framework\TestCase; - -/** - * @SuppressWarnings(PHPMD.CouplingBetweenObjects) - */ -class ContentTest extends TestCase -{ - /** - * @var Filesystem|MockObject - */ - protected $fileSystemMock; - - /** - * @var Read|MockObject - */ - protected $readMock; - - /** - * @var Content|MockObject - */ - protected $content; - - /** - * @var Config|MockObject - */ - protected $mediaConfigMock; - - /** - * @var EncoderInterface|MockObject - */ - protected $jsonEncoderMock; - - /** - * @var Gallery|MockObject - */ - protected $galleryMock; - - /** - * @var Image|MockObject - */ - protected $imageHelper; - - /** - * @var Database|MockObject - */ - protected $databaseMock; - - /** - * @var ObjectManager - */ - protected $objectManager; - - protected function setUp(): void - { - $this->fileSystemMock = $this->getMockBuilder(Filesystem::class) - ->addMethods(['stat']) - ->onlyMethods(['getDirectoryRead']) - ->disableOriginalConstructor() - ->getMock(); - $this->readMock = $this->getMockForAbstractClass(ReadInterface::class); - $this->galleryMock = $this->createMock(Gallery::class); - $this->mediaConfigMock = $this->createPartialMock( - Config::class, - ['getMediaUrl', 'getMediaPath'] - ); - $this->jsonEncoderMock = $this->getMockBuilder(EncoderInterface::class) - ->disableOriginalConstructor() - ->getMockForAbstractClass(); - - $this->databaseMock = $this->getMockBuilder(Database::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->objectManager = new ObjectManager($this); - $this->content = $this->objectManager->getObject( - Content::class, - [ - 'mediaConfig' => $this->mediaConfigMock, - 'jsonEncoder' => $this->jsonEncoderMock, - 'filesystem' => $this->fileSystemMock, - 'fileStorageDatabase' => $this->databaseMock - ] - ); - } - - public function testGetImagesJson() - { - $url = [ - ['file_1.jpg', 'url_to_the_image/image_1.jpg'], - ['file_2.jpg', 'url_to_the_image/image_2.jpg'] - ]; - $mediaPath = [ - ['file_1.jpg', 'catalog/product/image_1.jpg'], - ['file_2.jpg', 'catalog/product/image_2.jpg'] - ]; - - $sizeMap = [ - ['catalog/product/image_1.jpg', ['size' => 399659]], - ['catalog/product/image_2.jpg', ['size' => 879394]] - ]; - - $imagesResult = [ - [ - 'value_id' => '2', - 'file' => 'file_2.jpg', - 'media_type' => 'image', - 'position' => '0', - 'url' => 'url_to_the_image/image_2.jpg', - 'size' => 879394 - ], - [ - 'value_id' => '1', - 'file' => 'file_1.jpg', - 'media_type' => 'image', - 'position' => '1', - 'url' => 'url_to_the_image/image_1.jpg', - 'size' => 399659 - ] - ]; - - $images = [ - 'images' => [ - [ - 'value_id' => '1', - 'file' => 'file_1.jpg', - 'media_type' => 'image', - 'position' => '1' - ] , - [ - 'value_id' => '2', - 'file' => 'file_2.jpg', - 'media_type' => 'image', - 'position' => '0' - ] - ] - ]; - - $this->content->setElement($this->galleryMock); - $this->galleryMock->expects($this->once())->method('getImages')->willReturn($images); - $this->fileSystemMock->expects($this->once())->method('getDirectoryRead')->willReturn($this->readMock); - - $this->mediaConfigMock->method('getMediaUrl')->willReturnMap($url); - $this->mediaConfigMock->method('getMediaPath')->willReturnMap($mediaPath); - $this->readMock->method('stat')->willReturnMap($sizeMap); - $this->jsonEncoderMock->expects($this->once())->method('encode')->willReturnCallback('json_encode'); - - $this->readMock->method('isFile')->willReturn(true); - $this->databaseMock->method('checkDbUsage')->willReturn(false); - - $this->assertSame(json_encode($imagesResult), $this->content->getImagesJson()); - } - - public function testGetImagesJsonWithoutImages() - { - $this->content->setElement($this->galleryMock); - $this->galleryMock->expects($this->once())->method('getImages')->willReturn(null); - - $this->assertSame('[]', $this->content->getImagesJson()); - } - - public function testGetImagesJsonWithException() - { - $this->imageHelper = $this->getMockBuilder(Image::class) - ->disableOriginalConstructor() - ->setMethods(['getDefaultPlaceholderUrl']) - ->getMock(); - - $this->objectManager->setBackwardCompatibleProperty( - $this->content, - 'imageHelper', - $this->imageHelper - ); - - $placeholderUrl = 'url_to_the_placeholder/placeholder.jpg'; - - $imagesResult = [ - [ - 'value_id' => '2', - 'file' => 'file_2.jpg', - 'media_type' => 'image', - 'position' => '0', - 'url' => 'url_to_the_placeholder/placeholder.jpg', - 'size' => 0 - ], - [ - 'value_id' => '1', - 'file' => 'file_1.jpg', - 'media_type' => 'image', - 'position' => '1', - 'url' => 'url_to_the_placeholder/placeholder.jpg', - 'size' => 0 - ] - ]; - - $images = [ - 'images' => [ - [ - 'value_id' => '1', - 'file' => 'file_1.jpg', - 'media_type' => 'image', - 'position' => '1' - ], - [ - 'value_id' => '2', - 'file' => 'file_2.jpg', - 'media_type' => 'image', - 'position' => '0' - ] - ] - ]; - - $this->content->setElement($this->galleryMock); - $this->galleryMock->expects($this->once())->method('getImages')->willReturn($images); - $this->fileSystemMock->method('getDirectoryRead')->willReturn($this->readMock); - $this->mediaConfigMock->method('getMediaUrl'); - $this->mediaConfigMock->method('getMediaPath'); - - $this->readMock - ->method('isFile') - ->willReturn(true); - $this->databaseMock - ->method('checkDbUsage') - ->willReturn(false); - - $this->readMock->method('stat')->willReturnOnConsecutiveCalls( - $this->throwException( - new FileSystemException(new Phrase('test')) - ), - $this->throwException( - new FileSystemException(new Phrase('test')) - ) - ); - $this->imageHelper->method('getDefaultPlaceholderUrl')->willReturn($placeholderUrl); - $this->jsonEncoderMock->expects($this->once())->method('encode')->willReturnCallback('json_encode'); - - $this->assertSame(json_encode($imagesResult), $this->content->getImagesJson()); - } - - /** - * Test GetImageTypes() will return value for given attribute from data persistor. - * - * @return void - */ - public function testGetImageTypesFromDataPersistor() - { - $attributeCode = 'thumbnail'; - $value = 'testImageValue'; - $scopeLabel = 'testScopeLabel'; - $label = 'testLabel'; - $name = 'testName'; - $expectedTypes = [ - $attributeCode => [ - 'code' => $attributeCode, - 'value' => $value, - 'label' => $label, - 'name' => $name, - ], - ]; - $product = $this->getMockBuilder(Product::class) - ->disableOriginalConstructor() - ->getMock(); - $product->expects($this->once()) - ->method('getData') - ->with($this->identicalTo($attributeCode)) - ->willReturn(null); - $mediaAttribute = $this->getMediaAttribute($label, $attributeCode); - $product->expects($this->once()) - ->method('getMediaAttributes') - ->willReturn([$mediaAttribute]); - $this->galleryMock->expects($this->exactly(2)) - ->method('getDataObject') - ->willReturn($product); - $this->galleryMock->expects($this->once()) - ->method('getImageValue') - ->with($this->identicalTo($attributeCode)) - ->willReturn($value); - $this->galleryMock->expects($this->once()) - ->method('getScopeLabel') - ->with($this->identicalTo($mediaAttribute)) - ->willReturn($scopeLabel); - $this->galleryMock->expects($this->once()) - ->method('getAttributeFieldName') - ->with($this->identicalTo($mediaAttribute)) - ->willReturn($name); - $this->getImageTypesAssertions($attributeCode, $scopeLabel, $expectedTypes); - } - - /** - * Test GetImageTypes() will return value for given attribute from product. - * - * @return void - */ - public function testGetImageTypesFromProduct() - { - $attributeCode = 'thumbnail'; - $value = 'testImageValue'; - $scopeLabel = 'testScopeLabel'; - $label = 'testLabel'; - $name = 'testName'; - $expectedTypes = [ - $attributeCode => [ - 'code' => $attributeCode, - 'value' => $value, - 'label' => $label, - 'name' => $name, - ], - ]; - $product = $this->getMockBuilder(Product::class) - ->disableOriginalConstructor() - ->getMock(); - $product->expects($this->once()) - ->method('getData') - ->with($this->identicalTo($attributeCode)) - ->willReturn($value); - $mediaAttribute = $this->getMediaAttribute($label, $attributeCode); - $product->expects($this->once()) - ->method('getMediaAttributes') - ->willReturn([$mediaAttribute]); - $this->galleryMock->expects($this->exactly(2)) - ->method('getDataObject') - ->willReturn($product); - $this->galleryMock->expects($this->never()) - ->method('getImageValue'); - $this->galleryMock->expects($this->once()) - ->method('getScopeLabel') - ->with($this->identicalTo($mediaAttribute)) - ->willReturn($scopeLabel); - $this->galleryMock->expects($this->once()) - ->method('getAttributeFieldName') - ->with($this->identicalTo($mediaAttribute)) - ->willReturn($name); - $this->getImageTypesAssertions($attributeCode, $scopeLabel, $expectedTypes); - } - - /** - * Perform assertions. - * - * @param string $attributeCode - * @param string $scopeLabel - * @param array $expectedTypes - * @return void - */ - private function getImageTypesAssertions(string $attributeCode, string $scopeLabel, array $expectedTypes) - { - $this->content->setElement($this->galleryMock); - $result = $this->content->getImageTypes(); - $scope = $result[$attributeCode]['scope']; - $this->assertSame($scopeLabel, $scope->getText()); - unset($result[$attributeCode]['scope']); - $this->assertSame($expectedTypes, $result); - } - - /** - * Get media attribute mock. - * - * @param string $label - * @param string $attributeCode - * @return MockObject - */ - private function getMediaAttribute(string $label, string $attributeCode) - { - $frontend = $this->getMockBuilder(Product\Attribute\Frontend\Image::class) - ->disableOriginalConstructor() - ->getMock(); - $frontend->expects($this->once()) - ->method('getLabel') - ->willReturn($label); - $mediaAttribute = $this->getMockBuilder(Attribute::class) - ->disableOriginalConstructor() - ->getMock(); - $mediaAttribute - ->method('getAttributeCode') - ->willReturn($attributeCode); - $mediaAttribute->expects($this->once()) - ->method('getFrontend') - ->willReturn($frontend); - - return $mediaAttribute; - } - - /** - * Test GetImagesJson() calls MediaStorage functions to obtain image from DB prior to stat call - * - * @return void - */ - public function testGetImagesJsonMediaStorageMode() - { - $images = [ - 'images' => [ - [ - 'value_id' => '0', - 'file' => 'file_1.jpg', - 'media_type' => 'image', - 'position' => '0' - ] - ] - ]; - - $mediaPath = [ - ['file_1.jpg', 'catalog/product/image_1.jpg'] - ]; - - $this->content->setElement($this->galleryMock); - - $this->galleryMock->expects($this->once()) - ->method('getImages') - ->willReturn($images); - $this->fileSystemMock->expects($this->once()) - ->method('getDirectoryRead') - ->willReturn($this->readMock); - $this->mediaConfigMock - ->method('getMediaPath') - ->willReturnMap($mediaPath); - - $this->readMock - ->method('isFile') - ->willReturn(false); - $this->databaseMock - ->method('checkDbUsage') - ->willReturn(true); - - $this->databaseMock->expects($this->once()) - ->method('saveFileToFilesystem') - ->with('catalog/product/image_1.jpg'); - - $this->readMock->method('stat')->willReturn(['size' => 123]); - - $this->content->getImagesJson(); - } -} diff --git a/app/code/Magento/Catalog/Test/Unit/Controller/Adminhtml/Product/Initialization/HelperTest.php b/app/code/Magento/Catalog/Test/Unit/Controller/Adminhtml/Product/Initialization/HelperTest.php index 521468cd82927..886b03e0f3c1e 100644 --- a/app/code/Magento/Catalog/Test/Unit/Controller/Adminhtml/Product/Initialization/HelperTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Controller/Adminhtml/Product/Initialization/HelperTest.php @@ -7,7 +7,10 @@ namespace Magento\Catalog\Test\Unit\Controller\Adminhtml\Product\Initialization; +use Magento\Catalog\Api\Data\CategoryLinkInterface; +use Magento\Catalog\Api\Data\CategoryLinkInterfaceFactory; use Magento\Catalog\Api\Data\ProductCustomOptionInterfaceFactory; +use Magento\Catalog\Api\Data\ProductExtensionInterface; use Magento\Catalog\Api\Data\ProductLinkInterfaceFactory; use Magento\Catalog\Api\Data\ProductLinkTypeInterface; use Magento\Catalog\Api\ProductRepositoryInterface as ProductRepository; @@ -125,17 +128,13 @@ protected function setUp(): void ->setMethods(['create']) ->disableOriginalConstructor() ->getMock(); - $this->productRepositoryMock = $this->getMockBuilder(ProductRepository::class) - ->disableOriginalConstructor() - ->getMock(); + $this->productRepositoryMock = $this->createMock(ProductRepository::class); $this->requestMock = $this->getMockBuilder(RequestInterface::class) ->setMethods(['getPost']) ->getMockForAbstractClass(); - $this->storeManagerMock = $this->getMockBuilder(StoreManagerInterface::class) - ->getMockForAbstractClass(); - $this->stockFilterMock = $this->getMockBuilder(StockDataFilter::class) - ->disableOriginalConstructor() - ->getMock(); + $this->storeManagerMock = $this->createMock(StoreManagerInterface::class); + $this->stockFilterMock = $this->createMock(StockDataFilter::class); + $this->productMock = $this->getMockBuilder(Product::class) ->setMethods( [ @@ -150,30 +149,34 @@ protected function setUp(): void ) ->disableOriginalConstructor() ->getMockForAbstractClass(); + $productExtensionAttributes = $this->getMockBuilder(ProductExtensionInterface::class) + ->setMethods(['getCategoryLinks', 'setCategoryLinks']) + ->getMockForAbstractClass(); + $this->productMock->setExtensionAttributes($productExtensionAttributes); + $this->customOptionFactoryMock = $this->getMockBuilder(ProductCustomOptionInterfaceFactory::class) ->disableOriginalConstructor() ->setMethods(['create']) ->getMock(); - $this->productLinksMock = $this->getMockBuilder(ProductLinks::class) - ->disableOriginalConstructor() - ->getMock(); - $this->linkTypeProviderMock = $this->getMockBuilder(LinkTypeProvider::class) - ->disableOriginalConstructor() - ->getMock(); + $this->productLinksMock = $this->createMock(ProductLinks::class); + $this->linkTypeProviderMock = $this->createMock(LinkTypeProvider::class); $this->productLinksMock->expects($this->any()) ->method('initializeLinks') ->willReturn($this->productMock); - $this->attributeFilterMock = $this->getMockBuilder(AttributeFilter::class) - ->setMethods(['prepareProductAttributes']) - ->disableOriginalConstructor() - ->getMock(); - $this->localeFormatMock = $this->getMockBuilder(Format::class) - ->setMethods(['getNumber']) - ->disableOriginalConstructor() - ->getMock(); + $this->attributeFilterMock = $this->createMock(AttributeFilter::class); + $this->localeFormatMock = $this->createMock(Format::class); $this->dateTimeFilterMock = $this->createMock(DateTime::class); + $categoryLinkFactoryMock = $this->getMockBuilder(CategoryLinkInterfaceFactory::class) + ->setMethods(['create']) + ->disableOriginalConstructor() + ->getMock(); + $categoryLinkFactoryMock->method('create') + ->willReturnCallback(function () { + return $this->createMock(CategoryLinkInterface::class); + }); + $this->helper = $this->objectManager->getObject( Helper::class, [ @@ -187,13 +190,12 @@ protected function setUp(): void 'linkTypeProvider' => $this->linkTypeProviderMock, 'attributeFilter' => $this->attributeFilterMock, 'localeFormat' => $this->localeFormatMock, - 'dateTimeFilter' => $this->dateTimeFilterMock + 'dateTimeFilter' => $this->dateTimeFilterMock, + 'categoryLinkFactory' => $categoryLinkFactoryMock, ] ); - $this->linkResolverMock = $this->getMockBuilder(Resolver::class) - ->disableOriginalConstructor() - ->getMock(); + $this->linkResolverMock = $this->createMock(Resolver::class); $helperReflection = new \ReflectionClass(get_class($this->helper)); $resolverProperty = $helperReflection->getProperty('linkResolver'); $resolverProperty->setAccessible(true); diff --git a/app/code/Magento/Catalog/Test/Unit/Helper/ImageTest.php b/app/code/Magento/Catalog/Test/Unit/Helper/ImageTest.php index c606b7537cc44..125fd287cd4ce 100644 --- a/app/code/Magento/Catalog/Test/Unit/Helper/ImageTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Helper/ImageTest.php @@ -8,6 +8,7 @@ namespace Magento\Catalog\Test\Unit\Helper; use Magento\Catalog\Helper\Image; +use Magento\Catalog\Model\Config\CatalogMediaConfig; use Magento\Catalog\Model\Product; use Magento\Catalog\Model\Product\ImageFactory as ProductImageFactory; use Magento\Catalog\Model\View\Asset\PlaceholderFactory; @@ -70,6 +71,11 @@ class ImageTest extends TestCase */ protected $placeholderFactory; + /** + * @var CatalogMediaConfig|MockObject + */ + private $catalogMediaConfigMock; + protected function setUp(): void { $this->mockContext(); @@ -90,12 +96,17 @@ protected function setUp(): void ->disableOriginalConstructor() ->getMock(); + $this->catalogMediaConfigMock = $this->createPartialMock(CatalogMediaConfig::class, ['getMediaUrlFormat']); + $this->catalogMediaConfigMock->method('getMediaUrlFormat')->willReturn(CatalogMediaConfig::HASH); + + $this->helper = new Image( $this->context, $this->imageFactory, $this->assetRepository, $this->viewConfig, - $this->placeholderFactory + $this->placeholderFactory, + $this->catalogMediaConfigMock ); } diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Attribute/Backend/DefaultBackendTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Attribute/Backend/DefaultBackendTest.php new file mode 100644 index 0000000000000..36ec38841b7cc --- /dev/null +++ b/app/code/Magento/Catalog/Test/Unit/Model/Attribute/Backend/DefaultBackendTest.php @@ -0,0 +1,111 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +declare(strict_types=1); + +namespace Magento\Catalog\Test\Unit\Model\Attribute\Backend; + +use Magento\Catalog\Model\AbstractModel; +use Magento\Catalog\Model\Attribute\Backend\DefaultBackend; +use Magento\Framework\DataObject; +use Magento\Framework\Validation\ValidationException; +use Magento\Framework\Validator\HTML\WYSIWYGValidatorInterface; +use PHPUnit\Framework\TestCase; +use Magento\Eav\Model\Entity\Attribute as BasicAttribute; +use Magento\Catalog\Model\ResourceModel\Eav\Attribute; +use Magento\Eav\Model\Entity\Attribute\Exception as AttributeException; + +class DefaultBackendTest extends TestCase +{ + /** + * Different cases for attribute validation. + * + * @return array + */ + public function getAttributeConfigurations(): array + { + return [ + 'basic-attribute' => [true, false, true, 'basic', 'value', false, true, false], + 'non-html-attribute' => [false, false, false, 'non-html', 'value', false, false, false], + 'empty-html-attribute' => [false, false, true, 'html', null, false, true, false], + 'invalid-html-attribute' => [false, false, false, 'html', 'value', false, true, true], + 'valid-html-attribute' => [false, true, false, 'html', 'value', false, true, false], + 'changed-invalid-html-attribute' => [false, false, true, 'html', 'value', true, true, true], + 'changed-valid-html-attribute' => [false, true, true, 'html', 'value', true, true, false] + ]; + } + + /** + * Test attribute validation. + * + * @param bool $isBasic + * @param bool $isValidated + * @param bool $isCatalogEntity + * @param string $code + * @param mixed $value + * @param bool $isChanged + * @param bool $isHtmlAttribute + * @param bool $exceptionThrown + * @dataProvider getAttributeConfigurations + */ + public function testValidate( + bool $isBasic, + bool $isValidated, + bool $isCatalogEntity, + string $code, + $value, + bool $isChanged, + bool $isHtmlAttribute, + bool $exceptionThrown + ): void { + if ($isBasic) { + $attributeMock = $this->createMock(BasicAttribute::class); + } else { + $attributeMock = $this->createMock(Attribute::class); + $attributeMock->expects($this->any()) + ->method('getIsHtmlAllowedOnFront') + ->willReturn($isHtmlAttribute); + } + $attributeMock->expects($this->any())->method('getAttributeCode')->willReturn($code); + + $validatorMock = $this->getMockForAbstractClass(WYSIWYGValidatorInterface::class); + if (!$isValidated) { + $validatorMock->expects($this->any()) + ->method('validate') + ->willThrowException(new ValidationException(__('HTML is invalid'))); + } else { + $validatorMock->expects($this->any())->method('validate'); + } + + if ($isCatalogEntity) { + $objectMock = $this->createMock(AbstractModel::class); + $objectMock->expects($this->any()) + ->method('getOrigData') + ->willReturn($isChanged ? $value .'-OLD' : $value); + } else { + $objectMock = $this->createMock(DataObject::class); + } + $objectMock->expects($this->any())->method('getData')->with($code)->willReturn($value); + + $model = new DefaultBackend($validatorMock); + $model->setAttribute($attributeMock); + + $actuallyThrownForSave = false; + try { + $model->beforeSave($objectMock); + } catch (AttributeException $exception) { + $actuallyThrownForSave = true; + } + $actuallyThrownForValidate = false; + try { + $model->validate($objectMock); + } catch (AttributeException $exception) { + $actuallyThrownForValidate = true; + } + $this->assertEquals($actuallyThrownForSave, $actuallyThrownForValidate); + $this->assertEquals($actuallyThrownForSave, $exceptionThrown); + } +} diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Category/Attribute/Backend/ImageTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Category/Attribute/Backend/ImageTest.php index 16771214026f0..23136e55a2307 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/Category/Attribute/Backend/ImageTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/Category/Attribute/Backend/ImageTest.php @@ -14,7 +14,9 @@ use Magento\Framework\DataObject; use Magento\Framework\Exception\FileSystemException; use Magento\Framework\Filesystem; +use Magento\Framework\Filesystem\Directory\ReadInterface; use Magento\Framework\Filesystem\Directory\WriteInterface; +use Magento\Framework\ObjectManagerInterface; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; use Magento\Store\Model\Store; use Magento\Store\Model\StoreManagerInterface; @@ -186,6 +188,7 @@ public function testBeforeSaveValueInvalid($value) */ public function testBeforeSaveAttributeFileName() { + $this->setupObjectManagerForCheckImageExist(false); $this->attribute->expects($this->once()) ->method('getName') ->willReturn('test_attribute'); @@ -253,11 +256,23 @@ public function testBeforeSaveAttributeFileNameOutsideOfCategoryDir() ); } + private function setupObjectManagerForCheckImageExist($return) + { + $objectManagerMock = $this->getMockForAbstractClass(ObjectManagerInterface::class); + $mockFileSystem = $this->createMock(Filesystem::class); + $mockRead = $this->createMock(ReadInterface::class); + $objectManagerMock->method($this->logicalOr('get', 'create'))->willReturn($mockFileSystem); + $mockFileSystem->method('getDirectoryRead')->willReturn($mockRead); + $mockRead->method('isExist')->willReturn($return); + \Magento\Framework\App\ObjectManager::setInstance($objectManagerMock); + } + /** * Test beforeSaveTemporaryAttribute. */ public function testBeforeSaveTemporaryAttribute() { + $this->setupObjectManagerForCheckImageExist(false); $this->attribute->expects($this->once()) ->method('getName') ->willReturn('test_attribute'); @@ -268,7 +283,7 @@ public function testBeforeSaveTemporaryAttribute() $this->storeMock->expects($this->once()) ->method('getBaseMediaDir') - ->willReturn('pub/media'); + ->willReturn('media'); $model = $this->setUpModelForTests(); $model->setAttribute($this->attribute); @@ -279,7 +294,9 @@ public function testBeforeSaveTemporaryAttribute() ->with(DirectoryList::MEDIA) ->willReturn($mediaDirectoryMock); - $this->imageUploader->expects($this->any())->method('moveFileFromTmp')->willReturn('test123.jpg'); + $mediaDirectoryMock->method('getAbsolutePath')->willReturn('/media/test123.jpg'); + + $this->imageUploader->method('moveFileFromTmp')->willReturn('test123.jpg'); $object = new DataObject( [ @@ -287,7 +304,7 @@ public function testBeforeSaveTemporaryAttribute() [ 'name' => 'test123.jpg', 'tmp_name' => 'abc123', - 'url' => 'http://www.example.com/pub/media/temp/test123.jpg' + 'url' => 'http://www.example.com/media/temp/test123.jpg' ], ], ] @@ -297,7 +314,7 @@ public function testBeforeSaveTemporaryAttribute() $this->assertEquals( [ - ['name' => '/pub/media/test123.jpg', 'tmp_name' => 'abc123', 'url' => '/pub/media/test123.jpg'], + ['name' => '/media/test123.jpg', 'tmp_name' => 'abc123', 'url' => '/media/test123.jpg'], ], $object->getData('_additional_data_test_attribute') ); @@ -418,6 +435,7 @@ public function testBeforeSaveWithoutAdditionalData($value) */ public function testBeforeSaveWithExceptions() { + $this->setupObjectManagerForCheckImageExist(false); $model = $this->setUpModelForTests(); $this->storeManagerInterfaceMock->expects($this->once()) diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Category/DataProviderTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Category/DataProviderTest.php index e2c37c904ee82..5b2334cd55f05 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/Category/DataProviderTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/Category/DataProviderTest.php @@ -20,6 +20,8 @@ use Magento\Eav\Model\Entity\Type; use Magento\Framework\App\RequestInterface; use Magento\Framework\AuthorizationInterface; +use Magento\Framework\Config\Data; +use Magento\Framework\Config\DataInterfaceFactory; use Magento\Framework\Registry; use Magento\Framework\Stdlib\ArrayUtils; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; @@ -70,6 +72,11 @@ class DataProviderTest extends TestCase */ private $categoryFactory; + /** + * @var DataInterfaceFactory|MockObject + */ + private $uiConfigFactory; + /** * @var Collection|MockObject */ @@ -151,6 +158,15 @@ protected function setUp(): void ->disableOriginalConstructor() ->getMock(); + $dataMock = $this->getMockBuilder(Data::class) + ->disableOriginalConstructor() + ->getMock(); + $this->uiConfigFactory = $this->getMockBuilder(DataInterfaceFactory::class) + ->disableOriginalConstructor() + ->getMock(); + $this->uiConfigFactory->method('create') + ->willReturn($dataMock); + $this->fileInfo = $this->getMockBuilder(FileInfo::class) ->disableOriginalConstructor() ->getMock(); @@ -198,6 +214,7 @@ private function getModel() 'eavConfig' => $this->eavConfig, 'request' => $this->request, 'categoryFactory' => $this->categoryFactory, + 'uiConfigFactory' => $this->uiConfigFactory, 'pool' => $this->modifierPool, 'auth' => $this->auth, 'arrayUtils' => $this->arrayUtils, diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Category/ImageTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Category/ImageTest.php index 42a3031ae27e0..676cf07912f1d 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/Category/ImageTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/Category/ImageTest.php @@ -84,8 +84,8 @@ public function getUrlDataProvider() ], [ 'testimage', - 'http://www.example.com/pub/media/', - 'http://www.example.com/pub/media/catalog/category/testimage' + 'http://www.example.com/media/', + 'http://www.example.com/media/catalog/category/testimage' ], [ 'testimage', @@ -94,8 +94,8 @@ public function getUrlDataProvider() ], [ '/pub/media/catalog/category/testimage', - 'http://www.example.com/pub/media/', - 'http://www.example.com/pub/media/catalog/category/testimage' + 'http://www.example.com/media/', + 'http://www.example.com/media/catalog/category/testimage' ], [ '/pub/media/catalog/category/testimage', @@ -104,8 +104,8 @@ public function getUrlDataProvider() ], [ '/pub/media/posters/testimage', - 'http://www.example.com/pub/media/', - 'http://www.example.com/pub/media/posters/testimage' + 'http://www.example.com/media/', + 'http://www.example.com/media/posters/testimage' ], [ '/pub/media/posters/testimage', diff --git a/app/code/Magento/Catalog/Test/Unit/Model/CategoryLinkManagementTest.php b/app/code/Magento/Catalog/Test/Unit/Model/CategoryLinkManagementTest.php index be79b11cdf2b8..7cb2064d34d20 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/CategoryLinkManagementTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/CategoryLinkManagementTest.php @@ -15,6 +15,7 @@ use Magento\Catalog\Model\CategoryRepository; use Magento\Catalog\Model\ResourceModel\Product; use Magento\Catalog\Model\ResourceModel\Product\Collection; +use Magento\Framework\DataObject; use Magento\Framework\Indexer\IndexerRegistry; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -85,7 +86,11 @@ public function testGetAssignedProducts() $categoryMock->expects($this->once())->method('getProductCollection')->willReturn($productsMock); $categoryMock->expects($this->once())->method('getId')->willReturn($categoryId); $productsMock->expects($this->once())->method('addFieldToSelect')->with('position')->willReturnSelf(); + $productsMock->expects($this->once())->method('groupByAttribute')->with('entity_id')->willReturnSelf(); $productsMock->expects($this->once())->method('getItems')->willReturn($items); + $productsMock->expects($this->once()) + ->method('getProductEntityMetadata') + ->willReturn(new DataObject(['identifier_field' => 'entity_id'])); $this->productLinkFactoryMock->expects($this->once())->method('create')->willReturn($categoryProductLinkMock); $categoryProductLinkMock->expects($this->once()) ->method('setSku') diff --git a/app/code/Magento/Catalog/Test/Unit/Model/CategoryRepositoryTest.php b/app/code/Magento/Catalog/Test/Unit/Model/CategoryRepositoryTest.php index 900f630a7434d..8274ed9da5f32 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/CategoryRepositoryTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/CategoryRepositoryTest.php @@ -11,6 +11,7 @@ use Magento\Catalog\Model\Category as CategoryModel; use Magento\Catalog\Model\CategoryFactory; use Magento\Catalog\Model\CategoryRepository; +use Magento\Catalog\Model\CategoryRepository\PopulateWithValues; use Magento\Framework\Api\ExtensibleDataObjectConverter; use Magento\Framework\DataObject; use Magento\Framework\EntityManager\EntityMetadata; @@ -63,6 +64,14 @@ class CategoryRepositoryTest extends TestCase */ protected $metadataPoolMock; + /** + * @var PopulateWithValues|MockObject + */ + private $populateWithValuesMock; + + /** + * @inheridoc + */ protected function setUp(): void { $this->categoryFactoryMock = $this->createPartialMock( @@ -94,6 +103,12 @@ protected function setUp(): void ->with(CategoryInterface::class) ->willReturn($metadataMock); + $this->populateWithValuesMock = $this + ->getMockBuilder(PopulateWithValues::class) + ->onlyMethods(['execute']) + ->disableOriginalConstructor() + ->getMock(); + $this->model = (new ObjectManager($this))->getObject( CategoryRepository::class, [ @@ -102,6 +117,7 @@ protected function setUp(): void 'storeManager' => $this->storeManagerMock, 'metadataPool' => $this->metadataPoolMock, 'extensibleDataObjectConverter' => $this->extensibleDataObjectConverterMock, + 'populateWithValues' => $this->populateWithValuesMock, ] ); } @@ -202,7 +218,7 @@ public function testFilterExtraFieldsOnUpdateCategory($categoryId, $categoryData ->method('toNestedArray') ->willReturn($categoryData); $categoryMock->expects($this->once())->method('validate')->willReturn(true); - $categoryMock->expects($this->once())->method('addData')->with($dataForSave); + $this->populateWithValuesMock->expects($this->once())->method('execute')->with($categoryMock, $dataForSave); $this->categoryResourceMock->expects($this->once()) ->method('save') ->willReturn(DataObject::class); @@ -230,11 +246,11 @@ public function testCreateNewCategory() $categoryMock->expects($this->once())->method('getParentId')->willReturn($parentCategoryId); $parentCategoryMock->expects($this->once())->method('getPath')->willReturn('path'); - $categoryMock->expects($this->once())->method('addData')->with($dataForSave); $categoryMock->expects($this->once())->method('validate')->willReturn(true); $this->categoryResourceMock->expects($this->once()) ->method('save') ->willReturn(DataObject::class); + $this->populateWithValuesMock->expects($this->once())->method('execute')->with($categoryMock, $dataForSave); $this->assertEquals($categoryMock, $this->model->save($categoryMock)); } diff --git a/app/code/Magento/Catalog/Test/Unit/Model/ImageUploaderTest.php b/app/code/Magento/Catalog/Test/Unit/Model/ImageUploaderTest.php deleted file mode 100644 index 93bb85abced75..0000000000000 --- a/app/code/Magento/Catalog/Test/Unit/Model/ImageUploaderTest.php +++ /dev/null @@ -1,168 +0,0 @@ -<?php -/** - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. - */ -declare(strict_types=1); - -namespace Magento\Catalog\Test\Unit\Model; - -use Magento\Catalog\Model\ImageUploader; -use Magento\Framework\Filesystem; -use Magento\Framework\Filesystem\Directory\WriteInterface; -use Magento\MediaStorage\Helper\File\Storage\Database; -use Magento\MediaStorage\Model\File\Uploader; -use Magento\MediaStorage\Model\File\UploaderFactory; -use Magento\Store\Model\Store; -use Magento\Store\Model\StoreManagerInterface; -use PHPUnit\Framework\MockObject\MockObject; -use PHPUnit\Framework\TestCase; -use Psr\Log\LoggerInterface; - -class ImageUploaderTest extends TestCase -{ - /** - * @var ImageUploader - */ - private $imageUploader; - - /** - * Core file storage database - * - * @var Database|MockObject - */ - private $coreFileStorageDatabaseMock; - - /** - * Media directory object (writable). - * - * @var Filesystem|MockObject - */ - private $mediaDirectoryMock; - - /** - * Media directory object (writable). - * - * @var WriteInterface|MockObject - */ - private $mediaWriteDirectoryMock; - - /** - * Uploader factory - * - * @var UploaderFactory|MockObject - */ - private $uploaderFactoryMock; - - /** - * Store manager - * - * @var StoreManagerInterface|MockObject - */ - private $storeManagerMock; - - /** - * @var LoggerInterface|MockObject - */ - private $loggerMock; - - /** - * Base tmp path - * - * @var string - */ - private $baseTmpPath; - - /** - * Base path - * - * @var string - */ - private $basePath; - - /** - * Allowed extensions - * - * @var array - */ - private $allowedExtensions; - - /** - * Allowed mime types - * - * @var array - */ - private $allowedMimeTypes; - - protected function setUp(): void - { - $this->coreFileStorageDatabaseMock = $this->createMock( - Database::class - ); - $this->mediaDirectoryMock = $this->createMock( - Filesystem::class - ); - $this->mediaWriteDirectoryMock = $this->createMock( - WriteInterface::class - ); - $this->mediaDirectoryMock->expects($this->any())->method('getDirectoryWrite')->willReturn( - $this->mediaWriteDirectoryMock - ); - $this->uploaderFactoryMock = $this->createMock( - UploaderFactory::class - ); - $this->storeManagerMock = $this->createMock( - StoreManagerInterface::class - ); - $this->loggerMock = $this->getMockForAbstractClass(LoggerInterface::class); - $this->baseTmpPath = 'base/tmp/'; - $this->basePath = 'base/real/'; - $this->allowedExtensions = ['.jpg']; - $this->allowedMimeTypes = ['image/jpg', 'image/jpeg', 'image/gif', 'image/png']; - - $this->imageUploader = - new ImageUploader( - $this->coreFileStorageDatabaseMock, - $this->mediaDirectoryMock, - $this->uploaderFactoryMock, - $this->storeManagerMock, - $this->loggerMock, - $this->baseTmpPath, - $this->basePath, - $this->allowedExtensions, - $this->allowedMimeTypes - ); - } - - public function testSaveFileToTmpDir() - { - $fileId = 'file.jpg'; - $allowedMimeTypes = [ - 'image/jpg', - 'image/jpeg', - 'image/gif', - 'image/png', - ]; - /** @var \Magento\MediaStorage\Model\File\Uploader|MockObject $uploader */ - $uploader = $this->createMock(Uploader::class); - $this->uploaderFactoryMock->expects($this->once())->method('create')->willReturn($uploader); - $uploader->expects($this->once())->method('setAllowedExtensions')->with($this->allowedExtensions); - $uploader->expects($this->once())->method('setAllowRenameFiles')->with(true); - $this->mediaWriteDirectoryMock->expects($this->once())->method('getAbsolutePath')->with($this->baseTmpPath) - ->willReturn($this->basePath); - $uploader->expects($this->once())->method('save')->with($this->basePath) - ->willReturn(['tmp_name' => $this->baseTmpPath, 'file' => $fileId, 'path' => $this->basePath]); - $uploader->expects($this->atLeastOnce())->method('checkMimeType')->with($allowedMimeTypes)->willReturn(true); - $storeMock = $this->createPartialMock( - Store::class, - ['getBaseUrl'] - ); - $this->storeManagerMock->expects($this->once())->method('getStore')->willReturn($storeMock); - $storeMock->expects($this->once())->method('getBaseUrl'); - $this->coreFileStorageDatabaseMock->expects($this->once())->method('saveFile'); - - $result = $this->imageUploader->saveFileToTmpDir($fileId); - - $this->assertArrayNotHasKey('path', $result); - } -} diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Category/Product/Action/RowsTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Category/Product/Action/RowsTest.php new file mode 100644 index 0000000000000..f53b05a88c54f --- /dev/null +++ b/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Category/Product/Action/RowsTest.php @@ -0,0 +1,260 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Catalog\Test\Unit\Model\Indexer\Category\Product\Action; + +use Magento\Eav\Model\Entity\Attribute\AbstractAttribute; +use Magento\Store\Model\StoreManagerInterface; +use Magento\Store\Model\Store; +use Magento\Catalog\Model\Config; +use Magento\Catalog\Model\Indexer\Category\Product\Action\Rows; +use Magento\Catalog\Model\Indexer\Product\Category as ProductCategoryIndexer; +use Magento\Catalog\Model\Indexer\Category\Product as CategoryProductIndexer; +use Magento\Catalog\Model\Indexer\Category\Product\TableMaintainer; +use Magento\Indexer\Model\WorkingStateProvider; +use Magento\Framework\EntityManager\EntityMetadataInterface; +use Magento\Framework\App\ResourceConnection; +use Magento\Framework\DB\Query\Generator as QueryGenerator; +use Magento\Framework\DB\Adapter\AdapterInterface; +use Magento\Framework\DB\Select; +use Magento\Framework\DB\Ddl\Table; +use Magento\Framework\EntityManager\MetadataPool; +use Magento\Framework\Event\ManagerInterface as EventManagerInterface; +use Magento\Framework\Indexer\IndexerRegistry; +use Magento\Framework\Indexer\CacheContext; +use Magento\Framework\Indexer\IndexerInterface; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * Test for Rows action + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) to preserve compatibility with tested class + */ +class RowsTest extends TestCase +{ + /** + * @var WorkingStateProvider|MockObject + */ + private $workingStateProvider; + + /** + * @var ResourceConnection|MockObject + */ + private $resource; + + /** + * @var StoreManagerInterface|MockObject + */ + private $storeManager; + + /** + * @var Config|MockObject + */ + private $config; + + /** + * @var QueryGenerator|MockObject + */ + private $queryGenerator; + + /** + * @var MetadataPool|MockObject + */ + private $metadataPool; + + /** + * @var CacheContext|MockObject + */ + private $cacheContext; + + /** + * @var EventManagerInterface|MockObject + */ + private $eventManager; + + /** + * @var IndexerRegistry|MockObject + */ + private $indexerRegistry; + + /** + * @var TableMaintainer|MockObject + */ + private $tableMaintainer; + + /** + * @var IndexerInterface|MockObject + */ + private $indexer; + + /** + * @var AdapterInterface|MockObject + */ + private $connection; + + /** + * @var Select|MockObject + */ + private $select; + + /** + * @var Rows + */ + private $rowsModel; + + protected function setUp() : void + { + $this->workingStateProvider = $this->getMockBuilder(WorkingStateProvider::class) + ->disableOriginalConstructor() + ->getMock(); + $this->resource = $this->getMockBuilder(ResourceConnection::class) + ->disableOriginalConstructor() + ->getMock(); + $this->connection = $this->getMockBuilder(AdapterInterface::class) + ->getMockForAbstractClass(); + $this->resource->expects($this->any()) + ->method('getConnection') + ->willReturn($this->connection); + $this->select = $this->getMockBuilder(Select::class) + ->disableOriginalConstructor() + ->getMock(); + $this->select->expects($this->any()) + ->method('from') + ->willReturnSelf(); + $this->select->expects($this->any()) + ->method('where') + ->willReturnSelf(); + $this->select->expects($this->any()) + ->method('joinInner') + ->willReturnSelf(); + $this->select->expects($this->any()) + ->method('joinLeft') + ->willReturnSelf(); + $this->select->expects($this->any()) + ->method('columns') + ->willReturnSelf(); + $this->connection->expects($this->any()) + ->method('select') + ->willReturn($this->select); + $this->storeManager = $this->getMockBuilder(StoreManagerInterface::class) + ->getMockForAbstractClass(); + $this->config = $this->getMockBuilder(Config::class) + ->disableOriginalConstructor() + ->getMock(); + $this->queryGenerator = $this->getMockBuilder(QueryGenerator::class) + ->disableOriginalConstructor() + ->getMock(); + $this->metadataPool = $this->getMockBuilder(MetadataPool::class) + ->disableOriginalConstructor() + ->getMock(); + $this->cacheContext = $this->getMockBuilder(CacheContext::class) + ->disableOriginalConstructor() + ->getMock(); + $this->eventManager = $this->getMockBuilder(EventManagerInterface::class) + ->getMockForAbstractClass(); + $this->indexerRegistry = $this->getMockBuilder(IndexerRegistry::class) + ->disableOriginalConstructor() + ->getMock(); + $this->indexer = $this->getMockBuilder(IndexerInterface::class) + ->getMockForAbstractClass(); + $this->tableMaintainer = $this->getMockBuilder(TableMaintainer::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->rowsModel = new Rows( + $this->resource, + $this->storeManager, + $this->config, + $this->queryGenerator, + $this->metadataPool, + $this->tableMaintainer, + $this->cacheContext, + $this->eventManager, + $this->indexerRegistry, + $this->workingStateProvider + ); + } + + public function testExecuteWithIndexerWorking() : void + { + $categoryId = '1'; + $store = $this->getMockBuilder(Store::class) + ->disableOriginalConstructor() + ->getMock(); + $store->expects($this->any()) + ->method('getRootCategoryId') + ->willReturn($categoryId); + $store->expects($this->any()) + ->method('getId') + ->willReturn(1); + + $attribute = $this->getMockBuilder(AbstractAttribute::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $this->config->expects($this->any()) + ->method('getAttribute') + ->willReturn($attribute); + + $table = $this->getMockBuilder(Table::class) + ->disableOriginalConstructor() + ->getMock(); + $this->connection->expects($this->any()) + ->method('newTable') + ->willReturn($table); + + $metadata = $this->getMockBuilder(EntityMetadataInterface::class) + ->getMockForAbstractClass(); + $this->metadataPool->expects($this->any()) + ->method('getMetadata') + ->willReturn($metadata); + + $this->connection->expects($this->any()) + ->method('fetchAll') + ->willReturn([]); + + $this->connection->expects($this->any()) + ->method('fetchOne') + ->willReturn($categoryId); + $this->indexerRegistry->expects($this->at(0)) + ->method('get') + ->with(ProductCategoryIndexer::INDEXER_ID) + ->willReturn($this->indexer); + $this->indexerRegistry->expects($this->at(1)) + ->method('get') + ->with(ProductCategoryIndexer::INDEXER_ID) + ->willReturn($this->indexer); + $this->indexerRegistry->expects($this->at(2)) + ->method('get') + ->with(CategoryProductIndexer::INDEXER_ID) + ->willReturn($this->indexer); + $this->indexerRegistry->expects($this->at(3)) + ->method('get') + ->with(ProductCategoryIndexer::INDEXER_ID) + ->willReturn($this->indexer); + $this->indexerRegistry->expects($this->at(4)) + ->method('get') + ->with(CategoryProductIndexer::INDEXER_ID) + ->willReturn($this->indexer); + $this->indexer->expects($this->any()) + ->method('getId') + ->willReturn(ProductCategoryIndexer::INDEXER_ID); + $this->workingStateProvider->expects($this->any()) + ->method('isWorking') + ->with(ProductCategoryIndexer::INDEXER_ID) + ->willReturn(true); + $this->storeManager->expects($this->any()) + ->method('getStores') + ->willReturn([$store]); + + $this->connection->expects($this->once()) + ->method('delete'); + + $result = $this->rowsModel->execute([1, 2, 3]); + $this->assertInstanceOf(Rows::class, $result); + } +} diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Category/Product/Plugin/TableResolverTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Category/Product/Plugin/TableResolverTest.php new file mode 100644 index 0000000000000..c5018f1aa6313 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Category/Product/Plugin/TableResolverTest.php @@ -0,0 +1,77 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Catalog\Test\Unit\Model\Indexer\Category\Product\Plugin; + +use Magento\Catalog\Model\Indexer\Category\Product\Plugin\TableResolver; +use Magento\Framework\App\ResourceConnection; +use Magento\Framework\Indexer\ScopeResolver\IndexScopeResolver; +use Magento\Store\Model\Store; +use Magento\Store\Model\StoreManagerInterface; +use PHPUnit\Framework\TestCase; + +class TableResolverTest extends TestCase +{ + /** + * Tests replacing catalog_category_product_index table name + * + * @param int $storeId + * @param string $tableName + * @param string $expected + * @dataProvider afterGetTableNameDataProvider + */ + public function testAfterGetTableName(int $storeId, string $tableName, string $expected): void + { + $storeManagerMock = $this->getMockForAbstractClass(StoreManagerInterface::class); + + $storeMock = $this->getMockBuilder(Store::class) + ->onlyMethods(['getId']) + ->disableOriginalConstructor() + ->getMock(); + $storeMock->method('getId') + ->willReturn($storeId); + + $storeManagerMock->method('getStore')->willReturn($storeMock); + + $tableResolverMock = $this->getMockBuilder(IndexScopeResolver::class) + ->disableOriginalConstructor() + ->getMock(); + $tableResolverMock->method('resolve')->willReturn('catalog_category_product_index_store1'); + + $subjectMock = $this->getMockBuilder(ResourceConnection::class) + ->disableOriginalConstructor() + ->getMock(); + + $model = new TableResolver($storeManagerMock, $tableResolverMock); + + $this->assertEquals( + $expected, + $model->afterGetTableName($subjectMock, $tableName, 'catalog_category_product_index') + ); + } + + /** + * Data provider for testAfterGetTableName + * + * @return array + */ + public function afterGetTableNameDataProvider(): array + { + return [ + [ + 'storeId' => 1, + 'tableName' => 'catalog_category_product_index', + 'expected' => 'catalog_category_product_index_store1' + ], + [ + 'storeId' => 0, + 'tableName' => 'catalog_category_product_index', + 'expected' => 'catalog_category_product_index' + ], + ]; + } +} diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Product/Category/Action/RowsTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Product/Category/Action/RowsTest.php new file mode 100644 index 0000000000000..66eb058c7b0a4 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Product/Category/Action/RowsTest.php @@ -0,0 +1,269 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Catalog\Test\Unit\Model\Indexer\Product\Category\Action; + +use Magento\Eav\Model\Entity\Attribute\AbstractAttribute; +use Magento\Store\Model\StoreManagerInterface; +use Magento\Store\Model\Store; +use Magento\Catalog\Model\Config; +use Magento\Catalog\Model\Indexer\Product\Category\Action\Rows; +use Magento\Catalog\Model\Indexer\Product\Category as ProductCategoryIndexer; +use Magento\Catalog\Model\Indexer\Category\Product as CategoryProductIndexer; +use Magento\Catalog\Model\Indexer\Category\Product\TableMaintainer; +use Magento\Indexer\Model\WorkingStateProvider; +use Magento\Framework\EntityManager\EntityMetadataInterface; +use Magento\Framework\App\ResourceConnection; +use Magento\Framework\DB\Query\Generator as QueryGenerator; +use Magento\Framework\DB\Adapter\AdapterInterface; +use Magento\Framework\DB\Select; +use Magento\Framework\DB\Ddl\Table; +use Magento\Framework\EntityManager\MetadataPool; +use Magento\Framework\Event\ManagerInterface as EventManagerInterface; +use Magento\Framework\Indexer\IndexerRegistry; +use Magento\Framework\Indexer\CacheContext; +use Magento\Framework\Indexer\IndexerInterface; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * Test for Rows action + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) to preserve compatibility with tested class + */ +class RowsTest extends TestCase +{ + /** + * @var WorkingStateProvider|MockObject + */ + private $workingStateProvider; + + /** + * @var ResourceConnection|MockObject + */ + private $resource; + + /** + * @var StoreManagerInterface|MockObject + */ + private $storeManager; + + /** + * @var Config|MockObject + */ + private $config; + + /** + * @var QueryGenerator|MockObject + */ + private $queryGenerator; + + /** + * @var MetadataPool|MockObject + */ + private $metadataPool; + + /** + * @var CacheContext|MockObject + */ + private $cacheContext; + + /** + * @var EventManagerInterface|MockObject + */ + private $eventManager; + + /** + * @var IndexerRegistry|MockObject + */ + private $indexerRegistry; + + /** + * @var TableMaintainer|MockObject + */ + private $tableMaintainer; + + /** + * @var IndexerInterface|MockObject + */ + private $indexer; + + /** + * @var AdapterInterface|MockObject + */ + private $connection; + + /** + * @var Select|MockObject + */ + private $select; + + /** + * @var Rows + */ + private $rowsModel; + + protected function setUp() : void + { + $this->workingStateProvider = $this->getMockBuilder(WorkingStateProvider::class) + ->disableOriginalConstructor() + ->getMock(); + $this->resource = $this->getMockBuilder(ResourceConnection::class) + ->disableOriginalConstructor() + ->getMock(); + $this->connection = $this->getMockBuilder(AdapterInterface::class) + ->getMockForAbstractClass(); + $this->resource->expects($this->any()) + ->method('getConnection') + ->willReturn($this->connection); + $this->select = $this->getMockBuilder(Select::class) + ->disableOriginalConstructor() + ->getMock(); + $this->select->expects($this->any()) + ->method('from') + ->willReturnSelf(); + $this->select->expects($this->any()) + ->method('where') + ->willReturnSelf(); + $this->select->expects($this->any()) + ->method('distinct') + ->willReturnSelf(); + $this->select->expects($this->any()) + ->method('joinInner') + ->willReturnSelf(); + $this->select->expects($this->any()) + ->method('group') + ->willReturnSelf(); + $this->select->expects($this->any()) + ->method('joinLeft') + ->willReturnSelf(); + $this->select->expects($this->any()) + ->method('columns') + ->willReturnSelf(); + $this->connection->expects($this->any()) + ->method('select') + ->willReturn($this->select); + $this->storeManager = $this->getMockBuilder(StoreManagerInterface::class) + ->getMockForAbstractClass(); + $this->config = $this->getMockBuilder(Config::class) + ->disableOriginalConstructor() + ->getMock(); + $this->queryGenerator = $this->getMockBuilder(QueryGenerator::class) + ->disableOriginalConstructor() + ->getMock(); + $this->metadataPool = $this->getMockBuilder(MetadataPool::class) + ->disableOriginalConstructor() + ->getMock(); + $this->cacheContext = $this->getMockBuilder(CacheContext::class) + ->disableOriginalConstructor() + ->getMock(); + $this->eventManager = $this->getMockBuilder(EventManagerInterface::class) + ->getMockForAbstractClass(); + $this->indexerRegistry = $this->getMockBuilder(IndexerRegistry::class) + ->disableOriginalConstructor() + ->getMock(); + $this->indexer = $this->getMockBuilder(IndexerInterface::class) + ->getMockForAbstractClass(); + $this->tableMaintainer = $this->getMockBuilder(TableMaintainer::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->rowsModel = new Rows( + $this->resource, + $this->storeManager, + $this->config, + $this->queryGenerator, + $this->metadataPool, + $this->tableMaintainer, + $this->cacheContext, + $this->eventManager, + $this->indexerRegistry, + $this->workingStateProvider + ); + } + + public function testExecuteWithIndexerWorking() : void + { + $categoryId = '1'; + $store = $this->getMockBuilder(Store::class) + ->disableOriginalConstructor() + ->getMock(); + $store->expects($this->any()) + ->method('getRootCategoryId') + ->willReturn($categoryId); + $store->expects($this->any()) + ->method('getId') + ->willReturn(1); + + $attribute = $this->getMockBuilder(AbstractAttribute::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $this->config->expects($this->any()) + ->method('getAttribute') + ->willReturn($attribute); + + $table = $this->getMockBuilder(Table::class) + ->disableOriginalConstructor() + ->getMock(); + $this->connection->expects($this->any()) + ->method('newTable') + ->willReturn($table); + + $metadata = $this->getMockBuilder(EntityMetadataInterface::class) + ->getMockForAbstractClass(); + $this->metadataPool->expects($this->any()) + ->method('getMetadata') + ->willReturn($metadata); + + $this->connection->expects($this->any()) + ->method('fetchAll') + ->willReturn([]); + $this->connection->expects($this->any()) + ->method('fetchCol') + ->willReturn([]); + + $this->connection->expects($this->any()) + ->method('fetchOne') + ->willReturn($categoryId); + $this->indexerRegistry->expects($this->at(0)) + ->method('get') + ->with(CategoryProductIndexer::INDEXER_ID) + ->willReturn($this->indexer); + $this->indexerRegistry->expects($this->at(1)) + ->method('get') + ->with(CategoryProductIndexer::INDEXER_ID) + ->willReturn($this->indexer); + $this->indexerRegistry->expects($this->at(2)) + ->method('get') + ->with(ProductCategoryIndexer::INDEXER_ID) + ->willReturn($this->indexer); + $this->indexerRegistry->expects($this->at(3)) + ->method('get') + ->with(CategoryProductIndexer::INDEXER_ID) + ->willReturn($this->indexer); + $this->indexerRegistry->expects($this->at(4)) + ->method('get') + ->with(ProductCategoryIndexer::INDEXER_ID) + ->willReturn($this->indexer); + $this->indexer->expects($this->any()) + ->method('getId') + ->willReturn(CategoryProductIndexer::INDEXER_ID); + $this->workingStateProvider->expects($this->any()) + ->method('isWorking') + ->with(CategoryProductIndexer::INDEXER_ID) + ->willReturn(true); + $this->storeManager->expects($this->any()) + ->method('getStores') + ->willReturn([$store]); + + $this->connection->expects($this->once()) + ->method('delete'); + + $result = $this->rowsModel->execute([1, 2, 3]); + $this->assertInstanceOf(Rows::class, $result); + } +} diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Product/Price/Action/RowsTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Product/Price/Action/RowsTest.php index 4242ab7b2e914..816dc923ebc0a 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Product/Price/Action/RowsTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Product/Price/Action/RowsTest.php @@ -7,27 +7,189 @@ namespace Magento\Catalog\Test\Unit\Model\Indexer\Product\Price\Action; +use Magento\Store\Model\StoreManagerInterface; +use Magento\Directory\Model\CurrencyFactory; +use Magento\Catalog\Model\Product\Type; +use Magento\Catalog\Model\ResourceModel\Product\Indexer\Price\Factory; +use Magento\Catalog\Model\ResourceModel\Product\Indexer\Price\DefaultPrice; +use Magento\Catalog\Model\ResourceModel\Product\Indexer\Price\TierPrice; +use Magento\Catalog\Model\Indexer\Product\Price\DimensionCollectionFactory; +use Magento\Catalog\Model\Indexer\Product\Price\TableMaintainer; use Magento\Catalog\Model\Indexer\Product\Price\Action\Rows; -use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; +use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Framework\Stdlib\DateTime; +use Magento\Framework\Stdlib\DateTime\TimezoneInterface; +use Magento\Framework\DB\Adapter\AdapterInterface; +use Magento\Framework\DB\Select; +use Magento\Framework\Indexer\MultiDimensionProvider; use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\MockObject\MockObject; +/** + * Test coverage for the rows action + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) to preserve compatibility with parent class + */ class RowsTest extends TestCase { /** * @var Rows */ - protected $_model; + private $actionRows; + + /** + * @var ScopeConfigInterface|MockObject + */ + private $config; + + /** + * @var StoreManagerInterface|MockObject + */ + private $storeManager; + + /** + * @var CurrencyFactory|MockObject + */ + private $currencyFactory; + + /** + * @var TimezoneInterface|MockObject + */ + private $localeDate; + + /** + * @var DateTime|MockObject + */ + private $dateTime; + + /** + * @var Type|MockObject + */ + private $catalogProductType; + + /** + * @var Factory|MockObject + */ + private $indexerPriceFactory; + + /** + * @var DefaultPrice|MockObject + */ + private $defaultIndexerResource; + + /** + * @var TierPrice|MockObject + */ + private $tierPriceIndexResource; + + /** + * @var DimensionCollectionFactory|MockObject + */ + private $dimensionCollectionFactory; + + /** + * @var TableMaintainer|MockObject + */ + private $tableMaintainer; protected function setUp(): void { - $objectManager = new ObjectManager($this); - $this->_model = $objectManager->getObject(Rows::class); + $this->config = $this->getMockBuilder(ScopeConfigInterface::class) + ->getMockForAbstractClass(); + $this->storeManager = $this->getMockBuilder(StoreManagerInterface::class) + ->getMockForAbstractClass(); + $this->currencyFactory = $this->getMockBuilder(CurrencyFactory::class) + ->disableOriginalConstructor() + ->getMock(); + $this->localeDate = $this->getMockBuilder(TimezoneInterface::class) + ->getMockForAbstractClass(); + $this->dateTime = $this->getMockBuilder(DateTime::class) + ->disableOriginalConstructor() + ->getMock(); + $this->catalogProductType = $this->getMockBuilder(Type::class) + ->disableOriginalConstructor() + ->getMock(); + $this->indexerPriceFactory = $this->getMockBuilder(Factory::class) + ->disableOriginalConstructor() + ->getMock(); + $this->defaultIndexerResource = $this->getMockBuilder(DefaultPrice::class) + ->disableOriginalConstructor() + ->getMock(); + $this->tierPriceIndexResource = $this->getMockBuilder(TierPrice::class) + ->disableOriginalConstructor() + ->getMock(); + $this->dimensionCollectionFactory = $this->getMockBuilder(DimensionCollectionFactory::class) + ->disableOriginalConstructor() + ->getMock(); + $this->tableMaintainer = $this->getMockBuilder(TableMaintainer::class) + ->disableOriginalConstructor() + ->getMock(); + $batchSize = 2; + + $this->actionRows = new Rows( + $this->config, + $this->storeManager, + $this->currencyFactory, + $this->localeDate, + $this->dateTime, + $this->catalogProductType, + $this->indexerPriceFactory, + $this->defaultIndexerResource, + $this->tierPriceIndexResource, + $this->dimensionCollectionFactory, + $this->tableMaintainer, + $batchSize + ); } public function testEmptyIds() { $this->expectException('Magento\Framework\Exception\InputException'); $this->expectExceptionMessage('Bad value was supplied.'); - $this->_model->execute(null); + $this->actionRows->execute(null); + } + + public function testBatchProcessing() + { + $ids = [1, 2, 3, 4]; + $select = $this->getMockBuilder(Select::class) + ->disableOriginalConstructor() + ->getMock(); + $select->expects($this->any())->method('from')->willReturnSelf(); + $select->expects($this->any())->method('where')->willReturnSelf(); + $select->expects($this->any())->method('join')->willReturnSelf(); + $adapter = $this->getMockBuilder(AdapterInterface::class)->getMockForAbstractClass(); + $adapter->expects($this->any())->method('select')->willReturn($select); + $this->defaultIndexerResource->expects($this->any()) + ->method('getConnection') + ->willReturn($adapter); + $adapter->expects($this->any()) + ->method('fetchAll') + ->with($select) + ->willReturn([]); + $adapter->expects($this->any()) + ->method('fetchPairs') + ->with($select) + ->willReturn([]); + $multiDimensionProvider = $this->getMockBuilder(MultiDimensionProvider::class) + ->disableOriginalConstructor() + ->getMock(); + $this->dimensionCollectionFactory->expects($this->exactly(2)) + ->method('create') + ->willReturn($multiDimensionProvider); + $iterator = new \ArrayIterator([]); + $multiDimensionProvider->expects($this->exactly(2)) + ->method('getIterator') + ->willReturn($iterator); + $this->catalogProductType->expects($this->any()) + ->method('getTypesByPriority') + ->willReturn([]); + $adapter->expects($this->exactly(2)) + ->method('getIndexList') + ->willReturn(['entity_id'=>['COLUMNS_LIST'=>['test']]]); + $adapter->expects($this->exactly(2)) + ->method('getPrimaryKeyName') + ->willReturn('entity_id'); + $this->actionRows->execute($ids); } } diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Plugin/ProductRepository/TransactionWrapperTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Plugin/ProductRepository/TransactionWrapperTest.php index c60ef266b7ebb..89243ea30c9dc 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/Plugin/ProductRepository/TransactionWrapperTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/Plugin/ProductRepository/TransactionWrapperTest.php @@ -62,7 +62,7 @@ protected function setUp(): void $this->closureMock = function () use ($productMock) { return $productMock; }; - $this->rollbackClosureMock = function () use ($productMock) { + $this->rollbackClosureMock = function () { throw new \Exception(self::ERROR_MSG); }; diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Product/Attribute/RepositoryTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Product/Attribute/RepositoryTest.php index 0c2e5f361413e..673e12a5b42b5 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/Product/Attribute/RepositoryTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/Product/Attribute/RepositoryTest.php @@ -83,11 +83,6 @@ class RepositoryTest extends TestCase */ protected $searchResultMock; - /** - * @var AttributeOptionManagementInterface|MockObject - */ - private $optionManagementMock; - /** * @inheritdoc */ @@ -116,8 +111,6 @@ protected function setUp(): void ['getItems', 'getSearchCriteria', 'getTotalCount', 'setItems', 'setSearchCriteria', 'setTotalCount'] ) ->getMockForAbstractClass(); - $this->optionManagementMock = - $this->getMockForAbstractClass(ProductAttributeOptionManagementInterface::class); $this->model = new Repository( $this->attributeResourceMock, @@ -126,8 +119,7 @@ protected function setUp(): void $this->eavAttributeRepositoryMock, $this->eavConfigMock, $this->validatorFactoryMock, - $this->searchCriteriaBuilderMock, - $this->optionManagementMock + $this->searchCriteriaBuilderMock ); } @@ -291,7 +283,6 @@ public function testSaveDoesNotSaveAttributeOptionsIfOptionsAreAbsentInPayload() // Attribute code must not be changed after attribute creation $attributeMock->expects($this->once())->method('setAttributeCode')->with($attributeCode); $this->attributeResourceMock->expects($this->once())->method('save')->with($attributeMock); - $this->optionManagementMock->expects($this->never())->method('add'); $this->model->save($attributeMock); } diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Product/Filter/DateTimeTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Product/Filter/DateTimeTest.php index 629500ca91cdc..053d7d6e97826 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/Product/Filter/DateTimeTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/Product/Filter/DateTimeTest.php @@ -10,6 +10,7 @@ use Magento\Catalog\Model\Product\Filter\DateTime; use Magento\Framework\Locale\Resolver; use Magento\Framework\Locale\ResolverInterface; +use Magento\Framework\Stdlib\DateTime\Intl\DateFormatterFactory; use Magento\Framework\Stdlib\DateTime\Timezone; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; use PHPUnit\Framework\TestCase; @@ -43,7 +44,7 @@ function () { ); $timezone = $objectManager->getObject( Timezone::class, - ['localeResolver' => $localeResolver] + ['localeResolver' => $localeResolver, 'dateFormatterFactory' => new DateFormatterFactory()] ); $stdlibDateTimeFilter = $objectManager->getObject( \Magento\Framework\Stdlib\DateTime\Filter\DateTime::class, diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Product/Option/Validator/SelectTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Product/Option/Validator/SelectTest.php index 11412324b8363..d10f4931a928f 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/Product/Option/Validator/SelectTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/Product/Option/Validator/SelectTest.php @@ -66,7 +66,7 @@ protected function setUp(): void ]; $configMock->expects($this->once())->method('getAll')->willReturn($config); $methods = ['getTitle', 'getType', 'getPriceType', 'getPrice', 'getData']; - $this->valueMock = $this->createPartialMock(Option::class, $methods, []); + $this->valueMock = $this->createPartialMock(Option::class, $methods); $this->validator = new Select( $configMock, $priceConfigMock, diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Product/Option/ValueTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Product/Option/ValueTest.php index e46884d1637da..d4c1db4ec1b28 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/Product/Option/ValueTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/Product/Option/ValueTest.php @@ -12,12 +12,12 @@ use Magento\Catalog\Model\Product\Option\Value; use Magento\Catalog\Model\ResourceModel\Product\Option\Value\Collection; use Magento\Catalog\Model\ResourceModel\Product\Option\Value\CollectionFactory; -use Magento\Catalog\Pricing\Price\CustomOptionPriceCalculator; - use Magento\Framework\Pricing\Price\PriceInterface; use Magento\Framework\Pricing\PriceInfoInterface; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; use PHPUnit\Framework\TestCase; +use Magento\Catalog\Pricing\Price\CalculateCustomOptionCatalogRule; +use PHPUnit\Framework\MockObject\MockObject; /** * Test for \Magento\Catalog\Model\Product\Option\Value class. @@ -30,17 +30,20 @@ class ValueTest extends TestCase private $model; /** - * @var CustomOptionPriceCalculator + * @var CalculateCustomOptionCatalogRule|MockObject */ - private $customOptionPriceCalculatorMock; + private $calculateCustomOptionCatalogRule; + /** + * @inheritDoc + */ protected function setUp(): void { $mockedResource = $this->getMockedResource(); $mockedCollectionFactory = $this->getMockedValueCollectionFactory(); - $this->customOptionPriceCalculatorMock = $this->createMock( - CustomOptionPriceCalculator::class + $this->calculateCustomOptionCatalogRule = $this->createMock( + CalculateCustomOptionCatalogRule::class ); $helper = new ObjectManager($this); @@ -49,7 +52,7 @@ protected function setUp(): void [ 'resource' => $mockedResource, 'valueCollectionFactory' => $mockedCollectionFactory, - 'customOptionPriceCalculator' => $this->customOptionPriceCalculatorMock, + 'calculateCustomOptionCatalogRule' => $this->calculateCustomOptionCatalogRule ] ); $this->model->setOption($this->getMockedOption()); @@ -77,8 +80,8 @@ public function testGetPrice() $this->assertEquals($price, $this->model->getPrice(false)); $percentPrice = 100.0; - $this->customOptionPriceCalculatorMock->expects($this->atLeastOnce()) - ->method('getOptionPriceByPriceCode') + $this->calculateCustomOptionCatalogRule->expects($this->atLeastOnce()) + ->method('execute') ->willReturn($percentPrice); $this->assertEquals($percentPrice, $this->model->getPrice(true)); } diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Product/Price/PricePersistenceTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Product/Price/PricePersistenceTest.php index cdd5f4d91b653..6f70f7973c10c 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/Product/Price/PricePersistenceTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/Product/Price/PricePersistenceTest.php @@ -301,7 +301,7 @@ public function testDeleteWithException() ->willReturn($idsBySku); $this->attributeRepository->expects($this->once())->method('get')->willReturn($this->productAttribute); $this->productAttribute->expects($this->once())->method('getAttributeId')->willReturn($attributeId); - $this->attributeResource->expects($this->atLeastOnce(2))->method('getConnection') + $this->attributeResource->expects($this->atLeastOnce())->method('getConnection') ->willReturn($this->connection); $this->connection->expects($this->once())->method('beginTransaction')->willReturnSelf(); $this->attributeResource diff --git a/app/code/Magento/Catalog/Test/Unit/Model/ProductRepositoryTest.php b/app/code/Magento/Catalog/Test/Unit/Model/ProductRepositoryTest.php index aa90f1d8924a2..129873a067d97 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/ProductRepositoryTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/ProductRepositoryTest.php @@ -48,12 +48,20 @@ use PHPUnit\Framework\TestCase; /** + * Test for \Magento\Catalog\Model\ProductRepository. + * * @SuppressWarnings(PHPMD.TooManyFields) * @SuppressWarnings(PHPMD.CouplingBetweenObjects) * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ class ProductRepositoryTest extends TestCase { + private const STUB_STORE_ID = 1; + private const STUB_STORE_ID_GLOBAL = 0; + private const STUB_PRODUCT_ID = 100; + private const STUB_PRODUCT_NAME = 'name'; + private const STUB_PRODUCT_SKU = 'sku'; + /** * @var Product|MockObject */ @@ -298,6 +306,7 @@ protected function setUp(): void ->disableOriginalConstructor() ->setMethods([]) ->getMockForAbstractClass(); + $storeMock->method('getId')->willReturn(self::STUB_STORE_ID); $storeMock->expects($this->any())->method('getWebsiteId')->willReturn('1'); $storeMock->expects($this->any())->method('getCode')->willReturn(Store::ADMIN_CODE); $this->storeManager->expects($this->any())->method('getStore')->willReturn($storeMock); @@ -351,6 +360,72 @@ function ($value) { $this->objectManager->setBackwardCompatibleProperty($this->model, 'mediaProcessor', $mediaProcessor); } + /** + * Test save product with global store id + * + * @param array $productData + * @return void + * @dataProvider getProductData + */ + public function testSaveForAllStoreViewScope(array $productData): void + { + $this->productFactory->method('create')->willReturn($this->product); + $this->product->method('getSku')->willReturn($productData['sku']); + $this->extensibleDataObjectConverter + ->expects($this->once()) + ->method('toNestedArray') + ->willReturn($productData); + $this->resourceModel->method('getIdBySku')->willReturn(self::STUB_PRODUCT_ID); + $this->resourceModel->expects($this->once())->method('validate')->willReturn(true); + $this->product->expects($this->at(14))->method('setData') + ->with('store_id', $productData['store_id']); + + $this->model->save($this->product); + } + + /** + * Product data provider + * + * @return array + */ + public function getProductData(): array + { + return [ + [ + [ + 'sku' => self::STUB_PRODUCT_SKU, + 'name' => self::STUB_PRODUCT_NAME, + 'store_id' => self::STUB_STORE_ID_GLOBAL, + ], + ], + ]; + } + + /** + * Test save product without store + * + * @return void + */ + public function testSaveWithoutStoreId(): void + { + $this->productFactory->method('create')->willReturn($this->product); + $this->product->method('getSku')->willReturn($this->productData['sku']); + $this->extensibleDataObjectConverter + ->expects($this->once()) + ->method('toNestedArray') + ->willReturn($this->productData); + $this->resourceModel->method('getIdBySku')->willReturn(self::STUB_PRODUCT_ID); + $this->resourceModel->expects($this->once())->method('validate')->willReturn(true); + $this->product->expects($this->at(15))->method('setData') + ->with('store_id', self::STUB_STORE_ID); + + $this->model->save($this->product); + } + + /** + * @expectedException \Magento\Framework\Exception\NoSuchEntityException + * @expectedExceptionMessage The product that was requested doesn't exist. Verify the product and try again. + */ public function testGetAbsentProduct() { $this->expectException('Magento\Framework\Exception\NoSuchEntityException'); diff --git a/app/code/Magento/Catalog/Test/Unit/Model/ProductTest.php b/app/code/Magento/Catalog/Test/Unit/Model/ProductTest.php index 48a081aaeda54..42d0778daa4af 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/ProductTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/ProductTest.php @@ -313,10 +313,7 @@ protected function setUp(): void $contextMock = $this->createPartialMock( Context::class, - ['getEventDispatcher', 'getCacheManager', 'getAppState', 'getActionValidator'], - [], - '', - false + ['getEventDispatcher', 'getCacheManager', 'getAppState', 'getActionValidator'] ); $contextMock->expects($this->any())->method('getAppState')->willReturn($this->appStateMock); $contextMock->expects($this->any()) @@ -541,7 +538,7 @@ public function testGetStoreSingleSiteModelIds( /** * @return array */ - public function getSingleStoreIds() + public function getSingleStoreIds(): array { return [ [ @@ -619,7 +616,7 @@ public function testGetCategoryCollectionCollectionNull($initCategoryCollection, $result = $product->getCategoryCollection(); - $productIdCachedActual = $this->getPropertyValue($product, '_productIdCached', $productIdCached); + $productIdCachedActual = $this->getPropertyValue($product, '_productIdCached'); $this->assertEquals($getIdResult, $productIdCachedActual); $this->assertEquals($initCategoryCollection, $result); } @@ -627,7 +624,7 @@ public function testGetCategoryCollectionCollectionNull($initCategoryCollection, /** * @return array */ - public function getCategoryCollectionCollectionNullDataProvider() + public function getCategoryCollectionCollectionNullDataProvider(): array { return [ [ @@ -742,7 +739,7 @@ public function testReindex($productChanged, $isScheduled, $productFlatCount, $c /** * @return array */ - public function getProductReindexProvider() + public function getProductReindexProvider(): array { return [ 'set 1' => [true, false, 1, 1], @@ -774,12 +771,18 @@ public function testPriceReindexCallback() /** * @dataProvider getIdentitiesProvider * @param array $expected - * @param array $origData + * @param array|null $origData * @param array $data * @param bool $isDeleted - */ - public function testGetIdentities($expected, $origData, $data, $isDeleted = false) - { + * @param bool $isNew + */ + public function testGetIdentities( + array $expected, + ?array $origData, + array $data, + bool $isDeleted = false, + bool $isNew = false + ) { $this->model->setIdFieldName('id'); if (is_array($origData)) { foreach ($origData as $key => $value) { @@ -790,13 +793,14 @@ public function testGetIdentities($expected, $origData, $data, $isDeleted = fals $this->model->setData($key, $value); } $this->model->isDeleted($isDeleted); + $this->model->isObjectNew($isNew); $this->assertEquals($expected, $this->model->getIdentities()); } /** * @return array */ - public function getIdentitiesProvider() + public function getIdentitiesProvider(): array { $extensionAttributesMock = $this->getMockBuilder(ExtensionAttributesInterface::class) ->disableOriginalConstructor() @@ -814,90 +818,129 @@ public function getIdentitiesProvider() ['id' => 1, 'name' => 'value', 'category_ids' => [1]], ], 'new product' => $this->getNewProductProviderData(), + 'new disabled product' => $this->getNewDisabledProductProviderData(), 'status and category change' => [ [0 => 'cat_p_1', 1 => 'cat_c_p_1', 2 => 'cat_c_p_2'], - ['id' => 1, 'name' => 'value', 'category_ids' => [1], 'status' => 2], + ['id' => 1, 'name' => 'value', 'category_ids' => [1], 'status' => Status::STATUS_DISABLED], + [ + 'id' => 1, + 'name' => 'value', + 'category_ids' => [2], + 'status' => Status::STATUS_ENABLED, + 'affected_category_ids' => [1, 2], + 'is_changed_categories' => true + ], + ], + 'category change for disabled product' => [ + [0 => 'cat_p_1'], + ['id' => 1, 'name' => 'value', 'category_ids' => [1], 'status' => Status::STATUS_DISABLED], [ 'id' => 1, 'name' => 'value', 'category_ids' => [2], - 'status' => 1, + 'status' => Status::STATUS_DISABLED, 'affected_category_ids' => [1, 2], 'is_changed_categories' => true ], ], - 'status change only' => [ + 'status change to disabled' => [ + [0 => 'cat_p_1', 1 => 'cat_c_p_7'], + ['id' => 1, 'name' => 'value', 'category_ids' => [7], 'status' => Status::STATUS_ENABLED], + ['id' => 1, 'name' => 'value', 'category_ids' => [7], 'status' => Status::STATUS_DISABLED], + ], + 'status change to enabled' => [ [0 => 'cat_p_1', 1 => 'cat_c_p_7'], - ['id' => 1, 'name' => 'value', 'category_ids' => [7], 'status' => 1], - ['id' => 1, 'name' => 'value', 'category_ids' => [7], 'status' => 2], + ['id' => 1, 'name' => 'value', 'category_ids' => [7], 'status' => Status::STATUS_DISABLED], + ['id' => 1, 'name' => 'value', 'category_ids' => [7], 'status' => Status::STATUS_ENABLED], ], 'status changed, category unassigned' => $this->getStatusAndCategoryChangesData(), 'no status changes' => [ [0 => 'cat_p_1'], - ['id' => 1, 'name' => 'value', 'category_ids' => [1], 'status' => 1], - ['id' => 1, 'name' => 'value', 'category_ids' => [1], 'status' => 1], + ['id' => 1, 'name' => 'value', 'category_ids' => [1], 'status' => Status::STATUS_ENABLED], + ['id' => 1, 'name' => 'value', 'category_ids' => [1], 'status' => Status::STATUS_ENABLED], ], - 'no stock status changes' => [ + 'no stock status changes' => $this->getNoStockStatusChangesData($extensionAttributesMock), + 'no stock status data 1' => [ [0 => 'cat_p_1'], - ['id' => 1, 'name' => 'value', 'category_ids' => [1], 'status' => 1], + ['id' => 1, 'name' => 'value', 'category_ids' => [1], 'status' => Status::STATUS_ENABLED], [ 'id' => 1, 'name' => 'value', 'category_ids' => [1], - 'status' => 1, - 'stock_data' => ['is_in_stock' => true], + 'status' => Status::STATUS_ENABLED, ExtensibleDataInterface::EXTENSION_ATTRIBUTES_KEY => $extensionAttributesMock, ], ], - 'no stock status data 1' => [ + 'no stock status data 2' => [ [0 => 'cat_p_1'], - ['id' => 1, 'name' => 'value', 'category_ids' => [1], 'status' => 1], + ['id' => 1, 'name' => 'value', 'category_ids' => [1], 'status' => Status::STATUS_ENABLED], [ 'id' => 1, 'name' => 'value', 'category_ids' => [1], - 'status' => 1, - ExtensibleDataInterface::EXTENSION_ATTRIBUTES_KEY => $extensionAttributesMock, + 'status' => Status::STATUS_ENABLED, + 'stock_data' => ['is_in_stock' => true], ], ], - 'no stock status data 2' => [ + 'stock status changes for enabled product' => $this->getStatusStockProviderData($extensionAttributesMock), + 'stock status changes for disabled product' => [ [0 => 'cat_p_1'], - ['id' => 1, 'name' => 'value', 'category_ids' => [1], 'status' => 1], + ['id' => 1, 'name' => 'value', 'category_ids' => [1], 'status' => Status::STATUS_DISABLED], [ 'id' => 1, 'name' => 'value', 'category_ids' => [1], - 'status' => 1, - 'stock_data' => ['is_in_stock' => true], + 'status' => Status::STATUS_DISABLED, + 'stock_data' => ['is_in_stock' => false], + ExtensibleDataInterface::EXTENSION_ATTRIBUTES_KEY => $extensionAttributesMock, ], ], - 'stock status changes' => $this->getStatusStockProviderData($extensionAttributesMock), ]; } /** * @return array */ - private function getStatusAndCategoryChangesData() + private function getStatusAndCategoryChangesData(): array { return [ [0 => 'cat_p_1', 1 => 'cat_c_p_5'], - ['id' => 1, 'name' => 'value', 'category_ids' => [5], 'status' => 2], + ['id' => 1, 'name' => 'value', 'category_ids' => [5], 'status' => Status::STATUS_DISABLED], [ 'id' => 1, 'name' => 'value', 'category_ids' => [], - 'status' => 1, + 'status' => Status::STATUS_ENABLED, 'is_changed_categories' => true, 'affected_category_ids' => [5] ], ]; } + /** + * @param MockObject $extensionAttributesMock + * @return array + */ + private function getNoStockStatusChangesData($extensionAttributesMock): array + { + return [ + [0 => 'cat_p_1'], + ['id' => 1, 'name' => 'value', 'category_ids' => [1], 'status' => Status::STATUS_ENABLED], + [ + 'id' => 1, + 'name' => 'value', + 'category_ids' => [1], + 'status' => Status::STATUS_ENABLED, + 'stock_data' => ['is_in_stock' => true], + ExtensibleDataInterface::EXTENSION_ATTRIBUTES_KEY => $extensionAttributesMock, + ], + ]; + } + /** * @return array */ - private function getNewProductProviderData() + private function getNewProductProviderData(): array { return [ ['cat_p_1', 'cat_c_p_1'], @@ -908,7 +951,30 @@ private function getNewProductProviderData() 'category_ids' => [1], 'affected_category_ids' => [1], 'is_changed_categories' => true - ] + ], + false, + true, + ]; + } + + /** + * @return array + */ + private function getNewDisabledProductProviderData(): array + { + return [ + ['cat_p_1'], + null, + [ + 'id' => 1, + 'name' => 'value', + 'category_ids' => [1], + 'status' => Status::STATUS_DISABLED, + 'affected_category_ids' => [1], + 'is_changed_categories' => true + ], + false, + true, ]; } @@ -916,16 +982,16 @@ private function getNewProductProviderData() * @param MockObject $extensionAttributesMock * @return array */ - private function getStatusStockProviderData($extensionAttributesMock) + private function getStatusStockProviderData($extensionAttributesMock): array { return [ [0 => 'cat_p_1', 1 => 'cat_c_p_1'], - ['id' => 1, 'name' => 'value', 'category_ids' => [1], 'status' => 1], + ['id' => 1, 'name' => 'value', 'category_ids' => [1], 'status' => Status::STATUS_ENABLED], [ 'id' => 1, 'name' => 'value', 'category_ids' => [1], - 'status' => 1, + 'status' => Status::STATUS_ENABLED, 'stock_data' => ['is_in_stock' => false], ExtensibleDataInterface::EXTENSION_ATTRIBUTES_KEY => $extensionAttributesMock, ], @@ -1440,7 +1506,7 @@ public function testGetCustomAttributes() /** * @return array */ - public function priceDataProvider() + public function priceDataProvider(): array { return [ 'receive empty array' => [[]], diff --git a/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/AttributeTest.php b/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/AttributeTest.php index abe6949d87b90..2cbee50b5f590 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/AttributeTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/AttributeTest.php @@ -8,17 +8,15 @@ namespace Magento\Catalog\Test\Unit\Model\ResourceModel; -use Magento\Catalog\Api\Data\ProductInterface; use Magento\Catalog\Model\Attribute\LockValidatorInterface; use Magento\Catalog\Model\ResourceModel\Attribute; +use Magento\Catalog\Model\ResourceModel\Attribute\RemoveProductAttributeData; use Magento\Eav\Model\Config; use Magento\Eav\Model\Entity\Attribute\AbstractAttribute; use Magento\Eav\Model\Entity\Attribute\Backend\AbstractBackend; use Magento\Eav\Model\ResourceModel\Entity\Type; use Magento\Framework\App\ResourceConnection; use Magento\Framework\DB\Adapter\AdapterInterface as Adapter; -use Magento\Framework\EntityManager\EntityMetadataInterface; -use Magento\Framework\EntityManager\MetadataPool; use Magento\Framework\Model\AbstractModel; use Magento\Framework\Model\ResourceModel\Db\Context; use Magento\ResourceConnections\DB\Select; @@ -72,9 +70,9 @@ class AttributeTest extends TestCase private $lockValidatorMock; /** - * @var EntityMetadataInterface|MockObject + * @var RemoveProductAttributeData|MockObject */ - private $entityMetaDataInterfaceMock; + private $removeProductAttributeDataMock; /** * @inheritDoc @@ -88,13 +86,7 @@ protected function setUp(): void $this->connectionMock = $this->getMockBuilder(Adapter::class) ->getMockForAbstractClass(); - $this->connectionMock->expects($this->once())->method('select')->willReturn($this->selectMock); - $this->connectionMock->expects($this->once())->method('query')->willReturn($this->selectMock); $this->connectionMock->expects($this->once())->method('delete')->willReturn($this->selectMock); - $this->selectMock->expects($this->once())->method('from')->willReturnSelf(); - $this->selectMock->expects($this->once())->method('join')->willReturnSelf(); - $this->selectMock->expects($this->any())->method('where')->willReturnSelf(); - $this->selectMock->expects($this->any())->method('deleteFromSelect')->willReturnSelf(); $this->resourceMock = $this->getMockBuilder(ResourceConnection::class) ->disableOriginalConstructor() @@ -117,26 +109,10 @@ protected function setUp(): void ->disableOriginalConstructor() ->setMethods(['validate']) ->getMockForAbstractClass(); - $this->entityMetaDataInterfaceMock = $this->getMockBuilder(EntityMetadataInterface::class) + $this->removeProductAttributeDataMock = $this->getMockBuilder(RemoveProductAttributeData::class) + ->setMethods(['removeData']) ->disableOriginalConstructor() - ->getMockForAbstractClass(); - } - - /** - * Sets object non-public property. - * - * @param mixed $object - * @param string $propertyName - * @param mixed $value - * - * @return void - */ - private function setObjectProperty($object, string $propertyName, $value) : void - { - $reflectionClass = new \ReflectionClass($object); - $reflectionProperty = $reflectionClass->getProperty($propertyName); - $reflectionProperty->setAccessible(true); - $reflectionProperty->setValue($object, $value); + ->getMock(); } /** @@ -156,10 +132,9 @@ public function testDeleteEntity() : void ]; $backendTableName = 'weee_tax'; - $backendFieldName = 'value_id'; $attributeModel = $this->getMockBuilder(Attribute::class) - ->setMethods(['getEntityAttribute', 'getMetadataPool', 'getConnection', 'getTable']) + ->setMethods(['getEntityAttribute', 'getConnection', 'getTable']) ->setConstructorArgs([ $this->contextMock, $this->storeManagerMock, @@ -167,17 +142,12 @@ public function testDeleteEntity() : void $this->eavConfigMock, $this->lockValidatorMock, null, + $this->removeProductAttributeDataMock ])->getMock(); $attributeModel->expects($this->any()) ->method('getEntityAttribute') ->with($entityAttributeId) ->willReturn($result); - $metadataPoolMock = $this->getMockBuilder(MetadataPool::class) - ->disableOriginalConstructor() - ->setMethods(['getMetadata']) - ->getMock(); - - $this->setObjectProperty($attributeModel, 'metadataPool', $metadataPoolMock); $eavAttributeMock = $this->getMockBuilder(AbstractAttribute::class) ->disableOriginalConstructor() @@ -204,7 +174,7 @@ public function testDeleteEntity() : void $backendModelMock = $this->getMockBuilder(AbstractBackend::class) ->disableOriginalConstructor() - ->setMethods(['getBackend', 'getTable', 'getEntityIdField']) + ->setMethods(['getBackend', 'getTable']) ->getMock(); $abstractAttributeMock = $this->getMockBuilder(AbstractAttribute::class) @@ -216,16 +186,10 @@ public function testDeleteEntity() : void $eavAttributeMock->expects($this->any())->method('getEntity')->willReturn($abstractAttributeMock); $backendModelMock->expects($this->any())->method('getTable')->willReturn($backendTableName); - $backendModelMock->expects($this->once())->method('getEntityIdField')->willReturn($backendFieldName); - - $metadataPoolMock->expects($this->any()) - ->method('getMetadata') - ->with(ProductInterface::class) - ->willReturn($this->entityMetaDataInterfaceMock); - $this->entityMetaDataInterfaceMock->expects($this->any()) - ->method('getLinkField') - ->willReturn('row_id'); + $this->removeProductAttributeDataMock->expects($this->once()) + ->method('removeData') + ->with($abstractModelMock, $result['attribute_set_id']); $attributeModel->expects($this->any())->method('getConnection')->willReturn($this->connectionMock); $attributeModel->expects($this->any()) diff --git a/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/Product/CategoryLinkTest.php b/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/Product/CategoryLinkTest.php index 4f684f4d98ea9..b2dd2c7b1e29e 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/Product/CategoryLinkTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/Product/CategoryLinkTest.php @@ -145,11 +145,11 @@ public function testSaveCategoryLinks($newCategoryLinks, $dbCategoryLinks, $affe $expectedResult = []; foreach ($affectedIds as $type => $ids) { - // phpcs:ignore Magento2.Performance.ForeachArrayMerge - $expectedResult = array_merge($expectedResult, $ids); + $expectedResult[] = $ids; // Verify if the correct insert, update and/or delete actions are performed: $this->setupExpectationsForConnection($type, $ids); } + $expectedResult = array_merge([], ...$expectedResult); $actualResult = $this->model->saveCategoryLinks($product, $newCategoryLinks); diff --git a/app/code/Magento/Catalog/Test/Unit/Model/View/Asset/Image/ContextTest.php b/app/code/Magento/Catalog/Test/Unit/Model/View/Asset/Image/ContextTest.php deleted file mode 100644 index af8245de3525d..0000000000000 --- a/app/code/Magento/Catalog/Test/Unit/Model/View/Asset/Image/ContextTest.php +++ /dev/null @@ -1,79 +0,0 @@ -<?php -/** - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. - */ -declare(strict_types=1); - -namespace Magento\Catalog\Test\Unit\Model\View\Asset\Image; - -use Magento\Catalog\Model\Product\Media\ConfigInterface; -use Magento\Catalog\Model\View\Asset\Image\Context; -use Magento\Framework\App\Filesystem\DirectoryList; -use Magento\Framework\Filesystem; -use Magento\Framework\Filesystem\Directory\WriteInterface; -use PHPUnit\Framework\MockObject\MockObject; -use PHPUnit\Framework\TestCase; - -class ContextTest extends TestCase -{ - /** - * @var Context - */ - protected $model; - - /** - * @var WriteInterface|MockObject - */ - protected $mediaDirectory; - - /** - * @var ContextInterface|MockObject - */ - protected $mediaConfig; - - /** - * @var Filesystem|MockObject - */ - protected $filesystem; - - protected function setUp(): void - { - $this->mediaConfig = $this->getMockBuilder(ConfigInterface::class) - ->getMockForAbstractClass(); - $this->mediaConfig->expects($this->any())->method('getBaseMediaPath')->willReturn('catalog/product'); - $this->mediaDirectory = $this->getMockBuilder(WriteInterface::class) - ->getMockForAbstractClass(); - $this->mediaDirectory->expects($this->once())->method('create')->with('catalog/product'); - $this->filesystem = $this->getMockBuilder(Filesystem::class) - ->disableOriginalConstructor() - ->getMock(); - $this->filesystem->expects($this->once()) - ->method('getDirectoryWrite') - ->with(DirectoryList::MEDIA) - ->willReturn($this->mediaDirectory); - $this->model = new Context( - $this->mediaConfig, - $this->filesystem - ); - } - - public function testGetPath() - { - $path = '/var/www/html/magento2ce/pub/media/catalog/product'; - $this->mediaDirectory->expects($this->once()) - ->method('getAbsolutePath') - ->with('catalog/product') - ->willReturn($path); - - $this->assertEquals($path, $this->model->getPath()); - } - - public function testGetUrl() - { - $baseUrl = 'http://localhost/pub/media/catalog/product'; - $this->mediaConfig->expects($this->once())->method('getBaseMediaUrl')->willReturn($baseUrl); - - $this->assertEquals($baseUrl, $this->model->getBaseUrl()); - } -} diff --git a/app/code/Magento/Catalog/Test/Unit/Model/View/Asset/ImageTest.php b/app/code/Magento/Catalog/Test/Unit/Model/View/Asset/ImageTest.php deleted file mode 100644 index 1a61cd4d4eea8..0000000000000 --- a/app/code/Magento/Catalog/Test/Unit/Model/View/Asset/ImageTest.php +++ /dev/null @@ -1,213 +0,0 @@ -<?php -/** - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. - */ -declare(strict_types=1); - -namespace Magento\Catalog\Test\Unit\Model\View\Asset; - -use Magento\Catalog\Model\Product\Media\ConfigInterface; -use Magento\Catalog\Model\View\Asset\Image; -use Magento\Framework\Encryption\EncryptorInterface; -use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; -use Magento\Framework\View\Asset\ContextInterface; -use Magento\Framework\View\Asset\Repository; -use PHPUnit\Framework\MockObject\MockObject; -use PHPUnit\Framework\TestCase; - -class ImageTest extends TestCase -{ - /** - * @var Image - */ - protected $model; - - /** - * @var ContextInterface|MockObject - */ - protected $mediaConfig; - - /** - * @var EncryptorInterface|MockObject - */ - protected $encryptor; - - /** - * @var ContextInterface|MockObject - */ - protected $context; - - /** - * @var Repository|MockObject - */ - private $assetRepo; - - private $objectManager; - - protected function setUp(): void - { - $this->mediaConfig = $this->getMockForAbstractClass(ConfigInterface::class); - $this->encryptor = $this->getMockForAbstractClass(EncryptorInterface::class); - $this->context = $this->getMockForAbstractClass(ContextInterface::class); - $this->assetRepo = $this->createMock(Repository::class); - $this->objectManager = new ObjectManager($this); - $this->model = $this->objectManager->getObject( - Image::class, - [ - 'mediaConfig' => $this->mediaConfig, - 'imageContext' => $this->context, - 'encryptor' => $this->encryptor, - 'filePath' => '/somefile.png', - 'assetRepo' => $this->assetRepo, - 'miscParams' => [ - 'image_width' => 100, - 'image_height' => 50, - 'constrain_only' => false, - 'keep_aspect_ratio' => false, - 'keep_frame' => true, - 'keep_transparency' => false, - 'background' => '255,255,255', - 'image_type' => 'image', //thumbnail,small_image,image,swatch_image,swatch_thumb - 'quality' => 80, - 'angle' => null - ] - ] - ); - } - - public function testModuleAndContentAndContentType() - { - $contentType = 'image'; - $this->assertEquals($contentType, $this->model->getContentType()); - $this->assertEquals($contentType, $this->model->getSourceContentType()); - $this->assertNull($this->model->getContent()); - $this->assertEquals('cache', $this->model->getModule()); - } - - public function testGetFilePath() - { - $this->assertEquals('/somefile.png', $this->model->getFilePath()); - } - - public function testGetSoureFile() - { - $this->mediaConfig->expects($this->once())->method('getBaseMediaPath')->willReturn('catalog/product'); - $this->assertEquals('catalog/product/somefile.png', $this->model->getSourceFile()); - } - - public function testGetContext() - { - $this->assertInstanceOf(ContextInterface::class, $this->model->getContext()); - } - - /** - * @param string $filePath - * @param array $miscParams - * @param string $readableParams - * @dataProvider getPathDataProvider - */ - public function testGetPath($filePath, $miscParams, $readableParams) - { - $imageModel = $this->objectManager->getObject( - Image::class, - [ - 'mediaConfig' => $this->mediaConfig, - 'context' => $this->context, - 'encryptor' => $this->encryptor, - 'filePath' => $filePath, - 'assetRepo' => $this->assetRepo, - 'miscParams' => $miscParams - ] - ); - $absolutePath = '/var/www/html/magento2ce/pub/media/catalog/product'; - $hashPath = 'somehash'; - $this->context->method('getPath')->willReturn($absolutePath); - $this->encryptor->expects(static::once()) - ->method('hash') - ->with($readableParams, $this->anything()) - ->willReturn($hashPath); - static::assertEquals( - $absolutePath . '/cache/' . $hashPath . $filePath, - $imageModel->getPath() - ); - } - - /** - * @param string $filePath - * @param array $miscParams - * @param string $readableParams - * @dataProvider getPathDataProvider - */ - public function testGetUrl($filePath, $miscParams, $readableParams) - { - $imageModel = $this->objectManager->getObject( - Image::class, - [ - 'mediaConfig' => $this->mediaConfig, - 'context' => $this->context, - 'encryptor' => $this->encryptor, - 'filePath' => $filePath, - 'assetRepo' => $this->assetRepo, - 'miscParams' => $miscParams - ] - ); - $absolutePath = 'http://localhost/pub/media/catalog/product'; - $hashPath = 'somehash'; - $this->context->expects(static::once())->method('getBaseUrl')->willReturn($absolutePath); - $this->encryptor->expects(static::once()) - ->method('hash') - ->with($readableParams, $this->anything()) - ->willReturn($hashPath); - static::assertEquals( - $absolutePath . '/cache/' . $hashPath . $filePath, - $imageModel->getUrl() - ); - } - - /** - * @return array - */ - public function getPathDataProvider() - { - return [ - [ - '/some_file.png', - [], //default value for miscParams, - 'h:empty_w:empty_q:empty_r:empty_nonproportional_noframe_notransparency_notconstrainonly_nobackground', - ], - [ - '/some_file_2.png', - [ - 'image_type' => 'thumbnail', - 'image_height' => 75, - 'image_width' => 75, - 'keep_aspect_ratio' => true, - 'keep_frame' => true, - 'keep_transparency' => true, - 'constrain_only' => true, - 'background' => [233,1,0], - 'angle' => null, - 'quality' => 80, - ], - 'h:75_w:75_proportional_frame_transparency_doconstrainonly_rgb233,1,0_r:empty_q:80', - ], - [ - '/some_file_3.png', - [ - 'image_type' => 'thumbnail', - 'image_height' => 75, - 'image_width' => 75, - 'keep_aspect_ratio' => false, - 'keep_frame' => false, - 'keep_transparency' => false, - 'constrain_only' => false, - 'background' => [233,1,0], - 'angle' => 90, - 'quality' => 80, - ], - 'h:75_w:75_nonproportional_noframe_notransparency_notconstrainonly_rgb233,1,0_r:90_q:80', - ], - ]; - } -} diff --git a/app/code/Magento/Catalog/Test/Unit/Model/View/Asset/PlaceholderTest.php b/app/code/Magento/Catalog/Test/Unit/Model/View/Asset/PlaceholderTest.php index f32a7513f236b..401f16831e75a 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/View/Asset/PlaceholderTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/View/Asset/PlaceholderTest.php @@ -141,10 +141,10 @@ public function testGetUrl($imageType, $placeholderPath) if ($placeholderPath == null) { $this->imageContext->expects($this->never())->method('getBaseUrl'); - $expectedResult = 'http://localhost/pub/media/catalog/product/to_default/placeholder/by_type'; + $expectedResult = 'http://localhost/media/catalog/product/to_default/placeholder/by_type'; $this->repository->expects($this->any())->method('getUrl')->willReturn($expectedResult); } else { - $baseUrl = 'http://localhost/pub/media/catalog/product'; + $baseUrl = 'http://localhost/media/catalog/product'; $this->imageContext->expects($this->any())->method('getBaseUrl')->willReturn($baseUrl); $expectedResult = $baseUrl . DIRECTORY_SEPARATOR . $imageModel->getModule() diff --git a/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Form/Modifier/CurrencySymbolProviderTest.php b/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Form/Modifier/CurrencySymbolProviderTest.php new file mode 100644 index 0000000000000..07b3de40c31f8 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Form/Modifier/CurrencySymbolProviderTest.php @@ -0,0 +1,304 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Catalog\Test\Unit\Ui\DataProvider\Product\Form\Modifier; + +use Magento\Catalog\Ui\DataProvider\Product\Form\Modifier\CurrencySymbolProvider; +use Magento\Catalog\Model\Locator\LocatorInterface; +use Magento\Catalog\Model\Product; +use Magento\Directory\Model\Currency as CurrencyModel; +use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Framework\Locale\CurrencyInterface; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; +use Magento\Store\Api\Data\StoreInterface; +use Magento\Store\Api\Data\WebsiteInterface; +use Magento\Store\Model\StoreManagerInterface; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Zend_Currency; + +/** + * Test class for Website Currency Symbol provider + */ +class CurrencySymbolProviderTest extends TestCase +{ + /** + * @var CurrencySymbolProvider|MockObject + */ + private $currencySymbolProvider; + + /** + * @var ScopeConfigInterface|MockObject + */ + private $scopeConfigMock; + + /** + * @var StoreManagerInterface|MockObject + */ + private $storeManagerMock; + + /** + * @var LocatorInterface|MockObject + */ + private $locatorMock; + + /** + * @var CurrencyInterface|MockObject + */ + private $localeCurrencyMock; + + /** + * @var StoreInterface|MockObject + */ + private $currentStoreMock; + + /** + * @var CurrencyModel|MockObject + */ + private $currencyMock; + + /** + * @var Zend_Currency|MockObject + */ + private $websiteCurrencyMock; + + /** + * @var Product|MockObject + */ + private $productMock; + + protected function setUp(): void + { + $objectManager = new ObjectManager($this); + + $this->scopeConfigMock = $this->getMockForAbstractClass( + ScopeConfigInterface::class, + [], + '', + true, + true, + true, + ['getValue'] + ); + $this->storeManagerMock = $this->getMockForAbstractClass( + StoreManagerInterface::class, + [], + '', + true, + true, + true, + ['getWebsites'] + ); + $this->currentStoreMock = $this->getMockForAbstractClass( + StoreInterface::class, + [], + '', + true, + true, + true, + ['getBaseCurrency'] + ); + $this->currencyMock = $this->createMock(CurrencyModel::class); + $this->websiteCurrencyMock = $this->createMock(Zend_Currency::class); + $this->productMock = $this->createMock(Product::class); + $this->locatorMock = $this->getMockForAbstractClass( + LocatorInterface::class, + [], + '', + true, + true, + true, + ['getStore', 'getProduct'] + ); + $this->localeCurrencyMock = $this->getMockForAbstractClass( + CurrencyInterface::class, + [], + '', + true, + true, + true, + ['getWebsites', 'getCurrency'] + ); + $this->currencySymbolProvider = $objectManager->getObject( + CurrencySymbolProvider::class, + [ + 'scopeConfig' => $this->scopeConfigMock, + 'storeManager' => $this->storeManagerMock, + 'locator' => $this->locatorMock, + 'localeCurrency' => $this->localeCurrencyMock + ] + ); + } + + /** + * Test for Get option array of currency symbol prefixes. + * + * @param int $catalogPriceScope + * @param string $defaultStoreCurrencySymbol + * @param array $listOfWebsites + * @param array $productWebsiteIds + * @param array $currencySymbols + * @param array $actualResult + * @dataProvider getWebsiteCurrencySymbolDataProvider + */ + public function testGetCurrenciesPerWebsite( + int $catalogPriceScope, + string $defaultStoreCurrencySymbol, + array $listOfWebsites, + array $productWebsiteIds, + array $currencySymbols, + array $actualResult + ): void { + $this->locatorMock->expects($this->any()) + ->method('getStore') + ->willReturn($this->currentStoreMock); + $this->currentStoreMock->expects($this->any()) + ->method('getBaseCurrency') + ->willReturn($this->currencyMock); + $this->currencyMock->expects($this->any()) + ->method('getCurrencySymbol') + ->willReturn($defaultStoreCurrencySymbol); + $this->scopeConfigMock + ->expects($this->any()) + ->method('getValue') + ->willReturn($catalogPriceScope); + $this->locatorMock->expects($this->any()) + ->method('getProduct') + ->willReturn($this->productMock); + $this->storeManagerMock->expects($this->any()) + ->method('getWebsites') + ->willReturn($listOfWebsites); + $this->productMock->expects($this->any()) + ->method('getWebsiteIds') + ->willReturn($productWebsiteIds); + $this->localeCurrencyMock->expects($this->any()) + ->method('getCurrency') + ->willReturn($this->websiteCurrencyMock); + foreach ($currencySymbols as $currencySymbol) { + $this->websiteCurrencyMock->expects($this->any()) + ->method('getSymbol') + ->willReturn($currencySymbol); + } + $expectedResult = $this->currencySymbolProvider + ->getCurrenciesPerWebsite(); + $this->assertEquals($expectedResult, $actualResult); + } + + /** + * DataProvider for getCurrenciesPerWebsite. + * + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + * @return array + */ + public function getWebsiteCurrencySymbolDataProvider(): array + { + return [ + 'verify website currency with default website and global price scope' => [ + 'catalogPriceScope' => 0, + 'defaultStoreCurrencySymbol' => '$', + 'listOfWebsites' => $this->getWebsitesMock( + [ + [ + 'id' => '1', + 'name' => 'Main Website', + 'code' => 'main_website', + 'base_currency_code' => 'USD', + 'currency_symbol' => '$' + ] + ] + ), + 'productWebsiteIds' => ['1'], + 'currencySymbols' => ['$'], + 'actualResult' => ['$'] + ], + 'verify website currency with default website and website price scope' => [ + 'catalogPriceScope' => 1, + 'defaultStoreCurrencySymbol' => '$', + 'listOfWebsites' => $this->getWebsitesMock( + [ + [ + 'id' => '1', + 'name' => 'Main Website', + 'code' => 'main_website', + 'base_currency_code' => 'USD', + 'currency_symbol' => '$' + ] + ] + ), + 'productWebsiteIds' => ['1'], + 'currencySymbols' => ['$'], + 'actualResult' => ['$', '$'] + ], + 'verify website currency with two website and website price scope' => [ + 'catalogPriceScope' => 1, + 'defaultStoreCurrencySymbol' => '$', + 'listOfWebsites' => $this->getWebsitesMock( + [ + [ + 'id' => '1', + 'name' => 'Main Website', + 'code' => 'main_website', + 'base_currency_code' => 'USD', + 'currency_symbol' => '$' + ], + [ + 'id' => '2', + 'name' => 'Indian Website', + 'code' => 'indian_website', + 'base_currency_code' => 'INR', + 'currency_symbol' => '₹' + ] + ] + ), + 'productWebsiteIds' => ['1', '2'], + 'currencySymbols' => ['$', '₹'], + 'actualResult' => ['$', '$', '$'] + ] + ]; + } + + /** + * Get list of websites mock + * + * @param array $websites + * @return array + */ + private function getWebsitesMock(array $websites): array + { + $websitesMock = []; + foreach ($websites as $key => $website) { + $websitesMock[$key] = $this->getMockForAbstractClass( + WebsiteInterface::class, + [], + '', + true, + true, + true, + ['getId', 'getBaseCurrencyCode'] + ); + $websitesMock[$key]->expects($this->any()) + ->method('getId') + ->willReturn($website['id']); + $websitesMock[$key]->expects($this->any()) + ->method('getBaseCurrencyCode') + ->willReturn($website['base_currency_code']); + } + return $websitesMock; + } + + protected function tearDown(): void + { + unset($this->scopeConfigMock); + unset($this->storeManagerMock); + unset($this->currentStoreMock); + unset($this->currencyMock); + unset($this->websiteCurrencyMock); + unset($this->productMock); + unset($this->locatorMock); + unset($this->localeCurrencyMock); + } +} diff --git a/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Listing/Collector/ImageTest.php b/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Listing/Collector/ImageTest.php index 605a5e4fd5e3b..457408e0934af 100644 --- a/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Listing/Collector/ImageTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Listing/Collector/ImageTest.php @@ -104,9 +104,6 @@ public function testGet() ->method('create') ->willReturn($image); - $imageHelper->expects($this->once()) - ->method('getResizedImageInfo') - ->willReturn([11, 11]); $this->state->expects($this->once()) ->method('emulateAreaCode') ->with( @@ -116,12 +113,14 @@ public function testGet() ) ->willReturn($imageHelper); + $width = 5; + $height = 10; $imageHelper->expects($this->once()) ->method('getHeight') - ->willReturn(10); + ->willReturn($height); $imageHelper->expects($this->once()) ->method('getWidth') - ->willReturn(10); + ->willReturn($width); $imageHelper->expects($this->once()) ->method('getLabel') ->willReturn('Label'); @@ -137,10 +136,10 @@ public function testGet() ->with(); $image->expects($this->once()) ->method('setResizedHeight') - ->with(11); + ->with($height); $image->expects($this->once()) ->method('setResizedWidth') - ->with(11); + ->with($width); $productRenderInfoDto->expects($this->once()) ->method('setImages') diff --git a/app/code/Magento/Catalog/Ui/Component/Listing/Columns/AttributeSetId.php b/app/code/Magento/Catalog/Ui/Component/Listing/Columns/AttributeSetId.php index 5e9f7ba065be7..9dc5704673f01 100644 --- a/app/code/Magento/Catalog/Ui/Component/Listing/Columns/AttributeSetId.php +++ b/app/code/Magento/Catalog/Ui/Component/Listing/Columns/AttributeSetId.php @@ -8,7 +8,7 @@ namespace Magento\Catalog\Ui\Component\Listing\Columns; /** - * Attribute set listing column component + * AttributeSetId listing column component. */ class AttributeSetId extends \Magento\Ui\Component\Listing\Columns\Column { @@ -23,6 +23,7 @@ protected function applySorting() && !empty($sorting['field']) && !empty($sorting['direction']) && $sorting['field'] === $this->getName() + && in_array(strtoupper($sorting['direction']), ['ASC', 'DESC'], true) ) { $collection = $this->getContext()->getDataProvider()->getCollection(); $collection->joinField( diff --git a/app/code/Magento/Catalog/Ui/Component/Listing/Columns/Websites.php b/app/code/Magento/Catalog/Ui/Component/Listing/Columns/Websites.php index 6b85ade0995a0..3349879cce847 100644 --- a/app/code/Magento/Catalog/Ui/Component/Listing/Columns/Websites.php +++ b/app/code/Magento/Catalog/Ui/Component/Listing/Columns/Websites.php @@ -119,6 +119,7 @@ protected function applySorting() && !empty($sorting['field']) && !empty($sorting['direction']) && $sorting['field'] === $this->getName() + && in_array(strtoupper($sorting['direction']), ['ASC', 'DESC'], true) ) { /** @var \Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection $collection */ $collection = $this->getContext()->getDataProvider()->getCollection(); diff --git a/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/AdvancedPricing.php b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/AdvancedPricing.php index 174a01b72a109..8c9421b073394 100644 --- a/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/AdvancedPricing.php +++ b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/AdvancedPricing.php @@ -100,6 +100,11 @@ class AdvancedPricing extends AbstractModifier */ private $customerGroupSource; + /** + * @var CurrencySymbolProvider + */ + private $currencySymbolProvider; + /** * @param LocatorInterface $locator * @param StoreManagerInterface $storeManager @@ -110,7 +115,8 @@ class AdvancedPricing extends AbstractModifier * @param Data $directoryHelper * @param ArrayManager $arrayManager * @param string $scopeName - * @param GroupSourceInterface $customerGroupSource + * @param GroupSourceInterface|null $customerGroupSource + * @param CurrencySymbolProvider|null $currencySymbolProvider * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -123,7 +129,8 @@ public function __construct( Data $directoryHelper, ArrayManager $arrayManager, $scopeName = '', - GroupSourceInterface $customerGroupSource = null + GroupSourceInterface $customerGroupSource = null, + ?CurrencySymbolProvider $currencySymbolProvider = null ) { $this->locator = $locator; $this->storeManager = $storeManager; @@ -136,6 +143,8 @@ public function __construct( $this->scopeName = $scopeName; $this->customerGroupSource = $customerGroupSource ?: ObjectManager::getInstance()->get(GroupSourceInterface::class); + $this->currencySymbolProvider = $currencySymbolProvider + ?: ObjectManager::getInstance()->get(CurrencySymbolProvider::class); } /** @@ -488,6 +497,7 @@ private function getTierPriceStructure($tierPricePath) 'arguments' => [ 'data' => [ 'config' => [ + 'component' => 'Magento_Catalog/js/components/website-currency-symbol', 'dataType' => Text::NAME, 'formElement' => Select::NAME, 'componentType' => Field::NAME, @@ -498,6 +508,10 @@ private function getTierPriceStructure($tierPricePath) 'visible' => $this->isMultiWebsites(), 'disabled' => ($this->isShowWebsiteColumn() && !$this->isAllowChangeWebsite()), 'sortOrder' => 10, + 'currenciesForWebsites' => $this->currencySymbolProvider + ->getCurrenciesPerWebsite(), + 'currency' => $this->currencySymbolProvider + ->getDefaultCurrency(), ], ], ], @@ -548,9 +562,6 @@ private function getTierPriceStructure($tierPricePath) 'label' => __('Price'), 'enableLabel' => true, 'dataScope' => 'price', - 'addbefore' => $this->locator->getStore() - ->getBaseCurrency() - ->getCurrencySymbol(), 'sortOrder' => 40, 'validation' => [ 'required-entry' => true, @@ -559,8 +570,12 @@ private function getTierPriceStructure($tierPricePath) ], 'imports' => [ 'priceValue' => '${ $.provider }:data.product.price', - '__disableTmpl' => ['priceValue' => false], + '__disableTmpl' => ['priceValue' => false, 'addbefore' => false], + 'addbefore' => '${ $.parentName }:currency' ], + 'tracks' => [ + 'addbefore' => true + ] ], ], ], diff --git a/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/CurrencySymbolProvider.php b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/CurrencySymbolProvider.php new file mode 100644 index 0000000000000..b46ca682e576a --- /dev/null +++ b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/CurrencySymbolProvider.php @@ -0,0 +1,139 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Catalog\Ui\DataProvider\Product\Form\Modifier; + +use Magento\Catalog\Model\Locator\LocatorInterface; +use Magento\Framework\Locale\CurrencyInterface; +use Magento\Store\Model\Store; +use Magento\Store\Model\StoreManagerInterface; +use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Store\Model\ScopeInterface; +use Magento\Store\Model\Website; + +/** + * Website Currency Symbol provider + */ +class CurrencySymbolProvider +{ + /** + * Scope Config Details + * + * @var ScopeConfigInterface + */ + private $scopeConfig; + + /** + * Store Information + * + * @var StoreManagerInterface + */ + private $storeManager; + + /** + * Store locator + * + * @var LocatorInterface + */ + private $locator; + + /** + * Locale Currency + * + * @var CurrencyInterface + */ + private $localeCurrency; + + /** + * Initialize objects for website currency scope + * + * @param ScopeConfigInterface $scopeConfig + * @param StoreManagerInterface $storeManager + * @param LocatorInterface $locator + * @param CurrencyInterface $localeCurrency + */ + public function __construct( + ScopeConfigInterface $scopeConfig, + StoreManagerInterface $storeManager, + LocatorInterface $locator, + CurrencyInterface $localeCurrency + ) { + $this->scopeConfig = $scopeConfig; + $this->storeManager = $storeManager; + $this->locator = $locator; + $this->localeCurrency = $localeCurrency; + } + + /** + * Get option array of currency symbol prefixes. + * + * @return array + */ + public function getCurrenciesPerWebsite(): array + { + $baseCurrency = $this->locator->getStore() + ->getBaseCurrency(); + $websitesCurrencySymbol[0] = $baseCurrency->getCurrencySymbol() ?? + $baseCurrency->getCurrencyCode(); + $catalogPriceScope = $this->getCatalogPriceScope(); + $product = $this->locator->getProduct(); + $websitesList = $this->storeManager->getWebsites(); + $productWebsiteIds = $product->getWebsiteIds(); + if ($catalogPriceScope!=0) { + foreach ($websitesList as $website) { + /** @var Website $website */ + if (!in_array($website->getId(), $productWebsiteIds)) { + continue; + } + $websitesCurrencySymbol[$website->getId()] = $this + ->getCurrencySymbol( + $website->getBaseCurrencyCode() + ); + } + } + return $websitesCurrencySymbol; + } + + /** + * Get default store currency symbol + * + * @return string + */ + public function getDefaultCurrency(): string + { + $baseCurrency = $this->locator->getStore() + ->getBaseCurrency(); + return $baseCurrency->getCurrencySymbol() ?? + $baseCurrency->getCurrencyCode(); + } + + /** + * Get catalog price scope from the admin config + * + * @return int + */ + public function getCatalogPriceScope(): int + { + return (int) $this->scopeConfig->getValue( + Store::XML_PATH_PRICE_SCOPE, + ScopeInterface::SCOPE_WEBSITE + ); + } + + /** + * Retrieve currency name by code + * + * @param string $code + * @return string + */ + private function getCurrencySymbol(string $code): string + { + $currency = $this->localeCurrency->getCurrency($code); + return $currency->getSymbol() ? + $currency->getSymbol() : $currency->getShortName(); + } +} diff --git a/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/TierPrice.php b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/TierPrice.php index e9e8229e581ba..25e04302bd33c 100644 --- a/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/TierPrice.php +++ b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/TierPrice.php @@ -118,6 +118,10 @@ private function getUpdatedTierPriceStructure(array $priceMeta) 'showLabel' => false, 'dataScope' => '', 'additionalClasses' => 'control-grouped', + 'imports' => [ + 'currency' => '${ $.parentName }.website_id:currency', + '__disableTmpl' => ['currency' => false], + ], 'sortOrder' => isset($priceMeta['arguments']['data']['config']['sortOrder']) ? $priceMeta['arguments']['data']['config']['sortOrder'] : 40, ], diff --git a/app/code/Magento/Catalog/Ui/DataProvider/Product/Listing/Collector/Image.php b/app/code/Magento/Catalog/Ui/DataProvider/Product/Listing/Collector/Image.php index 2324ca27ffaaf..2d4f1566a5b6e 100644 --- a/app/code/Magento/Catalog/Ui/DataProvider/Product/Listing/Collector/Image.php +++ b/app/code/Magento/Catalog/Ui/DataProvider/Product/Listing/Collector/Image.php @@ -118,18 +118,14 @@ public function collect(ProductInterface $product, ProductRenderInterface $produ [$product, $imageCode, (int) $productRender->getStoreId(), $image] ); - try { - $resizedInfo = $helper->getResizedImageInfo(); - } catch (NotLoadInfoImageException $exception) { - $resizedInfo = [$helper->getWidth(), $helper->getHeight()]; - } - $image->setCode($imageCode); - $image->setHeight($helper->getHeight()); - $image->setWidth($helper->getWidth()); + $height = $helper->getHeight(); + $image->setHeight($height); + $width = $helper->getWidth(); + $image->setWidth($width); $image->setLabel($helper->getLabel()); - $image->setResizedHeight($resizedInfo[1]); - $image->setResizedWidth($resizedInfo[0]); + $image->setResizedHeight($height); + $image->setResizedWidth($width); $images[] = $image; } diff --git a/app/code/Magento/Catalog/Ui/DataProvider/Product/ProductCollection.php b/app/code/Magento/Catalog/Ui/DataProvider/Product/ProductCollection.php index ea8fc6f2d83b2..f4334bc25efd8 100644 --- a/app/code/Magento/Catalog/Ui/DataProvider/Product/ProductCollection.php +++ b/app/code/Magento/Catalog/Ui/DataProvider/Product/ProductCollection.php @@ -5,16 +5,10 @@ */ namespace Magento\Catalog\Ui\DataProvider\Product; -use Magento\Catalog\Model\ResourceModel\Eav\Attribute; -use Magento\Framework\Exception\LocalizedException; -use Magento\Eav\Model\Entity\Attribute\AttributeInterface; - /** * Collection which is used for rendering product list in the backend. * * Used for product grid and customizes behavior of the default Product collection for grid needs. - * - * @SuppressWarnings(PHPMD.CookieAndSessionMisuse) */ class ProductCollection extends \Magento\Catalog\Model\ResourceModel\Product\Collection { @@ -31,66 +25,4 @@ protected function _productLimitationJoinPrice() $this->_productLimitationFilters->setUsePriceIndex(false); return $this->_productLimitationPrice(true); } - - /** - * Add attribute filter to collection - * - * @param AttributeInterface|integer|string|array $attribute - * @param null|string|array $condition - * @param string $joinType - * @return $this - * @throws LocalizedException - */ - public function addAttributeToFilter($attribute, $condition = null, $joinType = 'inner') - { - $storeId = (int)$this->getStoreId(); - if ($attribute === 'is_saleable' - || is_array($attribute) - || $storeId !== $this->getDefaultStoreId() - ) { - return parent::addAttributeToFilter($attribute, $condition, $joinType); - } - - if ($attribute instanceof AttributeInterface) { - $attributeModel = $attribute; - } else { - $attributeModel = $this->getEntity()->getAttribute($attribute); - if ($attributeModel === false) { - throw new LocalizedException( - __('Invalid attribute identifier for filter (%1)', get_class($attribute)) - ); - } - } - - if ($attributeModel->isScopeGlobal() || $attributeModel->getBackend()->isStatic()) { - return parent::addAttributeToFilter($attribute, $condition, $joinType); - } - - $this->addAttributeToFilterAllStores($attributeModel, $condition); - - return $this; - } - - /** - * Add attribute to filter by all stores - * - * @param Attribute $attributeModel - * @param array $condition - * @return void - */ - private function addAttributeToFilterAllStores(Attribute $attributeModel, array $condition): void - { - $tableName = $this->getTable($attributeModel->getBackendTable()); - $entity = $this->getEntity(); - $fKey = 'e.' . $this->getEntityPkName($entity); - $pKey = $tableName . '.' . $this->getEntityPkName($entity); - $attributeId = $attributeModel->getAttributeId(); - $condition = "({$pKey} = {$fKey}) AND (" - . $this->_getConditionSql("{$tableName}.value", $condition) - . ') AND (' - . $this->_getConditionSql("{$tableName}.attribute_id", $attributeId) - . ')'; - $selectExistsInAllStores = $this->getConnection()->select()->from($tableName); - $this->getSelect()->exists($selectExistsInAllStores, $condition); - } } diff --git a/app/code/Magento/Catalog/etc/adminhtml/system.xml b/app/code/Magento/Catalog/etc/adminhtml/system.xml index 8f8a5f36e516c..4e10453f542bb 100644 --- a/app/code/Magento/Catalog/etc/adminhtml/system.xml +++ b/app/code/Magento/Catalog/etc/adminhtml/system.xml @@ -214,6 +214,13 @@ <source_model>Magento\Catalog\Model\Config\Source\LayoutList</source_model> </field> </group> + <group id="url"> + <field id="catalog_media_url_format" translate="label comment" type="select" sortOrder="30" showInDefault="1" showInWebsite="0" showInStore="0" canRestore="1"> + <label>Catalog media URL format</label> + <source_model>Magento\Catalog\Model\Config\Source\Web\CatalogMediaUrlFormat</source_model> + <comment><![CDATA[Images should be optimized based on query parameters by your CDN or web server. Use the legacy mode for backward compatibility. <a href="https://docs.magento.com/m2/ee/user_guide/configuration/general/web.html#url-options">Learn more</a> about catalog URL formats.<br/><br/><strong style="color:red">Warning!</strong> If you switch back to legacy mode, you must <a href="https://devdocs.magento.com/guides/v2.3/frontend-dev-guide/themes/theme-images.html#resize-catalog-images">use the CLI to regenerate images</a>.]]></comment> + </field> + </group> </section> <section id="system" translate="label" type="text" sortOrder="900" showInDefault="1" showInWebsite="1" showInStore="1"> <class>separator-top</class> diff --git a/app/code/Magento/Catalog/etc/config.xml b/app/code/Magento/Catalog/etc/config.xml index aa689c7dd35b2..f5546a06dd235 100644 --- a/app/code/Magento/Catalog/etc/config.xml +++ b/app/code/Magento/Catalog/etc/config.xml @@ -67,7 +67,6 @@ <media_storage_configuration> <allowed_resources> <tmp_images_folder>tmp</tmp_images_folder> - <catalog_product_images>media/catalog/product/cache/</catalog_product_images> <catalog_images_folder>catalog</catalog_images_folder> <product_custom_options_fodler>custom_options</product_custom_options_fodler> </allowed_resources> @@ -83,6 +82,11 @@ <thumbnail_position>stretch</thumbnail_position> </watermark> </design> + <web> + <url> + <catalog_media_url_format>hash</catalog_media_url_format> + </url> + </web> <general> <validator_data> <input_types> diff --git a/app/code/Magento/Catalog/etc/di.xml b/app/code/Magento/Catalog/etc/di.xml index 97a787c87bfa8..8a116282e2578 100644 --- a/app/code/Magento/Catalog/etc/di.xml +++ b/app/code/Magento/Catalog/etc/di.xml @@ -80,6 +80,9 @@ <plugin name="catalogLog" type="Magento\Catalog\Model\Plugin\Log" /> </type> <type name="Magento\Catalog\Model\Category\DataProvider"> + <arguments> + <argument name="uiConfigFactory" xsi:type="object">uiComponentConfigFactory</argument> + </arguments> <plugin name="set_page_layout_default_value" type="Magento\Catalog\Model\Plugin\SetPageLayoutDefaultValue" /> </type> <type name="Magento\Theme\Block\Html\Topmenu"> diff --git a/app/code/Magento/Catalog/etc/view.xml b/app/code/Magento/Catalog/etc/view.xml index 0740ab5b154f6..910a3be8055da 100644 --- a/app/code/Magento/Catalog/etc/view.xml +++ b/app/code/Magento/Catalog/etc/view.xml @@ -8,9 +8,5 @@ <view xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Config/etc/view.xsd"> <vars module="Magento_Catalog"> <var name="product_image_white_borders">1</var> - <!-- Variable to enable lazy loading for catalog product images without borders. - If you enable this setting your small size images without borders may be stretched in template. - So be sure you have correct image sizes. --> - <var name="enable_lazy_loading_for_images_without_borders">0</var> </vars> </view> diff --git a/app/code/Magento/Catalog/i18n/en_US.csv b/app/code/Magento/Catalog/i18n/en_US.csv index 555871ef32c26..7b9d4baacffe5 100644 --- a/app/code/Magento/Catalog/i18n/en_US.csv +++ b/app/code/Magento/Catalog/i18n/en_US.csv @@ -209,6 +209,7 @@ Catalog,Catalog "You saved the product attribute.","You saved the product attribute." "An attribute with this code already exists.","An attribute with this code already exists." "An attribute with the same code (%1) already exists.","An attribute with the same code (%1) already exists." +"Code (%1) is a reserved key and cannot be used as attribute code.","Code (%1) is a reserved key and cannot be used as attribute code." "The value of Admin must be unique.","The value of Admin must be unique." "The value of Admin scope can't be empty.","The value of Admin scope can't be empty." "You duplicated the product.","You duplicated the product." diff --git a/app/code/Magento/Catalog/view/adminhtml/web/js/components/website-currency-symbol.js b/app/code/Magento/Catalog/view/adminhtml/web/js/components/website-currency-symbol.js new file mode 100644 index 0000000000000..069bd9baed86f --- /dev/null +++ b/app/code/Magento/Catalog/view/adminhtml/web/js/components/website-currency-symbol.js @@ -0,0 +1,30 @@ +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +define([ + 'Magento_Ui/js/form/element/select' +], function (Select) { + 'use strict'; + + return Select.extend({ + defaults: { + currenciesForWebsites: {}, + tracks: { + currency: true + } + }, + + /** + * Set currency symbol per website + * + * @param {String} value - currency symbol + */ + setDifferedFromDefault: function (value) { + this.currency = this.currenciesForWebsites[value]; + + return this._super(); + } + }); +}); diff --git a/app/code/Magento/Catalog/view/frontend/templates/product/image.phtml b/app/code/Magento/Catalog/view/frontend/templates/product/image.phtml index 98d17045a1b2d..86b332679bcb4 100644 --- a/app/code/Magento/Catalog/view/frontend/templates/product/image.phtml +++ b/app/code/Magento/Catalog/view/frontend/templates/product/image.phtml @@ -11,7 +11,7 @@ <!--deprecated template as image_with_borders is a primary one--> <img class="photo image <?= $escaper->escapeHtmlAttr($block->getClass()) ?>" <?php foreach ($block->getCustomAttributes() as $name => $value): ?> - <?= $escaper->escapeHtmlAttr($name) ?>="<?= $escaper->escapeHtmlAttr($value) ?>" + <?= $escaper->escapeHtmlAttr($name) ?>="<?= $escaper->escapeHtml($value) ?>" <?php endforeach; ?> src="<?= $escaper->escapeUrl($block->getImageUrl()) ?>" loading="lazy" diff --git a/app/code/Magento/Catalog/view/frontend/templates/product/image_with_borders.phtml b/app/code/Magento/Catalog/view/frontend/templates/product/image_with_borders.phtml index 0ac6bc88df8ce..cc1a7276c70b8 100644 --- a/app/code/Magento/Catalog/view/frontend/templates/product/image_with_borders.phtml +++ b/app/code/Magento/Catalog/view/frontend/templates/product/image_with_borders.phtml @@ -8,16 +8,6 @@ /** @var $block \Magento\Catalog\Block\Product\Image */ /** @var $escaper \Magento\Framework\Escaper */ /** @var \Magento\Framework\View\Helper\SecureHtmlRenderer $secureRenderer */ -/** - * Enable lazy loading for images with borders and if variable enable_lazy_loading_for_images_without_borders - * is enabled in view.xml. Otherwise small size images without borders may be distorted. So max-width is used for them - * to prevent stretching and lazy loading does not work. - */ -$borders = (bool)$block->getVar('product_image_white_borders', 'Magento_Catalog'); -$enableLazyLoadingWithoutBorders = (bool)$block->getVar( - 'enable_lazy_loading_for_images_without_borders', - 'Magento_Catalog' -); $width = (int)$block->getWidth(); $paddingBottom = $block->getRatio() * 100; ?> @@ -25,17 +15,12 @@ $paddingBottom = $block->getRatio() * 100; <span class="product-image-wrapper"> <img class="<?= $escaper->escapeHtmlAttr($block->getClass()) ?>" <?php foreach ($block->getCustomAttributes() as $name => $value): ?> - <?= $escaper->escapeHtmlAttr($name) ?>="<?= $escaper->escapeHtmlAttr($value) ?>" + <?= $escaper->escapeHtmlAttr($name) ?>="<?= $escaper->escapeHtml($value) ?>" <?php endforeach; ?> src="<?= $escaper->escapeUrl($block->getImageUrl()) ?>" loading="lazy" - <?php if ($borders || $enableLazyLoadingWithoutBorders): ?> - width="<?= $escaper->escapeHtmlAttr($block->getWidth()) ?>" - height="<?= $escaper->escapeHtmlAttr($block->getHeight()) ?>" - <?php else: ?> - max-width="<?= $escaper->escapeHtmlAttr($block->getWidth()) ?>" - max-height="<?= $escaper->escapeHtmlAttr($block->getHeight()) ?>" - <?php endif; ?> + width="<?= $escaper->escapeHtmlAttr($block->getWidth()) ?>" + height="<?= $escaper->escapeHtmlAttr($block->getHeight()) ?>" alt="<?= $escaper->escapeHtmlAttr($block->getLabel()) ?>"/></span> </span> <?php diff --git a/app/code/Magento/CatalogCustomerGraphQl/Model/Resolver/PriceTiers.php b/app/code/Magento/CatalogCustomerGraphQl/Model/Resolver/PriceTiers.php index e78224ba0af38..9bc0d87061a01 100644 --- a/app/code/Magento/CatalogCustomerGraphQl/Model/Resolver/PriceTiers.php +++ b/app/code/Magento/CatalogCustomerGraphQl/Model/Resolver/PriceTiers.php @@ -110,6 +110,11 @@ public function resolve( } $product = $value['model']; + + if ($product->hasData('can_show_price') && $product->getData('can_show_price') === false) { + return []; + } + $productId = $product->getId(); $this->tiers->addProductFilter($productId); diff --git a/app/code/Magento/CatalogCustomerGraphQl/Model/Resolver/TierPrices.php b/app/code/Magento/CatalogCustomerGraphQl/Model/Resolver/TierPrices.php index c449d0a2ba30b..675bdaa5f1db0 100644 --- a/app/code/Magento/CatalogCustomerGraphQl/Model/Resolver/TierPrices.php +++ b/app/code/Magento/CatalogCustomerGraphQl/Model/Resolver/TierPrices.php @@ -87,7 +87,7 @@ public function resolve( $this->tiers->addProductFilter($productId); return $this->valueFactory->create( - function () use ($productId, $context) { + function () use ($productId) { $tierPrices = $this->tiers->getProductTierPrices($productId); return $tierPrices ?? []; diff --git a/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/AttributeOptionProvider.php b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/AttributeOptionProvider.php index 140659abfbfe6..d46776bfe498e 100644 --- a/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/AttributeOptionProvider.php +++ b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/AttributeOptionProvider.php @@ -64,6 +64,13 @@ public function getOptions(array $optionIds, ?int $storeId, array $attributeCode 'attribute_label' => 'a.frontend_label', ] ) + ->joinLeft( + ['attribute_label' => $this->resourceConnection->getTableName('eav_attribute_label')], + "a.attribute_id = attribute_label.attribute_id AND attribute_label.store_id = {$storeId}", + [ + 'attribute_store_label' => 'attribute_label.value', + ] + ) ->joinLeft( ['options' => $this->resourceConnection->getTableName('eav_attribute_option')], 'a.attribute_id = options.attribute_id', @@ -119,7 +126,8 @@ private function formatResult(\Magento\Framework\DB\Select $select): array $result[$option['attribute_code']] = [ 'attribute_id' => $option['attribute_id'], 'attribute_code' => $option['attribute_code'], - 'attribute_label' => $option['attribute_label'], + 'attribute_label' => $option['attribute_store_label'] + ? $option['attribute_store_label'] : $option['attribute_label'], 'options' => [], ]; } diff --git a/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/Builder/Attribute.php b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/Builder/Attribute.php index 105e91320de49..5fce0fcdf3ca2 100644 --- a/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/Builder/Attribute.php +++ b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/Builder/Attribute.php @@ -155,6 +155,10 @@ function (AggregationValueInterface $value) { return []; } - return $this->attributeOptionProvider->getOptions(\array_merge(...$attributeOptionIds), $storeId, $attributes); + return $this->attributeOptionProvider->getOptions( + \array_merge([], ...$attributeOptionIds), + $storeId, + $attributes + ); } } diff --git a/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/Builder/Price.php b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/Builder/Price.php index 02b638edbdce8..1e2cc99663731 100644 --- a/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/Builder/Price.php +++ b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/Builder/Price.php @@ -72,7 +72,7 @@ public function build(AggregationInterface $aggregation, ?int $storeId): array ); } - return [$result]; + return [self::PRICE_BUCKET => $result]; } /** diff --git a/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/LayerBuilder.php b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/LayerBuilder.php index ff661236be62f..ac3f396b45ef8 100644 --- a/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/LayerBuilder.php +++ b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/LayerBuilder.php @@ -36,7 +36,7 @@ public function build(AggregationInterface $aggregation, ?int $storeId): array foreach ($this->builders as $builder) { $layers[] = $builder->build($aggregation, $storeId); } - $layers = \array_merge(...$layers); + $layers = \array_merge([], ...$layers); return \array_filter($layers); } diff --git a/app/code/Magento/CatalogGraphQl/Model/AttributesJoiner.php b/app/code/Magento/CatalogGraphQl/Model/AttributesJoiner.php index 0bfd9d58ec969..34f5dd831686c 100644 --- a/app/code/Magento/CatalogGraphQl/Model/AttributesJoiner.php +++ b/app/code/Magento/CatalogGraphQl/Model/AttributesJoiner.php @@ -88,7 +88,7 @@ public function getQueryFields(FieldNode $fieldNode, ResolveInfo $resolveInfo): } } if ($fragmentFields) { - $selectedFields = array_merge($selectedFields, array_merge(...$fragmentFields)); + $selectedFields = array_merge([], $selectedFields, ...$fragmentFields); } $this->setSelectionsForFieldNode($fieldNode, array_unique($selectedFields)); } diff --git a/app/code/Magento/CatalogGraphQl/Model/Category/CategoryFilter.php b/app/code/Magento/CatalogGraphQl/Model/Category/CategoryFilter.php index dc93005983776..4350b6dd85266 100644 --- a/app/code/Magento/CatalogGraphQl/Model/Category/CategoryFilter.php +++ b/app/code/Magento/CatalogGraphQl/Model/Category/CategoryFilter.php @@ -7,17 +7,17 @@ namespace Magento\CatalogGraphQl\Model\Category; -use Magento\Catalog\Api\CategoryListInterface; -use Magento\Catalog\Api\Data\CategoryInterface; -use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Catalog\Api\CategoryRepositoryInterface; +use Magento\Catalog\Api\Data\CategorySearchResultsInterface; +use Magento\Catalog\Api\Data\CategorySearchResultsInterfaceFactory; +use Magento\Catalog\Model\ResourceModel\Category\CollectionFactory; +use Magento\CatalogGraphQl\Model\Resolver\Categories\DataProvider\Category\CollectionProcessorInterface; +use Magento\CatalogGraphQl\Model\Category\Filter\SearchCriteria; +use Magento\Framework\Api\ExtensionAttribute\JoinProcessorInterface; use Magento\Framework\Exception\InputException; use Magento\Framework\GraphQl\Exception\GraphQlInputException; -use Magento\Framework\GraphQl\Query\Resolver\Argument\SearchCriteria\ArgumentApplier\Filter; -use Magento\Framework\GraphQl\Query\Resolver\Argument\SearchCriteria\ArgumentApplier\Sort; -use Magento\Search\Model\Query; +use Magento\GraphQl\Model\Query\ContextInterface; use Magento\Store\Api\Data\StoreInterface; -use Magento\Store\Model\ScopeInterface; -use Magento\Framework\GraphQl\Query\Resolver\Argument\SearchCriteria\Builder; /** * Category filter allows filtering category results by attributes. @@ -25,38 +25,57 @@ class CategoryFilter { /** - * @var string + * @var CollectionFactory */ - private const SPECIAL_CHARACTERS = '-+~/\\<>\'":*$#@()!,.?`=%&^'; + private $categoryCollectionFactory; /** - * @var ScopeConfigInterface + * @var CollectionProcessorInterface */ - private $scopeConfig; + private $collectionProcessor; /** - * @var CategoryListInterface + * @var JoinProcessorInterface */ - private $categoryList; + private $extensionAttributesJoinProcessor; /** - * @var Builder + * @var CategorySearchResultsInterfaceFactory */ - private $searchCriteriaBuilder; + private $categorySearchResultsFactory; /** - * @param ScopeConfigInterface $scopeConfig - * @param CategoryListInterface $categoryList - * @param Builder $searchCriteriaBuilder + * @var CategoryRepositoryInterface + */ + private $categoryRepository; + + /** + * @var SearchCriteria + */ + private $searchCriteria; + + /** + * @param CollectionFactory $categoryCollectionFactory + * @param CollectionProcessorInterface $collectionProcessor + * @param JoinProcessorInterface $extensionAttributesJoinProcessor + * @param CategorySearchResultsInterfaceFactory $categorySearchResultsFactory + * @param CategoryRepositoryInterface $categoryRepository + * @param SearchCriteria $searchCriteria */ public function __construct( - ScopeConfigInterface $scopeConfig, - CategoryListInterface $categoryList, - Builder $searchCriteriaBuilder + CollectionFactory $categoryCollectionFactory, + CollectionProcessorInterface $collectionProcessor, + JoinProcessorInterface $extensionAttributesJoinProcessor, + CategorySearchResultsInterfaceFactory $categorySearchResultsFactory, + CategoryRepositoryInterface $categoryRepository, + SearchCriteria $searchCriteria ) { - $this->scopeConfig = $scopeConfig; - $this->categoryList = $categoryList; - $this->searchCriteriaBuilder = $searchCriteriaBuilder; + $this->categoryCollectionFactory = $categoryCollectionFactory; + $this->collectionProcessor = $collectionProcessor; + $this->extensionAttributesJoinProcessor = $extensionAttributesJoinProcessor; + $this->categorySearchResultsFactory = $categorySearchResultsFactory; + $this->categoryRepository = $categoryRepository; + $this->searchCriteria = $searchCriteria; } /** @@ -64,21 +83,25 @@ public function __construct( * * @param array $criteria * @param StoreInterface $store + * @param array $attributeNames + * @param ContextInterface $context * @return int[] * @throws InputException */ - public function getResult(array $criteria, StoreInterface $store) + public function getResult(array $criteria, StoreInterface $store, array $attributeNames, ContextInterface $context) { - $categoryIds = []; - $criteria[Filter::ARGUMENT_NAME] = $this->formatMatchFilters($criteria['filters'], $store); - $criteria[Filter::ARGUMENT_NAME][CategoryInterface::KEY_IS_ACTIVE] = ['eq' => 1]; - $criteria[Sort::ARGUMENT_NAME][CategoryInterface::KEY_POSITION] = ['ASC']; - $searchCriteria = $this->searchCriteriaBuilder->build('categoryList', $criteria); - $pageSize = $criteria['pageSize'] ?? 20; - $currentPage = $criteria['currentPage'] ?? 1; - $searchCriteria->setPageSize($pageSize)->setCurrentPage($currentPage); + $searchCriteria = $this->searchCriteria->buildCriteria($criteria, $store); + $collection = $this->categoryCollectionFactory->create(); + $this->extensionAttributesJoinProcessor->process($collection); + $this->collectionProcessor->process($collection, $searchCriteria, $attributeNames, $context); + + /** @var CategorySearchResultsInterface $searchResult */ + $categories = $this->categorySearchResultsFactory->create(); + $categories->setSearchCriteria($searchCriteria); + $categories->setItems($collection->getItems()); + $categories->setTotalCount($collection->getSize()); - $categories = $this->categoryList->getList($searchCriteria); + $categoryIds = []; foreach ($categories->getItems() as $category) { $categoryIds[] = (int)$category->getId(); } @@ -106,35 +129,4 @@ public function getResult(array $criteria, StoreInterface $store) ] ]; } - - /** - * Format match filters to behave like fuzzy match - * - * @param array $filters - * @param StoreInterface $store - * @return array - * @throws InputException - */ - private function formatMatchFilters(array $filters, StoreInterface $store): array - { - $minQueryLength = $this->scopeConfig->getValue( - Query::XML_PATH_MIN_QUERY_LENGTH, - ScopeInterface::SCOPE_STORE, - $store - ); - - foreach ($filters as $filter => $condition) { - $conditionType = current(array_keys($condition)); - if ($conditionType === 'match') { - $searchValue = trim(str_replace(self::SPECIAL_CHARACTERS, '', $condition[$conditionType])); - $matchLength = strlen($searchValue); - if ($matchLength < $minQueryLength) { - throw new InputException(__('Invalid match filter. Minimum length is %1.', $minQueryLength)); - } - unset($filters[$filter]['match']); - $filters[$filter]['like'] = '%' . $searchValue . '%'; - } - } - return $filters; - } } diff --git a/app/code/Magento/CatalogGraphQl/Model/Category/Filter/SearchCriteria.php b/app/code/Magento/CatalogGraphQl/Model/Category/Filter/SearchCriteria.php new file mode 100644 index 0000000000000..aea34f19fea16 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Category/Filter/SearchCriteria.php @@ -0,0 +1,105 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\CatalogGraphQl\Model\Category\Filter; + +use Magento\Catalog\Api\Data\CategoryInterface; +use Magento\Framework\Api\Search\SearchCriteriaInterface; +use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Framework\Exception\InputException; +use Magento\Framework\GraphQl\Query\Resolver\Argument\SearchCriteria\ArgumentApplier\Filter; +use Magento\Framework\GraphQl\Query\Resolver\Argument\SearchCriteria\ArgumentApplier\Sort; +use Magento\Framework\GraphQl\Query\Resolver\Argument\SearchCriteria\Builder; +use Magento\Search\Model\Query; +use Magento\Store\Api\Data\StoreInterface; +use Magento\Store\Model\ScopeInterface; + +/** + * Utility to help transform raw criteria data into SearchCriteriaInterface + */ +class SearchCriteria +{ + /** + * @var string + */ + private const SPECIAL_CHARACTERS = '-+~/\\<>\'":*$#@()!,.?`=%&^'; + + /** + * @var ScopeConfigInterface + */ + private $scopeConfig; + + /** + * @var Builder + */ + private $searchCriteriaBuilder; + + /** + * @param ScopeConfigInterface $scopeConfig + * @param Builder $searchCriteriaBuilder + */ + public function __construct( + ScopeConfigInterface $scopeConfig, + Builder $searchCriteriaBuilder + ) { + $this->scopeConfig = $scopeConfig; + $this->searchCriteriaBuilder = $searchCriteriaBuilder; + } + + /** + * Transform raw criteria data into SearchCriteriaInterface + * + * @param array $criteria + * @param StoreInterface $store + * @return SearchCriteriaInterface + * @throws InputException + */ + public function buildCriteria(array $criteria, StoreInterface $store): SearchCriteriaInterface + { + $criteria[Filter::ARGUMENT_NAME] = $this->formatMatchFilters($criteria['filters'], $store); + $criteria[Filter::ARGUMENT_NAME][CategoryInterface::KEY_IS_ACTIVE] = ['eq' => 1]; + $criteria[Sort::ARGUMENT_NAME][CategoryInterface::KEY_POSITION] = ['ASC']; + + $searchCriteria = $this->searchCriteriaBuilder->build('categoryList', $criteria); + $pageSize = $criteria['pageSize'] ?? 20; + $currentPage = $criteria['currentPage'] ?? 1; + $searchCriteria->setPageSize($pageSize)->setCurrentPage($currentPage); + + return $searchCriteria; + } + + /** + * Format match filters to behave like fuzzy match + * + * @param array $filters + * @param StoreInterface $store + * @return array + * @throws InputException + */ + private function formatMatchFilters(array $filters, StoreInterface $store): array + { + $minQueryLength = $this->scopeConfig->getValue( + Query::XML_PATH_MIN_QUERY_LENGTH, + ScopeInterface::SCOPE_STORE, + $store + ); + + foreach ($filters as $filter => $condition) { + $conditionType = current(array_keys($condition)); + if ($conditionType === 'match') { + $searchValue = trim(str_replace(self::SPECIAL_CHARACTERS, '', $condition[$conditionType])); + $matchLength = strlen($searchValue); + if ($matchLength < $minQueryLength) { + throw new InputException(__('Invalid match filter. Minimum length is %1.', $minQueryLength)); + } + unset($filters[$filter]['match']); + $filters[$filter]['like'] = '%' . $searchValue . '%'; + } + } + return $filters; + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Aggregations.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Aggregations.php index 47a1d1f977f9b..cc76855cc6d20 100644 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Aggregations.php +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Aggregations.php @@ -7,10 +7,12 @@ namespace Magento\CatalogGraphQl\Model\Resolver; +use Magento\CatalogGraphQl\DataProvider\Product\LayeredNavigation\LayerBuilder; +use Magento\Directory\Model\PriceCurrency; +use Magento\Framework\App\ObjectManager; use Magento\Framework\GraphQl\Config\Element\Field; use Magento\Framework\GraphQl\Query\ResolverInterface; use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; -use Magento\CatalogGraphQl\DataProvider\Product\LayeredNavigation\LayerBuilder; use Magento\Store\Api\Data\StoreInterface; /** @@ -28,16 +30,24 @@ class Aggregations implements ResolverInterface */ private $layerBuilder; + /** + * @var PriceCurrency + */ + private $priceCurrency; + /** * @param \Magento\CatalogGraphQl\Model\Resolver\Layer\DataProvider\Filters $filtersDataProvider * @param LayerBuilder $layerBuilder + * @param PriceCurrency $priceCurrency */ public function __construct( \Magento\CatalogGraphQl\Model\Resolver\Layer\DataProvider\Filters $filtersDataProvider, - LayerBuilder $layerBuilder + LayerBuilder $layerBuilder, + PriceCurrency $priceCurrency = null ) { $this->filtersDataProvider = $filtersDataProvider; $this->layerBuilder = $layerBuilder; + $this->priceCurrency = $priceCurrency ?: ObjectManager::getInstance()->get(PriceCurrency::class); } /** @@ -60,7 +70,18 @@ public function resolve( /** @var StoreInterface $store */ $store = $context->getExtensionAttributes()->getStore(); $storeId = (int)$store->getId(); - return $this->layerBuilder->build($aggregations, $storeId); + $results = $this->layerBuilder->build($aggregations, $storeId); + if (isset($results['price_bucket'])) { + foreach ($results['price_bucket']['options'] as &$value) { + list($from, $to) = explode('-', $value['label']); + $newLabel = $this->priceCurrency->convertAndRound($from) + . '-' + . $this->priceCurrency->convertAndRound($to); + $value['label'] = $newLabel; + $value['value'] = str_replace('-', '_', $newLabel); + } + } + return $results; } else { return []; } diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Categories/DataProvider/Category/CollectionProcessor/CatalogProcessor.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Categories/DataProvider/Category/CollectionProcessor/CatalogProcessor.php new file mode 100644 index 0000000000000..c8f9ad5de008f --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Categories/DataProvider/Category/CollectionProcessor/CatalogProcessor.php @@ -0,0 +1,55 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\CatalogGraphQl\Model\Resolver\Categories\DataProvider\Category\CollectionProcessor; + +use Magento\Catalog\Model\ResourceModel\Category\Collection; +use Magento\CatalogGraphQl\Model\Resolver\Categories\DataProvider\Category\CollectionProcessorInterface; +use Magento\Framework\Api\SearchCriteriaInterface; +use Magento\GraphQl\Model\Query\ContextInterface; +use Magento\Framework\Api\SearchCriteria\CollectionProcessorInterface as SearchCriteriaCollectionProcessor; + +/** + * Apply pre-defined catalog filtering + * + * {@inheritdoc} + */ +class CatalogProcessor implements CollectionProcessorInterface +{ + /** @var SearchCriteriaCollectionProcessor */ + private $collectionProcessor; + + /** + * @param SearchCriteriaCollectionProcessor $collectionProcessor + */ + public function __construct( + SearchCriteriaCollectionProcessor $collectionProcessor + ) { + $this->collectionProcessor = $collectionProcessor; + } + + /** + * Process collection to add additional joins, attributes, and clauses to a category collection. + * + * @param Collection $collection + * @param SearchCriteriaInterface $searchCriteria + * @param array $attributeNames + * @param ContextInterface|null $context + * @return Collection + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function process( + Collection $collection, + SearchCriteriaInterface $searchCriteria, + array $attributeNames, + ContextInterface $context = null + ): Collection { + $this->collectionProcessor->process($searchCriteria, $collection); + + return $collection; + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Categories/DataProvider/Category/CollectionProcessorInterface.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Categories/DataProvider/Category/CollectionProcessorInterface.php new file mode 100644 index 0000000000000..5e79064e9acfa --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Categories/DataProvider/Category/CollectionProcessorInterface.php @@ -0,0 +1,34 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\CatalogGraphQl\Model\Resolver\Categories\DataProvider\Category; + +use Magento\Catalog\Model\ResourceModel\Category\Collection; +use Magento\Framework\Api\SearchCriteriaInterface; +use Magento\GraphQl\Model\Query\ContextInterface; + +/** + * Add additional joins, attributes, and clauses to a category collection. + */ +interface CollectionProcessorInterface +{ + /** + * Process collection to add additional joins, attributes, and clauses to a category collection. + * + * @param Collection $collection + * @param SearchCriteriaInterface $searchCriteria + * @param array $attributeNames + * @param ContextInterface|null $context + * @return Collection + */ + public function process( + Collection $collection, + SearchCriteriaInterface $searchCriteria, + array $attributeNames, + ContextInterface $context = null + ): Collection; +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Categories/DataProvider/Category/CompositeCollectionProcessor.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Categories/DataProvider/Category/CompositeCollectionProcessor.php new file mode 100644 index 0000000000000..0ab76606f5dce --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Categories/DataProvider/Category/CompositeCollectionProcessor.php @@ -0,0 +1,55 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\CatalogGraphQl\Model\Resolver\Categories\DataProvider\Category; + +use Magento\Catalog\Model\ResourceModel\Category\Collection; +use Magento\Framework\Api\SearchCriteriaInterface; +use Magento\GraphQl\Model\Query\ContextInterface; + +/** + * Composite collection processor + * + * {@inheritdoc} + */ +class CompositeCollectionProcessor implements CollectionProcessorInterface +{ + /** + * @var CollectionProcessorInterface[] + */ + private $collectionProcessors; + + /** + * @param CollectionProcessorInterface[] $collectionProcessors + */ + public function __construct(array $collectionProcessors = []) + { + $this->collectionProcessors = $collectionProcessors; + } + + /** + * Process collection to add additional joins, attributes, and clauses to a category collection. + * + * @param Collection $collection + * @param SearchCriteriaInterface $searchCriteria + * @param array $attributeNames + * @param ContextInterface|null $context + * @return Collection + */ + public function process( + Collection $collection, + SearchCriteriaInterface $searchCriteria, + array $attributeNames, + ContextInterface $context = null + ): Collection { + foreach ($this->collectionProcessors as $collectionProcessor) { + $collection = $collectionProcessor->process($collection, $searchCriteria, $attributeNames, $context); + } + + return $collection; + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/CategoriesQuery.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/CategoriesQuery.php index eb6708dc48f01..4d7ce13fd23cc 100644 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/CategoriesQuery.php +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/CategoriesQuery.php @@ -70,7 +70,7 @@ public function resolve(Field $field, $context, ResolveInfo $info, array $value } try { - $filterResult = $this->categoryFilter->getResult($args, $store); + $filterResult = $this->categoryFilter->getResult($args, $store, [], $context); } catch (InputException $e) { throw new GraphQlInputException(__($e->getMessage())); } diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Category/DataProvider/Breadcrumbs.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Category/DataProvider/Breadcrumbs.php index 863e621bd8df3..dcd6f816088dd 100644 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Category/DataProvider/Breadcrumbs.php +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Category/DataProvider/Breadcrumbs.php @@ -7,6 +7,7 @@ namespace Magento\CatalogGraphQl\Model\Resolver\Category\DataProvider; +use Magento\Catalog\Api\Data\CategoryInterface; use Magento\Catalog\Model\ResourceModel\Category\CollectionFactory; /** @@ -46,6 +47,7 @@ public function getData(string $categoryPath): array $collection = $this->collectionFactory->create(); $collection->addAttributeToSelect(['name', 'url_key', 'url_path']); $collection->addAttributeToFilter('entity_id', $parentCategoryIds); + $collection->addAttributeToFilter(CategoryInterface::KEY_IS_ACTIVE, 1); foreach ($collection as $category) { $breadcrumbsData[] = [ diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Category/Image.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Category/Image.php index 5de7fdc10ff4a..549b1311000ec 100644 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Category/Image.php +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Category/Image.php @@ -7,14 +7,16 @@ namespace Magento\CatalogGraphQl\Model\Resolver\Category; +use Magento\Catalog\Model\Category; +use Magento\Catalog\Model\Category\FileInfo; +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\Filesystem\DirectoryList; use Magento\Framework\GraphQl\Config\Element\Field; +use Magento\Framework\GraphQl\Exception\GraphQlInputException; use Magento\Framework\GraphQl\Query\ResolverInterface; use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; -use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\UrlInterface; use Magento\Store\Api\Data\StoreInterface; -use Magento\Framework\Filesystem\DirectoryList; -use Magento\Catalog\Model\Category\FileInfo; -use Magento\Framework\GraphQl\Exception\GraphQlInputException; /** * Resolve category image to a fully qualified URL @@ -52,7 +54,7 @@ public function resolve( if (!isset($value['model'])) { throw new LocalizedException(__('"model" value should be specified')); } - /** @var \Magento\Catalog\Model\Category $category */ + /** @var Category $category */ $category = $value['model']; $imagePath = $category->getData('image'); if (empty($imagePath)) { @@ -60,7 +62,7 @@ public function resolve( } /** @var StoreInterface $store */ $store = $context->getExtensionAttributes()->getStore(); - $baseUrl = $store->getBaseUrl(); + $baseUrl = $store->getBaseUrl(UrlInterface::URL_TYPE_WEB); $filenameWithMedia = $this->fileInfo->isBeginsWithMediaDirectoryPath($imagePath) ? $imagePath : $this->formatFileNameWithMediaCategoryFolder($imagePath); diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/CategoryList.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/CategoryList.php index f32c5a1f38425..13db03bb2766b 100644 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/CategoryList.php +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/CategoryList.php @@ -65,7 +65,7 @@ public function resolve(Field $field, $context, ResolveInfo $info, array $value $args['filters']['ids'] = ['eq' => $store->getRootCategoryId()]; } try { - $filterResults = $this->categoryFilter->getResult($args, $store); + $filterResults = $this->categoryFilter->getResult($args, $store, [], $context); $rootCategoryIds = $filterResults['category_ids']; } catch (InputException $e) { throw new GraphQlInputException(__($e->getMessage())); diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/PriceRange.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/PriceRange.php index dbb52f2010930..805571d58d634 100644 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/PriceRange.php +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/PriceRange.php @@ -7,6 +7,7 @@ namespace Magento\CatalogGraphQl\Model\Resolver\Product; +use Magento\Catalog\Api\Data\ProductInterface; use Magento\CatalogGraphQl\Model\Resolver\Product\Price\Discount; use Magento\CatalogGraphQl\Model\Resolver\Product\Price\ProviderPool as PriceProviderPool; use Magento\Framework\GraphQl\Query\ResolverInterface; @@ -66,10 +67,12 @@ public function resolve( $returnArray = []; if (isset($requestedFields['minimum_price'])) { - $returnArray['minimum_price'] = $this->getMinimumProductPrice($product, $store); + $returnArray['minimum_price'] = $this->canShowPrice($product) ? + $this->getMinimumProductPrice($product, $store) : $this->formatEmptyResult(); } if (isset($requestedFields['maximum_price'])) { - $returnArray['maximum_price'] = $this->getMaximumProductPrice($product, $store); + $returnArray['maximum_price'] = $this->canShowPrice($product) ? + $this->getMaximumProductPrice($product, $store) : $this->formatEmptyResult(); } return $returnArray; } @@ -130,4 +133,39 @@ private function formatPrice(float $regularPrice, float $finalPrice, StoreInterf 'discount' => $this->discount->getDiscountByDifference($regularPrice, $finalPrice), ]; } + + /** + * Check if the product is allowed to show price + * + * @param ProductInterface $product + * @return bool + */ + private function canShowPrice($product): bool + { + if ($product->hasData('can_show_price') && $product->getData('can_show_price') === false) { + return false; + } + + return true; + } + + /** + * Format empty result + * + * @return array + */ + private function formatEmptyResult(): array + { + return [ + 'regular_price' => [ + 'value' => null, + 'currency' => null + ], + 'final_price' => [ + 'value' => null, + 'currency' => null + ], + 'discount' => null + ]; + } } diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/SpecialPrice.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/SpecialPrice.php index 1b42b0fde2bcb..c80cc3744876f 100644 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/SpecialPrice.php +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/SpecialPrice.php @@ -28,7 +28,10 @@ public function resolve(Field $field, $context, ResolveInfo $info, array $value /** @var PricingSpecialPrice $specialPrice */ $specialPrice = $product->getPriceInfo()->getPrice(PricingSpecialPrice::PRICE_CODE); - if ($specialPrice->getValue()) { + if ((!$product->hasData('can_show_price') + || ($product->hasData('can_show_price') && $product->getData('can_show_price') === true) + ) + && $specialPrice->getValue()) { return $specialPrice->getValue(); } diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products.php index 1a244b8a10546..ba158fab0120c 100644 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products.php +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products.php @@ -8,14 +8,11 @@ namespace Magento\CatalogGraphQl\Model\Resolver; use Magento\CatalogGraphQl\Model\Resolver\Products\Query\ProductQueryInterface; -use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; -use Magento\CatalogGraphQl\Model\Resolver\Products\Query\Filter; -use Magento\CatalogGraphQl\Model\Resolver\Products\Query\Search; use Magento\Framework\GraphQl\Config\Element\Field; +use Magento\Framework\GraphQl\Exception\GraphQlAuthorizationException; use Magento\Framework\GraphQl\Exception\GraphQlInputException; -use Magento\Framework\GraphQl\Query\Resolver\Argument\SearchCriteria\Builder; -use Magento\Framework\GraphQl\Query\Resolver\Argument\SearchCriteria\SearchFilter; use Magento\Framework\GraphQl\Query\ResolverInterface; +use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; use Magento\Catalog\Model\Layer\Resolver; use Magento\CatalogGraphQl\DataProvider\Product\SearchCriteriaBuilder; @@ -57,17 +54,7 @@ public function resolve( array $value = null, array $args = null ) { - if ($args['currentPage'] < 1) { - throw new GraphQlInputException(__('currentPage value must be greater than 0.')); - } - if ($args['pageSize'] < 1) { - throw new GraphQlInputException(__('pageSize value must be greater than 0.')); - } - if (!isset($args['search']) && !isset($args['filter'])) { - throw new GraphQlInputException( - __("'search' or 'filter' input argument is required.") - ); - } + $this->validateInput($args); $searchResult = $this->searchQuery->getResult($args, $info, $context); @@ -94,4 +81,29 @@ public function resolve( return $data; } + + /** + * Validate input arguments + * + * @param array $args + * @throws GraphQlAuthorizationException + * @throws GraphQlInputException + */ + private function validateInput(array $args) + { + if (isset($args['searchAllowed']) && $args['searchAllowed'] === false) { + throw new GraphQlAuthorizationException(__('Product search has been disabled.')); + } + if ($args['currentPage'] < 1) { + throw new GraphQlInputException(__('currentPage value must be greater than 0.')); + } + if ($args['pageSize'] < 1) { + throw new GraphQlInputException(__('pageSize value must be greater than 0.')); + } + if (!isset($args['search']) && !isset($args['filter'])) { + throw new GraphQlInputException( + __("'search' or 'filter' input argument is required.") + ); + } + } } diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/ProductSearch.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/ProductSearch.php index 4807cad54bd50..13bd29e83d87f 100644 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/ProductSearch.php +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/ProductSearch.php @@ -113,7 +113,7 @@ public function getList( $searchResults = $this->searchResultsFactory->create(); $searchResults->setSearchCriteria($searchCriteriaForCollection); $searchResults->setItems($collection->getItems()); - $searchResults->setTotalCount($searchResult->getTotalCount()); + $searchResults->setTotalCount($collection->getSize()); return $searchResults; } diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/SearchCriteria/CollectionProcessor/FilterProcessor/CategoryFilter.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/SearchCriteria/CollectionProcessor/FilterProcessor/CategoryFilter.php index f709f8cd6eb72..0cc00bb7b32fa 100644 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/SearchCriteria/CollectionProcessor/FilterProcessor/CategoryFilter.php +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/SearchCriteria/CollectionProcessor/FilterProcessor/CategoryFilter.php @@ -51,6 +51,11 @@ public function __construct( */ public function apply(Filter $filter, AbstractDb $collection) { + $conditionType = $filter->getConditionType(); + if ($conditionType !== 'eq') { + return true; + } + $categoryIds = $filter->getValue(); if (!is_array($categoryIds)) { $categoryIds = [$categoryIds]; @@ -64,7 +69,7 @@ public function apply(Filter $filter, AbstractDb $collection) $collection->addCategoryFilter($category); } - $categoryProductIds = array_unique(array_merge(...$categoryProducts)); + $categoryProductIds = array_unique(array_merge([], ...$categoryProducts)); $collection->addIdFilter($categoryProductIds); return true; } diff --git a/app/code/Magento/CatalogGraphQl/composer.json b/app/code/Magento/CatalogGraphQl/composer.json index 46d7454a6d7e2..463f974056749 100644 --- a/app/code/Magento/CatalogGraphQl/composer.json +++ b/app/code/Magento/CatalogGraphQl/composer.json @@ -7,6 +7,7 @@ "magento/module-eav": "*", "magento/module-catalog": "*", "magento/module-catalog-inventory": "*", + "magento/module-directory": "*", "magento/module-search": "*", "magento/module-store": "*", "magento/module-eav-graph-ql": "*", diff --git a/app/code/Magento/CatalogGraphQl/etc/di.xml b/app/code/Magento/CatalogGraphQl/etc/di.xml index 03f9d7ad03f04..fd3a834bff160 100644 --- a/app/code/Magento/CatalogGraphQl/etc/di.xml +++ b/app/code/Magento/CatalogGraphQl/etc/di.xml @@ -7,6 +7,7 @@ --> <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd"> <preference for="Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider\Product\CollectionProcessorInterface" type="Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider\Product\CompositeCollectionProcessor"/> + <preference for="Magento\CatalogGraphQl\Model\Resolver\Categories\DataProvider\Category\CollectionProcessorInterface" type="Magento\CatalogGraphQl\Model\Resolver\Categories\DataProvider\Category\CompositeCollectionProcessor"/> <type name="Magento\EavGraphQl\Model\Resolver\Query\Type"> <arguments> <argument name="customTypes" xsi:type="array"> @@ -53,6 +54,13 @@ </argument> </arguments> </type> + <type name="Magento\CatalogGraphQl\Model\Resolver\Categories\DataProvider\Category\CompositeCollectionProcessor"> + <arguments> + <argument name="collectionProcessors" xsi:type="array"> + <item name="catalog" xsi:type="object">Magento\CatalogGraphQl\Model\Resolver\Categories\DataProvider\Category\CollectionProcessor\CatalogProcessor</item> + </argument> + </arguments> + </type> <type name="Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider\Product\CollectionProcessor\SearchCriteriaProcessor"> <arguments> <argument name="searchCriteriaApplier" xsi:type="object">Magento\Catalog\Model\Api\SearchCriteria\ProductCollectionProcessor</argument> @@ -84,4 +92,9 @@ </argument> </arguments> </type> + <type name="Magento\CatalogGraphQl\Model\Resolver\Categories\DataProvider\Category\CollectionProcessor\CatalogProcessor"> + <arguments> + <argument name="collectionProcessor" xsi:type="object">Magento\Eav\Model\Api\SearchCriteria\CollectionProcessor</argument> + </arguments> + </type> </config> diff --git a/app/code/Magento/CatalogImportExport/Model/Export/Product.php b/app/code/Magento/CatalogImportExport/Model/Export/Product.php index bcd103c6d62ba..4a2ca0b4ec5ab 100644 --- a/app/code/Magento/CatalogImportExport/Model/Export/Product.php +++ b/app/code/Magento/CatalogImportExport/Model/Export/Product.php @@ -29,7 +29,7 @@ class Product extends \Magento\ImportExport\Model\Export\Entity\AbstractEntity { /** - * Attributes that should be exported + * Attributes that shouldn't be exported * * @var string[] */ @@ -478,6 +478,8 @@ protected function initCategories() protected function initTypeModels() { $productTypes = $this->_exportConfig->getEntityTypes($this->getEntityTypeCode()); + $disabledAttrs = []; + $indexValueAttributes = []; foreach ($productTypes as $productTypeName => $productTypeConfig) { if (!($model = $this->_typeFactory->create($productTypeConfig['model']))) { throw new \Magento\Framework\Exception\LocalizedException( @@ -494,13 +496,8 @@ protected function initTypeModels() } if ($model->isSuitable()) { $this->_productTypeModels[$productTypeName] = $model; - // phpcs:ignore Magento2.Performance.ForeachArrayMerge - $this->_disabledAttrs = array_merge($this->_disabledAttrs, $model->getDisabledAttrs()); - // phpcs:ignore Magento2.Performance.ForeachArrayMerge - $this->_indexValueAttributes = array_merge( - $this->_indexValueAttributes, - $model->getIndexValueAttributes() - ); + $disabledAttrs[] = $model->getDisabledAttrs(); + $indexValueAttributes[] = $model->getIndexValueAttributes(); } } if (!$this->_productTypeModels) { @@ -508,7 +505,10 @@ protected function initTypeModels() __('There are no product types available for export.') ); } - $this->_disabledAttrs = array_unique($this->_disabledAttrs); + $this->_disabledAttrs = array_unique(array_merge([], $this->_disabledAttrs, ...$disabledAttrs)); + $this->_indexValueAttributes = array_unique( + array_merge([], $this->_indexValueAttributes, ...$indexValueAttributes) + ); return $this; } @@ -1128,7 +1128,7 @@ private function wrapValue($value) protected function collectMultirawData() { $data = []; - $productIds = []; + $productLinkIds = []; $rowWebsites = []; $rowCategories = []; @@ -1138,7 +1138,6 @@ protected function collectMultirawData() /** @var \Magento\Catalog\Model\Product $item */ foreach ($collection as $item) { $productLinkIds[] = $item->getData($this->getProductEntityLinkField()); - $productIds[] = $item->getId(); $rowWebsites[$item->getId()] = array_intersect( array_keys($this->_websiteIdToCode), $item->getWebsites() diff --git a/app/code/Magento/CatalogImportExport/Model/Import/Product.php b/app/code/Magento/CatalogImportExport/Model/Import/Product.php index 74c6576e6bcdf..428961aa6ddf6 100644 --- a/app/code/Magento/CatalogImportExport/Model/Import/Product.php +++ b/app/code/Magento/CatalogImportExport/Model/Import/Product.php @@ -9,7 +9,6 @@ use Magento\Catalog\Api\ProductRepositoryInterface; use Magento\Catalog\Model\Config as CatalogConfig; use Magento\Catalog\Model\Product\Visibility; -use Magento\Catalog\Model\ResourceModel\Product\Link; use Magento\CatalogImportExport\Model\Import\Product\ImageTypeProcessor; use Magento\CatalogImportExport\Model\Import\Product\LinkProcessor; use Magento\CatalogImportExport\Model\Import\Product\MediaGalleryProcessor; @@ -23,6 +22,7 @@ use Magento\Framework\Exception\LocalizedException; use Magento\Framework\Exception\NoSuchEntityException; use Magento\Framework\Filesystem; +use Magento\Framework\Filesystem\Driver\File; use Magento\Framework\Intl\DateTimeFactory; use Magento\Framework\Model\ResourceModel\Db\ObjectRelationProcessor; use Magento\Framework\Model\ResourceModel\Db\TransactionManagerInterface; @@ -44,9 +44,10 @@ * @SuppressWarnings(PHPMD.ExcessivePublicCount) * @since 100.0.2 */ -class Product extends \Magento\ImportExport\Model\Import\Entity\AbstractEntity +class Product extends AbstractEntity { - const CONFIG_KEY_PRODUCT_TYPES = 'global/importexport/import_product_types'; + public const CONFIG_KEY_PRODUCT_TYPES = 'global/importexport/import_product_types'; + private const HASH_ALGORITHM = 'sha256'; /** * Size of bunch - part of products to save in one step. @@ -766,6 +767,11 @@ class Product extends \Magento\ImportExport\Model\Import\Entity\AbstractEntity */ private $linkProcessor; + /** + * @var File + */ + private $fileDriver; + /** * @param \Magento\Framework\Json\Helper\Data $jsonHelper * @param \Magento\ImportExport\Helper\Data $importExportData @@ -814,6 +820,7 @@ class Product extends \Magento\ImportExport\Model\Import\Entity\AbstractEntity * @param StatusProcessor|null $statusProcessor * @param StockProcessor|null $stockProcessor * @param LinkProcessor|null $linkProcessor + * @param File|null $fileDriver * @throws LocalizedException * @throws \Magento\Framework\Exception\FileSystemException * @SuppressWarnings(PHPMD.ExcessiveParameterList) @@ -866,7 +873,8 @@ public function __construct( ProductRepositoryInterface $productRepository = null, StatusProcessor $statusProcessor = null, StockProcessor $stockProcessor = null, - LinkProcessor $linkProcessor = null + LinkProcessor $linkProcessor = null, + ?File $fileDriver = null ) { $this->_eventManager = $eventManager; $this->stockRegistry = $stockRegistry; @@ -930,6 +938,7 @@ public function __construct( $this->dateTimeFactory = $dateTimeFactory ?? ObjectManager::getInstance()->get(DateTimeFactory::class); $this->productRepository = $productRepository ?? ObjectManager::getInstance() ->get(ProductRepositoryInterface::class); + $this->fileDriver = $fileDriver ?: ObjectManager::getInstance()->get(File::class); } /** @@ -1212,6 +1221,8 @@ private function initImagesArrayKeys() protected function _initTypeModels() { $productTypes = $this->_importConfig->getEntityTypes($this->getEntityTypeCode()); + $fieldsMap = []; + $specialAttributes = []; foreach ($productTypes as $productTypeName => $productTypeConfig) { $params = [$this, $productTypeName]; if (!($model = $this->_productTypeFactory->create($productTypeConfig['model'], ['params' => $params])) @@ -1231,14 +1242,13 @@ protected function _initTypeModels() if ($model->isSuitable()) { $this->_productTypeModels[$productTypeName] = $model; } - // phpcs:disable Magento2.Performance.ForeachArrayMerge.ForeachArrayMerge - $this->_fieldsMap = array_merge($this->_fieldsMap, $model->getCustomFieldsMapping()); - $this->_specialAttributes = array_merge($this->_specialAttributes, $model->getParticularAttributes()); - // phpcs:enable + $fieldsMap[] = $model->getCustomFieldsMapping(); + $specialAttributes[] = $model->getParticularAttributes(); } + $this->_fieldsMap = array_merge([], $this->_fieldsMap, ...$fieldsMap); $this->_initErrorTemplates(); // remove doubles - $this->_specialAttributes = array_unique($this->_specialAttributes); + $this->_specialAttributes = array_unique(array_merge([], $this->_specialAttributes, ...$specialAttributes)); return $this; } @@ -1569,7 +1579,10 @@ protected function _saveProducts() $uploadedImages = []; $previousType = null; $prevAttributeSet = null; + + $importDir = $this->_mediaDirectory->getAbsolutePath($this->getUploader()->getTmpDir()); $existingImages = $this->getExistingImages($bunch); + $this->addImageHashes($existingImages); foreach ($bunch as $rowNum => $rowData) { // reset category processor's failed categories array @@ -1737,7 +1750,8 @@ protected function _saveProducts() $position = 0; foreach ($rowImages as $column => $columnImages) { foreach ($columnImages as $columnImageKey => $columnImage) { - if (!isset($uploadedImages[$columnImage])) { + $uploadedFile = $this->getAlreadyExistedImage($rowExistingImages, $columnImage, $importDir); + if (!$uploadedFile && !isset($uploadedImages[$columnImage])) { $uploadedFile = $this->uploadMediaFiles($columnImage); $uploadedFile = $uploadedFile ?: $this->getSystemFile($columnImage); if ($uploadedFile) { @@ -1752,7 +1766,7 @@ protected function _saveProducts() ProcessingError::ERROR_LEVEL_NOT_CRITICAL ); } - } else { + } elseif (isset($uploadedImages[$columnImage])) { $uploadedFile = $uploadedImages[$columnImage]; } @@ -1781,8 +1795,7 @@ protected function _saveProducts() } if (isset($rowLabels[$column][$columnImageKey]) - && $rowLabels[$column][$columnImageKey] != - $currentFileData['label'] + && $rowLabels[$column][$columnImageKey] !== $currentFileData['label'] ) { $labelsForUpdate[] = [ 'label' => $rowLabels[$column][$columnImageKey], @@ -1791,7 +1804,7 @@ protected function _saveProducts() ]; } } else { - if ($column == self::COL_MEDIA_IMAGE) { + if ($column === self::COL_MEDIA_IMAGE) { $rowData[$column][] = $uploadedFile; } $mediaGallery[$storeId][$rowSku][$uploadedFile] = [ @@ -1907,24 +1920,14 @@ protected function _saveProducts() } } - $this->saveProductEntity( - $entityRowsIn, - $entityRowsUp - )->_saveProductWebsites( - $this->websitesCache - )->_saveProductCategories( - $this->categoriesCache - )->_saveProductTierPrices( - $tierPrices - )->_saveMediaGallery( - $mediaGallery - )->_saveProductAttributes( - $attributes - )->updateMediaGalleryVisibility( - $imagesForChangeVisibility - )->updateMediaGalleryLabels( - $labelsForUpdate - ); + $this->saveProductEntity($entityRowsIn, $entityRowsUp) + ->_saveProductWebsites($this->websitesCache) + ->_saveProductCategories($this->categoriesCache) + ->_saveProductTierPrices($tierPrices) + ->_saveMediaGallery($mediaGallery) + ->_saveProductAttributes($attributes) + ->updateMediaGalleryVisibility($imagesForChangeVisibility) + ->updateMediaGalleryLabels($labelsForUpdate); $this->_eventManager->dispatch( 'catalog_product_import_bunch_save_after', @@ -1938,6 +1941,87 @@ protected function _saveProducts() // phpcs:enable + /** + * Returns image hash by path + * + * @param string $path + * @return string + */ + private function getFileHash(string $path): string + { + return hash_file(self::HASH_ALGORITHM, $path); + } + + /** + * Returns existed image + * + * @param array $imageRow + * @param string $columnImage + * @param string $importDir + * @return string + */ + private function getAlreadyExistedImage(array $imageRow, string $columnImage, string $importDir): string + { + if (filter_var($columnImage, FILTER_VALIDATE_URL)) { + $hash = $this->getFileHash($columnImage); + } else { + $path = $importDir . DS . $columnImage; + $hash = $this->isFileExists($path) ? $this->getFileHash($path) : ''; + } + + return array_reduce( + $imageRow, + function ($exists, $file) use ($hash) { + if (!$exists && isset($file['hash']) && $file['hash'] === $hash) { + return $file['value']; + } + + return $exists; + }, + '' + ); + } + + /** + * Generate hashes for existing images for comparison with newly uploaded images. + * + * @param array $images + * @return void + */ + private function addImageHashes(array &$images): void + { + $productMediaPath = $this->filesystem->getDirectoryRead(DirectoryList::MEDIA) + ->getAbsolutePath(DS . 'catalog' . DS . 'product'); + + foreach ($images as $storeId => $skus) { + foreach ($skus as $sku => $files) { + foreach ($files as $path => $file) { + if ($this->fileDriver->isExists($productMediaPath . $file['value'])) { + $fileName = $productMediaPath . $file['value']; + $images[$storeId][$sku][$path]['hash'] = $this->getFileHash($fileName); + } + } + } + } + } + + /** + * Is file exists + * + * @param string $path + * @return bool + */ + private function isFileExists(string $path): bool + { + try { + $fileExists = $this->fileDriver->isExists($path); + } catch (\Exception $exception) { + $fileExists = false; + } + + return $fileExists; + } + /** * Clears entries from Image Set and Row Data marked as no_selection * @@ -1949,9 +2033,8 @@ private function clearNoSelectionImages($rowImages, $rowData) { foreach ($rowImages as $column => $columnImages) { foreach ($columnImages as $key => $image) { - if ($image == 'no_selection') { - unset($rowImages[$column][$key]); - unset($rowData[$column]); + if ($image === 'no_selection') { + unset($rowImages[$column][$key], $rowData[$column]); } } } @@ -2094,6 +2177,21 @@ protected function _saveProductTierPrices(array $tierPriceData) return $this; } + /** + * Returns the import directory if specified or a default import directory (media/import). + * + * @return string + */ + private function getImportDir(): string + { + $dirConfig = DirectoryList::getDefaultConfig(); + $dirAddon = $dirConfig[DirectoryList::MEDIA][DirectoryList::PATH]; + + return empty($this->_parameters[Import::FIELD_NAME_IMG_FILE_DIR]) + ? $dirAddon . DS . $this->_mediaDirectory->getRelativePath('import') + : $this->_parameters[Import::FIELD_NAME_IMG_FILE_DIR]; + } + /** * Returns an object for upload a media files * @@ -2110,12 +2208,13 @@ protected function _getUploader() $dirConfig = DirectoryList::getDefaultConfig(); $dirAddon = $dirConfig[DirectoryList::MEDIA][DirectoryList::PATH]; - if (!empty($this->_parameters[Import::FIELD_NAME_IMG_FILE_DIR])) { - $tmpPath = $this->_parameters[Import::FIELD_NAME_IMG_FILE_DIR]; - } else { - $tmpPath = $dirAddon . '/' . $this->_mediaDirectory->getRelativePath('import'); + // make media folder a primary folder for media in external storages + if (!is_a($this->_mediaDirectory->getDriver(), File::class)) { + $dirAddon = DirectoryList::MEDIA; } + $tmpPath = $this->getImportDir(); + if (!$fileUploader->setTmpDir($tmpPath)) { throw new LocalizedException( __('File directory \'%1\' is not readable.', $tmpPath) diff --git a/app/code/Magento/CatalogImportExport/Model/Import/Product/Option.php b/app/code/Magento/CatalogImportExport/Model/Import/Product/Option.php index e12fc726f1056..7757a2ba5eb7d 100644 --- a/app/code/Magento/CatalogImportExport/Model/Import/Product/Option.php +++ b/app/code/Magento/CatalogImportExport/Model/Import/Product/Option.php @@ -421,7 +421,7 @@ public function __construct( $this->_initMessageTemplates(); - $this->_initProductsSku()->_initOldCustomOptions(); + $this->_initProductsSku(); } /** @@ -606,6 +606,9 @@ protected function _initOldCustomOptions() 'option_title.store_id = ?', $storeId ); + if (!empty($this->_newOptionsOldData)) { + $this->_optionCollection->addProductToFilter(array_keys($this->_newOptionsOldData)); + } $this->_byPagesIterator->iterate($this->_optionCollection, $this->_pageSize, [$addCustomOptions]); } @@ -614,6 +617,20 @@ protected function _initOldCustomOptions() return $this; } + /** + * Get existing custom options data + * + * @return array + */ + private function getOldCustomOptions(): array + { + if ($this->_oldCustomOptions === null) { + $this->_initOldCustomOptions(); + } + + return $this->_oldCustomOptions; + } + /** * Imported entity type code getter * @@ -717,9 +734,9 @@ protected function _findOldOptionsWithTheSameTitles() $errorRows = []; foreach ($this->_newOptionsOldData as $productId => $options) { foreach ($options as $outerData) { - if (isset($this->_oldCustomOptions[$productId])) { + if (isset($this->getOldCustomOptions()[$productId])) { $optionsCount = 0; - foreach ($this->_oldCustomOptions[$productId] as $innerData) { + foreach ($this->getOldCustomOptions()[$productId] as $innerData) { if (count($outerData['titles']) == count($innerData['titles'])) { $outerTitles = $outerData['titles']; $innerTitles = $innerData['titles']; @@ -753,8 +770,8 @@ protected function _findNewOldOptionsTypeMismatch() $errorRows = []; foreach ($this->_newOptionsOldData as $productId => $options) { foreach ($options as $outerData) { - if (isset($this->_oldCustomOptions[$productId])) { - foreach ($this->_oldCustomOptions[$productId] as $innerData) { + if (isset($this->getOldCustomOptions()[$productId])) { + foreach ($this->getOldCustomOptions()[$productId] as $innerData) { if (count($outerData['titles']) == count($innerData['titles'])) { $outerTitles = $outerData['titles']; $innerTitles = $innerData['titles']; @@ -784,9 +801,9 @@ protected function _findNewOldOptionsTypeMismatch() protected function _findExistingOptionId(array $newOptionData, array $newOptionTitles) { $productId = $newOptionData['product_id']; - if (isset($this->_oldCustomOptions[$productId])) { + if (isset($this->getOldCustomOptions()[$productId])) { ksort($newOptionTitles); - $existingOptions = $this->_oldCustomOptions[$productId]; + $existingOptions = $this->getOldCustomOptions()[$productId]; foreach ($existingOptions as $optionId => $optionData) { if ($optionData['type'] == $newOptionData['type'] && $optionData['titles'][Store::DEFAULT_STORE_ID] == $newOptionTitles[Store::DEFAULT_STORE_ID] 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 6571b16c87565..bd17cfd2cd7f1 100644 --- a/app/code/Magento/CatalogImportExport/Model/Import/Product/Type/AbstractType.php +++ b/app/code/Magento/CatalogImportExport/Model/Import/Product/Type/AbstractType.php @@ -533,6 +533,7 @@ public function prepareAttributesWithDefaultValueForSave(array $rowData, $withDe if ($attrParams['is_static']) { continue; } + $attrCode = mb_strtolower($attrCode); if (isset($rowData[$attrCode]) && strlen(trim($rowData[$attrCode]))) { if (in_array($attrParams['type'], ['select', 'boolean'])) { $resultAttrs[$attrCode] = $attrParams['options'][strtolower($rowData[$attrCode])]; diff --git a/app/code/Magento/CatalogImportExport/Model/Import/Uploader.php b/app/code/Magento/CatalogImportExport/Model/Import/Uploader.php index 487ffaffa95e9..d2a0019349ef2 100644 --- a/app/code/Magento/CatalogImportExport/Model/Import/Uploader.php +++ b/app/code/Magento/CatalogImportExport/Model/Import/Uploader.php @@ -9,6 +9,7 @@ use Magento\Framework\App\ObjectManager; use Magento\Framework\Exception\ValidatorException; use Magento\Framework\Filesystem; +use Magento\Framework\Filesystem\Directory\TargetDirectory; use Magento\Framework\Filesystem\DriverPool; /** @@ -111,6 +112,16 @@ class Uploader extends \Magento\MediaStorage\Model\File\Uploader */ private $fileSystem; + /** + * Directory and filename must be no more than 255 characters in length + */ + private $maxFilenameLength = 255; + + /** + * @var TargetDirectory + */ + private $targetDirectory; + /** * @param \Magento\MediaStorage\Helper\File\Storage\Database $coreFileStorageDb * @param \Magento\MediaStorage\Helper\File\Storage $coreFileStorage @@ -120,6 +131,7 @@ class Uploader extends \Magento\MediaStorage\Model\File\Uploader * @param Filesystem\File\ReadFactory $readFactory * @param string|null $filePath * @param \Magento\Framework\Math\Random|null $random + * @param TargetDirectory|null $targetDirectory * @throws \Magento\Framework\Exception\FileSystemException * @throws \Magento\Framework\Exception\LocalizedException */ @@ -131,7 +143,8 @@ public function __construct( Filesystem $filesystem, Filesystem\File\ReadFactory $readFactory, $filePath = null, - \Magento\Framework\Math\Random $random = null + \Magento\Framework\Math\Random $random = null, + TargetDirectory $targetDirectory = null ) { $this->_imageFactory = $imageFactory; $this->_coreFileStorageDb = $coreFileStorageDb; @@ -144,6 +157,7 @@ public function __construct( $this->_setUploadFile($filePath); } $this->random = $random ?: ObjectManager::getInstance()->get(\Magento\Framework\Math\Random::class); + $this->targetDirectory = $targetDirectory ?: ObjectManager::getInstance()->get(TargetDirectory::class); } /** @@ -183,11 +197,19 @@ public function move($fileName, $renameFileOff = false) } $this->_setUploadFile($tmpFilePath); - $destDir = $this->_directory->getAbsolutePath($this->getDestDir()); + $rootDirectory = $this->getTargetDirectory()->getDirectoryRead(DirectoryList::ROOT); + $destDir = $rootDirectory->getAbsolutePath($this->getDestDir()); $result = $this->save($destDir); unset($result['path']); $result['name'] = self::getCorrectFileName($result['name']); + // Directory and filename must be no more than 255 characters in length + if (strlen($result['file']) > $this->maxFilenameLength) { + throw new \LengthException( + __('Filename is too long; must be %1 characters or less', $this->maxFilenameLength) + ); + } + return $result; } @@ -231,6 +253,20 @@ private function downloadFileFromUrl($url, $driver) return $tmpFilePath; } + /** + * Retrieves target directory. + * + * @return TargetDirectory + */ + private function getTargetDirectory(): TargetDirectory + { + if (!isset($this->targetDirectory)) { + $this->targetDirectory = ObjectManager::getInstance()->get(TargetDirectory::class); + } + + return $this->targetDirectory; + } + /** * Prepare information about the file for moving * @@ -369,7 +405,8 @@ public function getDestDir() */ public function setDestDir($path) { - if (is_string($path) && $this->_directory->isWritable($path)) { + $directoryRoot = $this->getTargetDirectory()->getDirectoryWrite(DirectoryList::ROOT); + if (is_string($path) && $directoryRoot->isWritable($path)) { $this->_destDir = $path; return true; } @@ -392,7 +429,8 @@ protected function _moveFile($tmpPath, $destPath) $destinationRealPath = $this->_directory->getDriver()->getRealPath($destPath); $relativeDestPath = $this->_directory->getRelativePath($destPath); $isSameFile = $tmpRealPath === $destinationRealPath; - return $isSameFile ?: $this->_directory->copyFile($tmpPath, $relativeDestPath); + $rootDirectory = $this->getTargetDirectory()->getDirectoryWrite(DirectoryList::ROOT); + return $isSameFile ?: $this->_directory->copyFile($tmpPath, $relativeDestPath, $rootDirectory); } else { return false; } diff --git a/app/code/Magento/CatalogImportExport/Test/Unit/Model/Import/Product/Type/AbstractTypeTest.php b/app/code/Magento/CatalogImportExport/Test/Unit/Model/Import/Product/Type/AbstractTypeTest.php index 08915fb31a8aa..9453075f99e7c 100644 --- a/app/code/Magento/CatalogImportExport/Test/Unit/Model/Import/Product/Type/AbstractTypeTest.php +++ b/app/code/Magento/CatalogImportExport/Test/Unit/Model/Import/Product/Type/AbstractTypeTest.php @@ -6,6 +6,7 @@ */ namespace Magento\CatalogImportExport\Test\Unit\Model\Import\Product\Type; +use Magento\Catalog\Model\ResourceModel\Product\Attribute\CollectionFactory as AttributeCollectionFactory; use Magento\CatalogImportExport\Model\Import\Product; use Magento\CatalogImportExport\Model\Import\Product\RowValidatorInterface; use Magento\CatalogImportExport\Model\Import\Product\Type\AbstractType as AbstractType; @@ -13,6 +14,7 @@ use Magento\Eav\Model\Entity\Attribute; use Magento\Eav\Model\Entity\Attribute\Set; use Magento\Eav\Model\ResourceModel\Entity\Attribute\Set\Collection; +use Magento\Eav\Model\ResourceModel\Entity\Attribute\Set\CollectionFactory as AttributeSetCollectionFactory; use Magento\Framework\App\ResourceConnection; use Magento\Framework\DB\Adapter\AdapterInterface; use Magento\Framework\DB\Adapter\Pdo\Mysql; @@ -68,12 +70,12 @@ protected function setUp(): void { $this->entityModel = $this->createMock(Product::class); $attrSetColFactory = $this->createPartialMock( - \Magento\Eav\Model\ResourceModel\Entity\Attribute\Set\CollectionFactory::class, + AttributeSetCollectionFactory::class, ['create'] ); $attrSetCollection = $this->createMock(Collection::class); $attrColFactory = $this->createPartialMock( - \Magento\Catalog\Model\ResourceModel\Product\Attribute\CollectionFactory::class, + AttributeCollectionFactory::class, ['create'] ); $attributeSet = $this->createMock(Set::class); @@ -100,14 +102,22 @@ protected function setUp(): void ) ->disableOriginalConstructor() ->getMock(); - $attribute->expects($this->any())->method('getIsVisible')->willReturn(true); - $attribute->expects($this->any())->method('getIsGlobal')->willReturn(true); - $attribute->expects($this->any())->method('getIsRequired')->willReturn(true); - $attribute->expects($this->any())->method('getIsUnique')->willReturn(true); - $attribute->expects($this->any())->method('getFrontendLabel')->willReturn('frontend_label'); - $attribute->expects($this->any())->method('getApplyTo')->willReturn(['simple']); - $attribute->expects($this->any())->method('getDefaultValue')->willReturn('default_value'); - $attribute->expects($this->any())->method('usesSource')->willReturn(true); + $attribute->method('getIsVisible') + ->willReturn(true); + $attribute->method('getIsGlobal') + ->willReturn(true); + $attribute->method('getIsRequired') + ->willReturn(true); + $attribute->method('getIsUnique') + ->willReturn(true); + $attribute->method('getFrontendLabel') + ->willReturn('frontend_label'); + $attribute->method('getApplyTo') + ->willReturn(['simple']); + $attribute->method('getDefaultValue') + ->willReturn('default_value'); + $attribute->method('usesSource') + ->willReturn(true); $entityAttributes = [ [ @@ -123,38 +133,54 @@ protected function setUp(): void $attribute2 = clone $attribute; $attribute3 = clone $attribute; - $attribute1->expects($this->any())->method('getId')->willReturn('1'); - $attribute1->expects($this->any())->method('getAttributeCode')->willReturn('attr_code'); - $attribute1->expects($this->any())->method('getFrontendInput')->willReturn('multiselect'); - $attribute1->expects($this->any())->method('isStatic')->willReturn(true); - - $attribute2->expects($this->any())->method('getId')->willReturn('2'); - $attribute2->expects($this->any())->method('getAttributeCode')->willReturn('boolean_attribute'); - $attribute2->expects($this->any())->method('getFrontendInput')->willReturn('boolean'); - $attribute2->expects($this->any())->method('isStatic')->willReturn(false); - - $attribute3->expects($this->any())->method('getId')->willReturn('3'); - $attribute3->expects($this->any())->method('getAttributeCode')->willReturn('text_attribute'); - $attribute3->expects($this->any())->method('getFrontendInput')->willReturn('text'); - $attribute3->expects($this->any())->method('isStatic')->willReturn(false); - - $this->entityModel->expects($this->any())->method('getEntityTypeId')->willReturn(3); - $this->entityModel->expects($this->any())->method('getAttributeOptions')->willReturnOnConsecutiveCalls( - ['option1', 'option2'], - ['yes' => 1, 'no' => 0] - ); - $attrSetColFactory->expects($this->any())->method('create')->willReturn($attrSetCollection); - $attrSetCollection->expects($this->any())->method('setEntityTypeFilter')->willReturn([$attributeSet]); - $attrColFactory->expects($this->any())->method('create')->willReturn($attrCollection); - $attrCollection->expects($this->any()) - ->method('setAttributeSetFilter') + $attribute1->method('getId') + ->willReturn('1'); + $attribute1->method('getAttributeCode') + ->willReturn('attr_code'); + $attribute1->method('getFrontendInput') + ->willReturn('multiselect'); + $attribute1->method('isStatic') + ->willReturn(true); + + $attribute2->method('getId') + ->willReturn('2'); + $attribute2->method('getAttributeCode') + ->willReturn('boolean_attribute'); + $attribute2->method('getFrontendInput') + ->willReturn('boolean'); + $attribute2->method('isStatic') + ->willReturn(false); + + $attribute3->method('getId') + ->willReturn('3'); + $attribute3->method('getAttributeCode') + ->willReturn('Text_attribute'); + $attribute3->method('getFrontendInput') + ->willReturn('text'); + $attribute3->method('isStatic') + ->willReturn(false); + + $this->entityModel->method('getEntityTypeId') + ->willReturn(3); + $this->entityModel->method('getAttributeOptions') + ->willReturnOnConsecutiveCalls( + ['option1', 'option2'], + ['yes' => 1, 'no' => 0] + ); + $attrSetColFactory->method('create') + ->willReturn($attrSetCollection); + $attrSetCollection->method('setEntityTypeFilter') + ->willReturn([$attributeSet]); + $attrColFactory->method('create') + ->willReturn($attrCollection); + $attrCollection->method('setAttributeSetFilter') ->willReturn([$attribute1, $attribute2, $attribute3]); - $attributeSet->expects($this->any())->method('getId')->willReturn(1); - $attributeSet->expects($this->any())->method('getAttributeSetName')->willReturn('attribute_set_name'); + $attributeSet->method('getId') + ->willReturn(1); + $attributeSet->method('getAttributeSetName') + ->willReturn('attribute_set_name'); - $attrCollection - ->expects($this->any()) - ->method('addFieldToFilter') + $attrCollection->method('addFieldToFilter') ->with( ['main_table.attribute_id', 'main_table.attribute_code'], [ @@ -193,19 +219,26 @@ protected function setUp(): void 'getConnection', ] ); - $this->select->expects($this->any())->method('from')->willReturnSelf(); - $this->select->expects($this->any())->method('where')->willReturnSelf(); - $this->select->expects($this->any())->method('joinLeft')->willReturnSelf(); - $this->connection->expects($this->any())->method('select')->willReturn($this->select); + $this->select->method('from') + ->willReturnSelf(); + $this->select->method('where') + ->willReturnSelf(); + $this->select->method('joinLeft') + ->willReturnSelf(); + $this->connection->method('select') + ->willReturn($this->select); $connection = $this->createMock(Mysql::class); - $connection->expects($this->any())->method('quoteInto')->willReturn('query'); - $this->select->expects($this->any())->method('getConnection')->willReturn($connection); - $this->connection->expects($this->any())->method('insertOnDuplicate')->willReturnSelf(); - $this->connection->expects($this->any())->method('delete')->willReturnSelf(); - $this->connection->expects($this->any())->method('quoteInto')->willReturn(''); - $this->connection - ->expects($this->any()) - ->method('fetchAll') + $connection->method('quoteInto') + ->willReturn('query'); + $this->select->method('getConnection') + ->willReturn($connection); + $this->connection->method('insertOnDuplicate') + ->willReturnSelf(); + $this->connection->method('delete') + ->willReturnSelf(); + $this->connection->method('quoteInto') + ->willReturn(''); + $this->connection->method('fetchAll') ->willReturn($entityAttributes); $this->resource = $this->createPartialMock( @@ -215,12 +248,10 @@ protected function setUp(): void 'getTableName', ] ); - $this->resource->expects($this->any())->method('getConnection')->willReturn( - $this->connection - ); - $this->resource->expects($this->any())->method('getTableName')->willReturn( - 'tableName' - ); + $this->resource->method('getConnection') + ->willReturn($this->connection); + $this->resource->method('getTableName') + ->willReturn('tableName'); $this->objectManagerHelper = new ObjectManagerHelper($this); $this->simpleType = $this->objectManagerHelper->getObject( @@ -233,9 +264,7 @@ protected function setUp(): void ] ); - $this->abstractType = $this->getMockBuilder( - \Magento\CatalogImportExport\Model\Import\Product\Type\AbstractType::class - ) + $this->abstractType = $this->getMockBuilder(AbstractType::class) ->disableOriginalConstructor() ->getMockForAbstractClass(); } @@ -277,8 +306,10 @@ public function testIsRowValidSuccess() { $rowData = ['_attribute_set' => 'attribute_set_name']; $rowNum = 1; - $this->entityModel->expects($this->any())->method('getRowScope')->willReturn(null); - $this->entityModel->expects($this->never())->method('addRowError'); + $this->entityModel->method('getRowScope') + ->willReturn(null); + $this->entityModel->expects($this->never()) + ->method('addRowError'); $this->setPropertyValue( $this->simpleType, '_attributes', @@ -296,8 +327,9 @@ public function testIsRowValidError() 'sku' => 'sku' ]; $rowNum = 1; - $this->entityModel->expects($this->any())->method('getRowScope')->willReturn(1); - $this->entityModel->expects($this->once())->method('addRowError') + $this->entityModel->method('getRowScope') + ->willReturn(1); + $this->entityModel->method('addRowError') ->with( RowValidatorInterface::ERROR_VALUE_IS_REQUIRED, 1, diff --git a/app/code/Magento/CatalogImportExport/Test/Unit/Model/Import/ProductTest.php b/app/code/Magento/CatalogImportExport/Test/Unit/Model/Import/ProductTest.php index 52769859a74ac..2eb8c86a34686 100644 --- a/app/code/Magento/CatalogImportExport/Test/Unit/Model/Import/ProductTest.php +++ b/app/code/Magento/CatalogImportExport/Test/Unit/Model/Import/ProductTest.php @@ -39,6 +39,7 @@ use Magento\Framework\Exception\LocalizedException; use Magento\Framework\Filesystem; use Magento\Framework\Filesystem\Directory\WriteInterface; +use Magento\Framework\Filesystem\Driver\File as DriverFile; use Magento\Framework\Indexer\IndexerRegistry; use Magento\Framework\Json\Helper\Data; use Magento\Framework\Model\ResourceModel\Db\ObjectRelationProcessor; @@ -207,6 +208,9 @@ class ProductTest extends AbstractImportTestCase /** @var ImageTypeProcessor|MockObject */ protected $imageTypeProcessor; + /** @var DriverFile|MockObject */ + private $driverFile; + /** * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ @@ -374,6 +378,10 @@ protected function setUp(): void $this->errorAggregator = $this->getErrorAggregatorObject(); + $this->driverFile = $this->getMockBuilder(DriverFile::class) + ->disableOriginalConstructor() + ->getMock(); + $this->data = []; $this->imageTypeProcessor = $this->getMockBuilder(ImageTypeProcessor::class) @@ -1336,6 +1344,10 @@ public function testFillUploaderObject($isRead, $isWrite, $message) ->with('pub/media/catalog/product') ->willReturn($isWrite); + $this->_mediaDirectory + ->method('getDriver') + ->willReturn($this->driverFile); + $this->_mediaDirectory ->method('getRelativePath') ->willReturnMap( diff --git a/app/code/Magento/CatalogImportExport/Test/Unit/Model/Import/UploaderTest.php b/app/code/Magento/CatalogImportExport/Test/Unit/Model/Import/UploaderTest.php index 0af8a4904463a..bc8fba5e2b919 100644 --- a/app/code/Magento/CatalogImportExport/Test/Unit/Model/Import/UploaderTest.php +++ b/app/code/Magento/CatalogImportExport/Test/Unit/Model/Import/UploaderTest.php @@ -9,6 +9,8 @@ use Magento\CatalogImportExport\Model\Import\Uploader; use Magento\Framework\Exception\LocalizedException; use Magento\Framework\Filesystem; +use Magento\Framework\Filesystem\Directory\TargetDirectory; +use Magento\Framework\Filesystem\Directory\Write; use Magento\Framework\Filesystem\Driver\Http; use Magento\Framework\Filesystem\Driver\Https; use Magento\Framework\Filesystem\DriverPool; @@ -58,7 +60,7 @@ class UploaderTest extends TestCase protected $readFactory; /** - * @var \Magento\Framework\Filesystem\Directory\Writer|MockObject + * @var WriteInterface|MockObject */ protected $directoryMock; @@ -72,6 +74,11 @@ class UploaderTest extends TestCase */ protected $uploader; + /** + * @var TargetDirectory|MockObject + */ + private $targetDirectory; + protected function setUp(): void { $this->coreFileStorageDb = $this->getMockBuilder(Database::class) @@ -96,7 +103,7 @@ protected function setUp(): void ->setMethods(['create']) ->getMock(); - $this->directoryMock = $this->getMockBuilder(\Magento\Framework\Filesystem\Directory\Writer::class) + $this->directoryMock = $this->getMockBuilder(Write::class) ->setMethods(['writeFile', 'getRelativePath', 'isWritable', 'getAbsolutePath']) ->disableOriginalConstructor() ->getMock(); @@ -114,6 +121,13 @@ protected function setUp(): void ->setMethods(['getRandomString']) ->getMock(); + $this->targetDirectory = $this->getMockBuilder(TargetDirectory::class) + ->disableOriginalConstructor() + ->setMethods(['getDirectoryWrite', 'getDirectoryRead']) + ->getMock(); + $this->targetDirectory->method('getDirectoryWrite')->willReturn($this->directoryMock); + $this->targetDirectory->method('getDirectoryRead')->willReturn($this->directoryMock); + $this->uploader = $this->getMockBuilder(Uploader::class) ->setConstructorArgs( [ @@ -124,7 +138,8 @@ protected function setUp(): void $this->filesystem, $this->readFactory, null, - $this->random + $this->random, + $this->targetDirectory ] ) ->setMethods(['_setUploadFile', 'save', 'getTmpDir', 'checkAllowedExtension']) @@ -186,14 +201,21 @@ public function testMoveFileUrl($fileUrl, $expectedHost, $expectedFileName, $che ->willReturn($destDir . '/' . $expectedFileName); $this->uploader->expects($this->once())->method('_setUploadFile') ->willReturnSelf(); + + $returnFile = $destDir . DIRECTORY_SEPARATOR . $expectedFileName; + $this->uploader->expects($this->once())->method('save') ->with($destDir . '/' . $expectedFileName) - ->willReturn(['name' => $expectedFileName, 'path' => 'absPath']); + ->willReturn([ + 'name' => $expectedFileName, + 'path' => 'absPath', + 'file' => $returnFile + ]); $this->uploader->setDestDir($destDir); $result = $this->uploader->move($fileUrl); - $this->assertEquals(['name' => $expectedFileName], $result); + $this->assertEquals(['name' => $expectedFileName, 'file' => $returnFile], $result); $this->assertArrayNotHasKey('path', $result); } @@ -209,11 +231,50 @@ public function testMoveFileName() //Check invoking of getTmpDir(), _setUploadFile(), save() methods. $this->uploader->expects($this->once())->method('getTmpDir')->willReturn(''); $this->uploader->expects($this->once())->method('_setUploadFile')->willReturnSelf(); + + $returnFile = $destDir . DIRECTORY_SEPARATOR . $fileName; + $this->uploader->expects($this->once())->method('save')->with($destDir . '/' . $fileName) - ->willReturn(['name' => $fileName]); + ->willReturn(['name' => $fileName, 'file' => $returnFile]); $this->uploader->setDestDir($destDir); - $this->assertEquals(['name' => $fileName], $this->uploader->move($fileName)); + $this->assertEquals(['name' => $fileName, 'file' => $returnFile], $this->uploader->move($fileName)); + } + + public function testFilenameLength() + { + $destDir = 'var/tmp/' . str_repeat('testFilenameLength', 13); // 242 characters + + $fileName = \uniqid(); // 13 characters + + $this->directoryMock->expects($this->once()) + ->method('isWritable') + ->with($destDir) + ->willReturn(true); + + $this->directoryMock->expects($this->once()) + ->method('getRelativePath') + ->with($fileName) + ->willReturn(null); + + $this->directoryMock->expects($this->once()) + ->method('getAbsolutePath') + ->with($destDir) + ->willReturn($destDir); + + $this->uploader->expects($this->once()) + ->method('save') + ->with($destDir) + ->willReturn([ + 'name' => $fileName, + 'file' => $destDir . DIRECTORY_SEPARATOR . $fileName // 256 characters + ]); + + $this->uploader->setDestDir($destDir); + + $this->expectException(\LengthException::class); + + $this->uploader->move($fileName); } /** @@ -227,9 +288,9 @@ public function testMoveFileUrlDrivePool($fileUrl, $expectedHost, $expectedDrive ->addMethods(['readAll']) ->onlyMethods(['isExists']) ->getMock(); - $driverMock->expects($this->any())->method('isExists')->willReturn(true); - $driverMock->expects($this->any())->method('readAll')->willReturn(null); - $driverPool->expects($this->any())->method('getDriver')->willReturn($driverMock); + $driverMock->method('isExists')->willReturn(true); + $driverMock->method('readAll')->willReturn(null); + $driverPool->method('getDriver')->willReturn($driverMock); $readFactory = $this->getMockBuilder(ReadFactory::class) ->setConstructorArgs( @@ -240,10 +301,11 @@ public function testMoveFileUrlDrivePool($fileUrl, $expectedHost, $expectedDrive ->setMethods(['create']) ->getMock(); - $readFactory->expects($this->any())->method('create') + $readFactory->method('create') ->with($expectedHost, $expectedScheme) ->willReturn($driverMock); + /** @var Uploader $uploaderMock */ $uploaderMock = $this->getMockBuilder(Uploader::class) ->setConstructorArgs( [ @@ -253,6 +315,9 @@ public function testMoveFileUrlDrivePool($fileUrl, $expectedHost, $expectedDrive $this->validator, $this->filesystem, $readFactory, + null, + $this->random, + $this->targetDirectory ] ) ->getMock(); diff --git a/app/code/Magento/CatalogInventory/Model/Indexer/Stock/AbstractAction.php b/app/code/Magento/CatalogInventory/Model/Indexer/Stock/AbstractAction.php index 85fee62eb4303..4ea6b6bcfde9a 100644 --- a/app/code/Magento/CatalogInventory/Model/Indexer/Stock/AbstractAction.php +++ b/app/code/Magento/CatalogInventory/Model/Indexer/Stock/AbstractAction.php @@ -1,7 +1,5 @@ <?php /** - * @category Magento - * @package Magento_CatalogInventory * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ @@ -14,8 +12,6 @@ /** * Abstract action reindex class - * - * @package Magento\CatalogInventory\Model\Indexer\Stock */ abstract class AbstractAction { @@ -283,6 +279,8 @@ private function doReindex($productIds = []) } /** + * Get cache cleaner object + * * @return CacheCleaner */ private function getCacheCleaner() diff --git a/app/code/Magento/CatalogInventory/Model/Indexer/Stock/Action/Row.php b/app/code/Magento/CatalogInventory/Model/Indexer/Stock/Action/Row.php index c7dfcffee3d31..9e5e39e4aeb53 100644 --- a/app/code/Magento/CatalogInventory/Model/Indexer/Stock/Action/Row.php +++ b/app/code/Magento/CatalogInventory/Model/Indexer/Stock/Action/Row.php @@ -1,7 +1,5 @@ <?php /** - * @category Magento - * @package Magento_CatalogInventory * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ @@ -10,8 +8,6 @@ /** * Class Row reindex action - * - * @package Magento\CatalogInventory\Model\Indexer\Stock\Action */ class Row extends \Magento\CatalogInventory\Model\Indexer\Stock\AbstractAction { diff --git a/app/code/Magento/CatalogInventory/Model/Indexer/Stock/Action/Rows.php b/app/code/Magento/CatalogInventory/Model/Indexer/Stock/Action/Rows.php index f107955f0201e..a6176df3b107e 100644 --- a/app/code/Magento/CatalogInventory/Model/Indexer/Stock/Action/Rows.php +++ b/app/code/Magento/CatalogInventory/Model/Indexer/Stock/Action/Rows.php @@ -1,7 +1,5 @@ <?php /** - * @category Magento - * @package Magento_CatalogInventory * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ @@ -10,8 +8,6 @@ /** * Class Rows reindex action for mass actions - * - * @package Magento\CatalogInventory\Model\Indexer\Stock\Action */ class Rows extends \Magento\CatalogInventory\Model\Indexer\Stock\AbstractAction { diff --git a/app/code/Magento/CatalogInventory/Model/Indexer/Stock/CacheCleaner.php b/app/code/Magento/CatalogInventory/Model/Indexer/Stock/CacheCleaner.php index f1cef90fc68ca..005ffd11ac7a1 100644 --- a/app/code/Magento/CatalogInventory/Model/Indexer/Stock/CacheCleaner.php +++ b/app/code/Magento/CatalogInventory/Model/Indexer/Stock/CacheCleaner.php @@ -1,7 +1,5 @@ <?php /** - * @category Magento - * @package Magento_CatalogInventory * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ diff --git a/app/code/Magento/CatalogInventory/Model/Indexer/Stock/Processor.php b/app/code/Magento/CatalogInventory/Model/Indexer/Stock/Processor.php index 403f64e7f77f8..73c4a8833e433 100644 --- a/app/code/Magento/CatalogInventory/Model/Indexer/Stock/Processor.php +++ b/app/code/Magento/CatalogInventory/Model/Indexer/Stock/Processor.php @@ -1,7 +1,5 @@ <?php /** - * @category Magento - * @package Magento_CatalogInventory * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ @@ -11,7 +9,7 @@ class Processor extends \Magento\Framework\Indexer\AbstractProcessor { /** - * Indexer ID + * Get Indexer ID for cataloginventory_stock */ const INDEXER_ID = 'cataloginventory_stock'; } diff --git a/app/code/Magento/CatalogInventory/Model/Quote/Item/QuantityValidator.php b/app/code/Magento/CatalogInventory/Model/Quote/Item/QuantityValidator.php index 2ccb726f2c625..317a573a653e9 100644 --- a/app/code/Magento/CatalogInventory/Model/Quote/Item/QuantityValidator.php +++ b/app/code/Magento/CatalogInventory/Model/Quote/Item/QuantityValidator.php @@ -193,7 +193,7 @@ public function validate(Observer $observer) $option->setHasError(true); //Setting this to false, so no error statuses are cleared $removeError = false; - $this->addErrorInfoToQuote($result, $quoteItem, $removeError); + $this->addErrorInfoToQuote($result, $quoteItem); } } if ($removeError) { diff --git a/app/code/Magento/CatalogInventory/Model/StockIndex.php b/app/code/Magento/CatalogInventory/Model/StockIndex.php index ad0cff43c6ac9..6b659073485ad 100644 --- a/app/code/Magento/CatalogInventory/Model/StockIndex.php +++ b/app/code/Magento/CatalogInventory/Model/StockIndex.php @@ -169,11 +169,11 @@ protected function processChildren( $requiredChildrenIds = $typeInstance->getChildrenIds($productId, true); if ($requiredChildrenIds) { - $childrenIds = [[]]; + $childrenIds = []; foreach ($requiredChildrenIds as $groupedChildrenIds) { $childrenIds[] = $groupedChildrenIds; } - $childrenIds = array_merge(...$childrenIds); + $childrenIds = array_merge([], ...$childrenIds); $childrenWebsites = $this->productWebsite->getWebsites($childrenIds); foreach ($websitesWithStores as $websiteId => $storeId) { @@ -232,13 +232,13 @@ protected function getWebsitesWithDefaultStores($websiteId = null) */ protected function processParents($productId, $websiteId) { - $parentIds = [[]]; + $parentIds = []; foreach ($this->getProductTypeInstances() as $typeInstance) { /* @var ProductType\AbstractType $typeInstance */ $parentIds[] = $typeInstance->getParentIdsByChild($productId); } - $parentIds = array_merge(...$parentIds); + $parentIds = array_merge([], ...$parentIds); if (empty($parentIds)) { return; diff --git a/app/code/Magento/CatalogInventory/Model/StockRegistryPreloader.php b/app/code/Magento/CatalogInventory/Model/StockRegistryPreloader.php new file mode 100644 index 0000000000000..2d0aef46a4ebd --- /dev/null +++ b/app/code/Magento/CatalogInventory/Model/StockRegistryPreloader.php @@ -0,0 +1,131 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\CatalogInventory\Model; + +use Magento\CatalogInventory\Api\StockConfigurationInterface; +use Magento\CatalogInventory\Api\StockItemRepositoryInterface; +use Magento\CatalogInventory\Api\StockItemCriteriaInterfaceFactory; +use Magento\CatalogInventory\Api\StockStatusCriteriaInterfaceFactory; +use Magento\CatalogInventory\Api\StockStatusRepositoryInterface; + +/** + * Preload stock data into stock registry + */ +class StockRegistryPreloader +{ + /** + * @var StockItemRepositoryInterface + */ + private $stockItemRepository; + /** + * @var StockConfigurationInterface + */ + private $stockConfiguration; + /** + * @var StockRegistryStorage + */ + private $stockRegistryStorage; + /** + * @var StockItemCriteriaInterfaceFactory + */ + private $stockItemCriteriaFactory; + /** + * @var StockStatusCriteriaInterfaceFactory + */ + private $stockStatusCriteriaFactory; + /** + * @var StockStatusRepositoryInterface + */ + private $stockStatusRepository; + + /** + * @param StockItemRepositoryInterface $stockItemRepository + * @param StockStatusRepositoryInterface $stockStatusRepository + * @param StockItemCriteriaInterfaceFactory $stockItemCriteriaFactory + * @param StockStatusCriteriaInterfaceFactory $stockStatusCriteriaFactory + * @param StockConfigurationInterface $stockConfiguration + * @param StockRegistryStorage $stockRegistryStorage + */ + public function __construct( + StockItemRepositoryInterface $stockItemRepository, + StockStatusRepositoryInterface $stockStatusRepository, + StockItemCriteriaInterfaceFactory $stockItemCriteriaFactory, + StockStatusCriteriaInterfaceFactory $stockStatusCriteriaFactory, + StockConfigurationInterface $stockConfiguration, + StockRegistryStorage $stockRegistryStorage + ) { + $this->stockItemRepository = $stockItemRepository; + $this->stockStatusRepository = $stockStatusRepository; + $this->stockItemCriteriaFactory = $stockItemCriteriaFactory; + $this->stockStatusCriteriaFactory = $stockStatusCriteriaFactory; + $this->stockConfiguration = $stockConfiguration; + $this->stockRegistryStorage = $stockRegistryStorage; + } + + /** + * Preload stock item into stock registry + * + * @param array $productIds + * @param int|null $scopeId + * @return \Magento\CatalogInventory\Api\Data\StockItemInterface[] + */ + public function preloadStockItems(array $productIds, ?int $scopeId = null): array + { + $scopeId = $scopeId ?? $this->stockConfiguration->getDefaultScopeId(); + $criteria = $this->stockItemCriteriaFactory->create(); + $criteria->setProductsFilter($productIds); + $criteria->setScopeFilter($scopeId); + $collection = $this->stockItemRepository->getList($criteria); + $this->setStockItems($collection->getItems(), $scopeId); + return $collection->getItems(); + } + + /** + * Saves stock items into registry + * + * @param \Magento\CatalogInventory\Api\Data\StockItemInterface[] $stockItems + * @param int $scopeId + */ + public function setStockItems(array $stockItems, int $scopeId): void + { + foreach ($stockItems as $item) { + $this->stockRegistryStorage->setStockItem($item->getProductId(), $scopeId, $item); + } + } + + /** + * Preload stock status into stock registry + * + * @param array $productIds + * @param int|null $scopeId + * @return \Magento\CatalogInventory\Api\Data\StockStatusInterface[] + */ + public function preloadStockStatuses(array $productIds, ?int $scopeId = null): array + { + $scopeId = $scopeId ?? $this->stockConfiguration->getDefaultScopeId(); + $criteria = $this->stockStatusCriteriaFactory->create(); + $criteria->setProductsFilter($productIds); + $criteria->setScopeFilter($scopeId); + $collection = $this->stockStatusRepository->getList($criteria); + $this->setStockStatuses($collection->getItems(), $scopeId); + return $collection->getItems(); + } + + /** + * Saves stock statuses into registry + * + * @param \Magento\CatalogInventory\Api\Data\StockStatusInterface[] $stockStatuses + * @param int $scopeId + */ + public function setStockStatuses(array $stockStatuses, int $scopeId): void + { + foreach ($stockStatuses as $item) { + $this->stockRegistryStorage->setStockStatus($item->getProductId(), $scopeId, $item); + } + } +} diff --git a/app/code/Magento/CatalogInventory/Observer/AddStockItemsObserver.php b/app/code/Magento/CatalogInventory/Observer/AddStockItemsObserver.php index 8fa90cf6531c4..68924c635de9d 100644 --- a/app/code/Magento/CatalogInventory/Observer/AddStockItemsObserver.php +++ b/app/code/Magento/CatalogInventory/Observer/AddStockItemsObserver.php @@ -10,7 +10,7 @@ use Magento\Catalog\Model\ResourceModel\Product\Collection; use Magento\CatalogInventory\Api\StockConfigurationInterface; use Magento\CatalogInventory\Api\StockItemCriteriaInterfaceFactory; -use Magento\CatalogInventory\Api\StockItemRepositoryInterface; +use Magento\CatalogInventory\Model\StockRegistryPreloader; use Magento\Framework\Event\Observer; use Magento\Framework\Event\ObserverInterface; @@ -19,36 +19,27 @@ */ class AddStockItemsObserver implements ObserverInterface { - /** - * @var StockItemCriteriaInterfaceFactory - */ - private $criteriaInterfaceFactory; - - /** - * @var StockItemRepositoryInterface - */ - private $stockItemRepository; - /** * @var StockConfigurationInterface */ private $stockConfiguration; + /** + * @var StockRegistryPreloader + */ + private $stockRegistryPreloader; /** * AddStockItemsObserver constructor. * - * @param StockItemCriteriaInterfaceFactory $criteriaInterfaceFactory - * @param StockItemRepositoryInterface $stockItemRepository * @param StockConfigurationInterface $stockConfiguration + * @param StockRegistryPreloader $stockRegistryPreloader */ public function __construct( - StockItemCriteriaInterfaceFactory $criteriaInterfaceFactory, - StockItemRepositoryInterface $stockItemRepository, - StockConfigurationInterface $stockConfiguration + StockConfigurationInterface $stockConfiguration, + StockRegistryPreloader $stockRegistryPreloader ) { - $this->criteriaInterfaceFactory = $criteriaInterfaceFactory; - $this->stockItemRepository = $stockItemRepository; $this->stockConfiguration = $stockConfiguration; + $this->stockRegistryPreloader = $stockRegistryPreloader; } /** @@ -62,11 +53,13 @@ public function execute(Observer $observer) /** @var Collection $productCollection */ $productCollection = $observer->getData('collection'); $productIds = array_keys($productCollection->getItems()); - $criteria = $this->criteriaInterfaceFactory->create(); - $criteria->setProductsFilter($productIds); - $criteria->setScopeFilter($this->stockConfiguration->getDefaultScopeId()); - $stockItemCollection = $this->stockItemRepository->getList($criteria); - foreach ($stockItemCollection->getItems() as $item) { + $scopeId = $this->stockConfiguration->getDefaultScopeId(); + $stockItems = []; + if ($productIds) { + $stockItems = $this->stockRegistryPreloader->preloadStockItems($productIds, $scopeId); + $this->stockRegistryPreloader->preloadStockStatuses($productIds, $scopeId); + } + foreach ($stockItems as $item) { /** @var Product $product */ $product = $productCollection->getItemById($item->getProductId()); $productExtension = $product->getExtensionAttributes(); diff --git a/app/code/Magento/CatalogInventory/Plugin/MassUpdateProductAttribute.php b/app/code/Magento/CatalogInventory/Plugin/MassUpdateProductAttribute.php index 334d2b22edbfa..2dd47eae16959 100644 --- a/app/code/Magento/CatalogInventory/Plugin/MassUpdateProductAttribute.php +++ b/app/code/Magento/CatalogInventory/Plugin/MassUpdateProductAttribute.php @@ -3,13 +3,19 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); + namespace Magento\CatalogInventory\Plugin; +use Magento\Catalog\Api\Data\ProductInterface; +use Magento\Catalog\Api\ProductRepositoryInterface; use Magento\Catalog\Controller\Adminhtml\Product\Action\Attribute\Save; use Magento\CatalogInventory\Api\Data\StockItemInterface; +use Magento\CatalogInventory\Observer\ParentItemProcessorInterface; /** - * MassUpdate product attribute. + * Around plugin for MassUpdate product attribute via product grid. + * * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class MassUpdateProductAttribute @@ -49,6 +55,15 @@ class MassUpdateProductAttribute */ private $messageManager; + /** + * @var ParentItemProcessorInterface[] + */ + private $parentItemProcessorPool; + + /** + * @var ProductRepositoryInterface + */ + private $productRepository; /** * @param \Magento\CatalogInventory\Model\Indexer\Stock\Processor $stockIndexerProcessor * @param \Magento\Framework\Api\DataObjectHelper $dataObjectHelper @@ -57,6 +72,8 @@ class MassUpdateProductAttribute * @param \Magento\CatalogInventory\Api\StockConfigurationInterface $stockConfiguration * @param \Magento\Catalog\Helper\Product\Edit\Action\Attribute $attributeHelper * @param \Magento\Framework\Message\ManagerInterface $messageManager + * @param ProductRepositoryInterface $productRepository + * @param ParentItemProcessorInterface[] $parentItemProcessorPool * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -66,7 +83,9 @@ public function __construct( \Magento\CatalogInventory\Api\StockItemRepositoryInterface $stockItemRepository, \Magento\CatalogInventory\Api\StockConfigurationInterface $stockConfiguration, \Magento\Catalog\Helper\Product\Edit\Action\Attribute $attributeHelper, - \Magento\Framework\Message\ManagerInterface $messageManager + \Magento\Framework\Message\ManagerInterface $messageManager, + ProductRepositoryInterface $productRepository, + array $parentItemProcessorPool = [] ) { $this->stockIndexerProcessor = $stockIndexerProcessor; $this->dataObjectHelper = $dataObjectHelper; @@ -75,6 +94,8 @@ public function __construct( $this->stockConfiguration = $stockConfiguration; $this->attributeHelper = $attributeHelper; $this->messageManager = $messageManager; + $this->productRepository = $productRepository; + $this->parentItemProcessorPool = $parentItemProcessorPool; } /** @@ -145,6 +166,7 @@ private function addConfigSettings($inventoryData) private function updateInventoryInProducts($productIds, $websiteId, $inventoryData): void { foreach ($productIds as $productId) { + $product = $this->productRepository->getById($productId); $stockItemDo = $this->stockRegistry->getStockItem($productId, $websiteId); if (!$stockItemDo->getProductId()) { $inventoryData['product_id'] = $productId; @@ -153,7 +175,21 @@ private function updateInventoryInProducts($productIds, $websiteId, $inventoryDa $this->dataObjectHelper->populateWithArray($stockItemDo, $inventoryData, StockItemInterface::class); $stockItemDo->setItemId($stockItemId); $this->stockItemRepository->save($stockItemDo); + $this->processParents($product); } $this->stockIndexerProcessor->reindexList($productIds); } + + /** + * Process stock data for parent products + * + * @param ProductInterface $product + * @return void + */ + private function processParents(ProductInterface $product): void + { + foreach ($this->parentItemProcessorPool as $processor) { + $processor->process($product); + } + } } diff --git a/app/code/Magento/CatalogInventory/Test/Mftf/Test/AssociatedProductToConfigurableOutOfStockTest.xml b/app/code/Magento/CatalogInventory/Test/Mftf/Test/AssociatedProductToConfigurableOutOfStockTest.xml index f1c01919d52f1..e7387ddd5d674 100644 --- a/app/code/Magento/CatalogInventory/Test/Mftf/Test/AssociatedProductToConfigurableOutOfStockTest.xml +++ b/app/code/Magento/CatalogInventory/Test/Mftf/Test/AssociatedProductToConfigurableOutOfStockTest.xml @@ -115,18 +115,14 @@ <actionGroup ref="StorefrontSignOutActionGroup" stepKey="StorefrontSignOutActionGroup"/> <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin1"/> - <amOnPage url="{{AdminOrdersPage.url}}" stepKey="onOrdersPage"/> - <waitForLoadingMaskToDisappear stepKey="waitForLoadingMask3"/> <!-- Reset admin order filter --> <actionGroup ref="AdminOrdersGridClearFiltersActionGroup" stepKey="clearOrderFilters"/> - <waitForLoadingMaskToDisappear stepKey="waitForLoadingMask5"/> <fillField selector="{{AdminOrdersGridSection.search}}" userInput="{$grabOrderNumber}" stepKey="searchOrderNum"/> <click selector="{{AdminOrdersGridSection.submitSearch}}" stepKey="submitSearch"/> <waitForLoadingMaskToDisappear stepKey="waitForLoadingMask4"/> <actionGroup ref="AdminOrderGridClickFirstRowActionGroup" stepKey="clickOrderRow"/> - <click selector="{{AdminOrderDetailsMainActionsSection.invoice}}" stepKey="clickInvoice"/> - <waitForPageLoad stepKey="waitForNewInvoicePageToLoad"/> + <actionGroup ref="AdminClickInvoiceButtonOrderViewActionGroup" stepKey="clickInvoice"/> <actionGroup ref="AdminInvoiceClickSubmitActionGroup" stepKey="clickSubmitInvoice"/> <see selector="{{AdminOrderDetailsMessagesSection.successMessage}}" userInput="The invoice has been created." stepKey="seeSuccessMessage"/> <click selector="{{AdminOrderDetailsMainActionsSection.ship}}" stepKey="clickShip"/> diff --git a/app/code/Magento/CatalogInventory/Test/Mftf/Test/StorefrontValidateQuantityIncrementsWithDecimalInventoryTest.xml b/app/code/Magento/CatalogInventory/Test/Mftf/Test/StorefrontValidateQuantityIncrementsWithDecimalInventoryTest.xml new file mode 100644 index 0000000000000..e17c8fe65d4cf --- /dev/null +++ b/app/code/Magento/CatalogInventory/Test/Mftf/Test/StorefrontValidateQuantityIncrementsWithDecimalInventoryTest.xml @@ -0,0 +1,66 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="StorefrontValidateQuantityIncrementsWithDecimalInventoryTest"> + <annotations> + <features value="CatalogInventory"/> + <stories value="Qty increments wrong calculation for decimal fraction quantity"/> + <title value="Validate qty increments for decimal fraction quantity works"/> + <description value="Validate qty increments for decimal fraction quantity works"/> + <severity value="MAJOR"/> + <useCaseId value="MC-38242"/> + <testCaseId value="MC-38883"/> + <group value="catalogInventory"/> + </annotations> + <before> + <createData entity="_defaultCategory" stepKey="createPreReqCategory"/> + <createData entity="SimpleProduct" stepKey="createPreReqSimpleProduct"> + <requiredEntity createDataKey="createPreReqCategory"/> + </createData> + </before> + <after> + <!--Clear Filters--> + <actionGroup ref="AdminClearFiltersActionGroup" stepKey="ClearFiltersAfter"/> + <deleteData createDataKey="createPreReqCategory" stepKey="deletePreReqCategory"/> + <deleteData createDataKey="createPreReqSimpleProduct" stepKey="deletePreReqSimpleProduct"/> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> + </after> + <!--Step1. Login as admin. Go to Catalog > Products page. Filtering *prod1*. Open *prod1* to edit--> + <actionGroup ref="AdminLoginActionGroup" stepKey="LoginAsAdmin" /> + <actionGroup ref="SearchForProductOnBackendActionGroup" stepKey="filterGroupedProductOptions"> + <argument name="product" value="SimpleProduct"/> + </actionGroup> + <!-- Step2. Update product Advanced Inventory Setting. + Set *Qty Uses Decimals* to *Yes* and *Enable Qty Increments* to *Yes* and *Qty Increments* to *3.33*. --> + <actionGroup ref="OpenProductForEditByClickingRowXColumnYInProductGridActionGroup" stepKey="openProduct"/> + <actionGroup ref="AdminClickOnAdvancedInventoryLinkActionGroup" stepKey="clickOnAdvancedInventoryLink"/> + <actionGroup ref="AdminSetQtyUsesDecimalsConfigActionGroup" stepKey="setQtyUsesDecimalsConfig"> + <argument name="value" value="Yes"/> + </actionGroup> + <actionGroup ref="AdminSetEnableQtyIncrementsActionGroup" stepKey="setEnableQtyIncrements"> + <argument name="value" value="Yes"/> + </actionGroup> + <actionGroup ref="AdminSetQtyIncrementsForProductActionGroup" stepKey="setQtyIncrementsValue"> + <argument name="qty" value="3.33"/> + </actionGroup> + <actionGroup ref="AdminSubmitAdvancedInventoryFormActionGroup" stepKey="clickOnDoneButton"/> + + <!--Step3. Save the product--> + <actionGroup ref="AdminProductFormSaveActionGroup" stepKey="clickOnSaveButton2"/> + <!--Step4. Open *Customer view* (Go to *Store Front*). Open *prod1* page (Find via search and click on product name) --> + <amOnPage url="{{StorefrontHomePage.url}}$$createPreReqSimpleProduct.custom_attributes[url_key]$$.html" stepKey="amOnProductPage"/> + <!--Step5. Fill *23.31* in *Qty*. Click on button *Add to Cart*--> + <fillField selector="{{StorefrontProductPageSection.qtyInput}}" userInput="23.31" stepKey="fillQty"/> + <click selector="{{StorefrontProductPageSection.addToCartBtn}}" stepKey="clickOnAddToCart"/> + <waitForElementVisible selector="{{StorefrontProductPageSection.successMsg}}" time="30" stepKey="waitForProductAdded"/> + <!--Step6. Verify the product is successfully added to the cart with success message--> + <see selector="{{StorefrontCategoryMainSection.SuccessMsg}}" userInput="You added $$createPreReqSimpleProduct.name$$ to your shopping cart." stepKey="seeAddedToCartMessage"/> + </test> +</tests> diff --git a/app/code/Magento/CatalogInventory/Test/Unit/Model/Indexer/Stock/Action/FullTest.php b/app/code/Magento/CatalogInventory/Test/Unit/Model/Indexer/Stock/Action/FullTest.php index ca89ac01f280f..c888d522d2e8b 100644 --- a/app/code/Magento/CatalogInventory/Test/Unit/Model/Indexer/Stock/Action/FullTest.php +++ b/app/code/Magento/CatalogInventory/Test/Unit/Model/Indexer/Stock/Action/FullTest.php @@ -1,8 +1,5 @@ <?php /** - * @category Magento - * @package Magento_CatalogInventory - * @subpackage unit_tests * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ diff --git a/app/code/Magento/CatalogInventory/Test/Unit/Model/Indexer/Stock/Action/RowTest.php b/app/code/Magento/CatalogInventory/Test/Unit/Model/Indexer/Stock/Action/RowTest.php index 25b0c2ef33ebe..c9f60bd61c2fb 100644 --- a/app/code/Magento/CatalogInventory/Test/Unit/Model/Indexer/Stock/Action/RowTest.php +++ b/app/code/Magento/CatalogInventory/Test/Unit/Model/Indexer/Stock/Action/RowTest.php @@ -1,8 +1,5 @@ <?php /** - * @category Magento - * @package Magento_CatalogInventory - * @subpackage unit_tests * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ diff --git a/app/code/Magento/CatalogInventory/Test/Unit/Model/Indexer/Stock/Action/RowsTest.php b/app/code/Magento/CatalogInventory/Test/Unit/Model/Indexer/Stock/Action/RowsTest.php index e01f371b829d6..42d578ec88ea8 100644 --- a/app/code/Magento/CatalogInventory/Test/Unit/Model/Indexer/Stock/Action/RowsTest.php +++ b/app/code/Magento/CatalogInventory/Test/Unit/Model/Indexer/Stock/Action/RowsTest.php @@ -1,8 +1,5 @@ <?php /** - * @category Magento - * @package Magento_CatalogInventory - * @subpackage unit_tests * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ diff --git a/app/code/Magento/CatalogInventory/Test/Unit/Model/Indexer/Stock/Plugin/StoreGroupTest.php b/app/code/Magento/CatalogInventory/Test/Unit/Model/Indexer/Stock/Plugin/StoreGroupTest.php index 0e2b6b2f329c1..a81a4cd34b87f 100644 --- a/app/code/Magento/CatalogInventory/Test/Unit/Model/Indexer/Stock/Plugin/StoreGroupTest.php +++ b/app/code/Magento/CatalogInventory/Test/Unit/Model/Indexer/Stock/Plugin/StoreGroupTest.php @@ -1,8 +1,5 @@ <?php /** - * @category Magento - * @package Magento_CatalogInventory - * @subpackage unit_tests * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ diff --git a/app/code/Magento/CatalogInventory/Test/Unit/Model/Plugin/AfterProductLoadTest.php b/app/code/Magento/CatalogInventory/Test/Unit/Model/Plugin/AfterProductLoadTest.php index d0e34c89b897c..215b31e1b4dad 100644 --- a/app/code/Magento/CatalogInventory/Test/Unit/Model/Plugin/AfterProductLoadTest.php +++ b/app/code/Magento/CatalogInventory/Test/Unit/Model/Plugin/AfterProductLoadTest.php @@ -30,11 +30,6 @@ class AfterProductLoadTest extends TestCase */ protected $productMock; - /** - * @var ProductExtensionFactory|MockObject - */ - protected $productExtensionFactoryMock; - /** * @var ProductExtensionInterface|MockObject */ @@ -43,16 +38,9 @@ class AfterProductLoadTest extends TestCase protected function setUp(): void { $stockRegistryMock = $this->getMockForAbstractClass(StockRegistryInterface::class); - $this->productExtensionFactoryMock = $this->getMockBuilder( - ProductExtensionFactory::class - ) - ->setMethods(['create']) - ->disableOriginalConstructor() - ->getMock(); $this->plugin = new AfterProductLoad( - $stockRegistryMock, - $this->productExtensionFactoryMock + $stockRegistryMock ); $productId = 5494; @@ -88,8 +76,6 @@ public function testAfterLoad() $this->productMock->expects($this->once()) ->method('getExtensionAttributes') ->willReturn($this->productExtensionMock); - $this->productExtensionFactoryMock->expects($this->never()) - ->method('create'); $this->assertEquals( $this->productMock, diff --git a/app/code/Magento/CatalogInventory/Test/Unit/Model/StockRegistryPreloaderTest.php b/app/code/Magento/CatalogInventory/Test/Unit/Model/StockRegistryPreloaderTest.php new file mode 100644 index 0000000000000..037d491c8b5bf --- /dev/null +++ b/app/code/Magento/CatalogInventory/Test/Unit/Model/StockRegistryPreloaderTest.php @@ -0,0 +1,161 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\CatalogInventory\Test\Unit\Model; + +use Magento\CatalogInventory\Api\Data\StockItemCollectionInterface; +use Magento\CatalogInventory\Api\Data\StockItemInterface; +use Magento\CatalogInventory\Api\Data\StockStatusCollectionInterface; +use Magento\CatalogInventory\Api\Data\StockStatusInterface; +use Magento\CatalogInventory\Api\StockConfigurationInterface; +use Magento\CatalogInventory\Api\StockItemCriteriaInterface; +use Magento\CatalogInventory\Api\StockItemRepositoryInterface; +use Magento\CatalogInventory\Api\StockItemCriteriaInterfaceFactory; +use Magento\CatalogInventory\Api\StockStatusCriteriaInterface; +use Magento\CatalogInventory\Api\StockStatusCriteriaInterfaceFactory; +use Magento\CatalogInventory\Api\StockStatusRepositoryInterface; +use Magento\CatalogInventory\Model\StockRegistryPreloader; +use Magento\CatalogInventory\Model\StockRegistryStorage; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * Test for StockRegistryStorage + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ +class StockRegistryPreloaderTest extends TestCase +{ + /** + * @var StockItemRepositoryInterface|MockObject + */ + private $stockItemRepository; + /** + * @var StockStatusRepositoryInterface|MockObject + */ + private $stockStatusRepository; + /** + * @var MockObject + */ + private $stockItemCriteriaFactory; + /** + * @var MockObject + */ + private $stockStatusCriteriaFactory; + /** + * @var StockConfigurationInterface|MockObject + */ + private $stockConfiguration; + /** + * @var StockRegistryStorage + */ + private $stockRegistryStorage; + /** + * @var StockRegistryPreloader + */ + private $model; + + /** + * @inheritDoc + */ + protected function setUp(): void + { + parent::setUp(); + $this->stockItemRepository = $this->createMock(StockItemRepositoryInterface::class); + $this->stockStatusRepository = $this->createMock(StockStatusRepositoryInterface::class); + $this->stockItemCriteriaFactory = $this->createMock(StockItemCriteriaInterfaceFactory::class); + $this->stockStatusCriteriaFactory = $this->createMock(StockStatusCriteriaInterfaceFactory::class); + $this->stockConfiguration = $this->createMock(StockConfigurationInterface::class); + $this->stockRegistryStorage = new StockRegistryStorage(); + $this->model = new StockRegistryPreloader( + $this->stockItemRepository, + $this->stockStatusRepository, + $this->stockItemCriteriaFactory, + $this->stockStatusCriteriaFactory, + $this->stockConfiguration, + $this->stockRegistryStorage, + ); + } + + public function testPreloadStockItems(): void + { + $productIds = [10, 20]; + $scopeId = 1; + $stockItems = [ + $this->createConfiguredMock(StockItemInterface::class, ['getProductId' => 10]), + $this->createConfiguredMock(StockItemInterface::class, ['getProductId' => 20]), + ]; + $collection = $this->createConfiguredMock(StockItemCollectionInterface::class, ['getItems' => $stockItems]); + $criteria = $this->createMock(StockItemCriteriaInterface::class); + $criteria->expects($this->once()) + ->method('setProductsFilter') + ->with($productIds) + ->willReturnSelf(); + $criteria->expects($this->once()) + ->method('setScopeFilter') + ->with($scopeId) + ->willReturnSelf(); + $this->stockItemRepository->method('getList') + ->willReturn($collection); + $this->stockItemCriteriaFactory->method('create') + ->willReturn($criteria); + $this->assertEquals($stockItems, $this->model->preloadStockItems($productIds, $scopeId)); + $this->assertSame($stockItems[0], $this->stockRegistryStorage->getStockItem(10, $scopeId)); + $this->assertSame($stockItems[1], $this->stockRegistryStorage->getStockItem(20, $scopeId)); + } + + public function testPreloadStockStatuses(): void + { + $productIds = [10, 20]; + $scopeId = 1; + $stockItems = [ + $this->createConfiguredMock(StockStatusInterface::class, ['getProductId' => 10]), + $this->createConfiguredMock(StockStatusInterface::class, ['getProductId' => 20]), + ]; + $collection = $this->createConfiguredMock(StockStatusCollectionInterface::class, ['getItems' => $stockItems]); + $criteria = $this->createMock(StockStatusCriteriaInterface::class); + $criteria->expects($this->once()) + ->method('setProductsFilter') + ->with($productIds) + ->willReturnSelf(); + $criteria->expects($this->once()) + ->method('setScopeFilter') + ->with($scopeId) + ->willReturnSelf(); + $this->stockStatusRepository->method('getList') + ->willReturn($collection); + $this->stockStatusCriteriaFactory->method('create') + ->willReturn($criteria); + $this->assertEquals($stockItems, $this->model->preloadStockStatuses($productIds, $scopeId)); + $this->assertSame($stockItems[0], $this->stockRegistryStorage->getStockStatus(10, $scopeId)); + $this->assertSame($stockItems[1], $this->stockRegistryStorage->getStockStatus(20, $scopeId)); + } + + public function testSetStockItems(): void + { + $scopeId = 1; + $stockItems = [ + $this->createConfiguredMock(StockItemInterface::class, ['getProductId' => 10]), + $this->createConfiguredMock(StockItemInterface::class, ['getProductId' => 20]), + ]; + $this->model->setStockItems($stockItems, $scopeId); + $this->assertSame($stockItems[0], $this->stockRegistryStorage->getStockItem(10, $scopeId)); + $this->assertSame($stockItems[1], $this->stockRegistryStorage->getStockItem(20, $scopeId)); + } + + public function testSetStockStatuses(): void + { + $scopeId = 1; + $stockItems = [ + $this->createConfiguredMock(StockStatusInterface::class, ['getProductId' => 10]), + $this->createConfiguredMock(StockStatusInterface::class, ['getProductId' => 20]), + ]; + $this->model->setStockStatuses($stockItems, $scopeId); + $this->assertSame($stockItems[0], $this->stockRegistryStorage->getStockStatus(10, $scopeId)); + $this->assertSame($stockItems[1], $this->stockRegistryStorage->getStockStatus(20, $scopeId)); + } +} diff --git a/app/code/Magento/CatalogInventory/Test/Unit/Observer/AddStockItemsObserverTest.php b/app/code/Magento/CatalogInventory/Test/Unit/Observer/AddStockItemsObserverTest.php index bba44ef436fd6..fce232821b67d 100644 --- a/app/code/Magento/CatalogInventory/Test/Unit/Observer/AddStockItemsObserverTest.php +++ b/app/code/Magento/CatalogInventory/Test/Unit/Observer/AddStockItemsObserverTest.php @@ -10,15 +10,12 @@ use Magento\Catalog\Api\Data\ProductExtensionInterface; use Magento\Catalog\Model\Product; use Magento\Catalog\Model\ResourceModel\Product\Collection as ProductCollection; -use Magento\CatalogInventory\Api\Data\StockItemCollectionInterface; use Magento\CatalogInventory\Api\Data\StockItemInterface; use Magento\CatalogInventory\Api\StockConfigurationInterface; -use Magento\CatalogInventory\Api\StockItemCriteriaInterface; use Magento\CatalogInventory\Api\StockItemCriteriaInterfaceFactory; -use Magento\CatalogInventory\Api\StockItemRepositoryInterface; +use Magento\CatalogInventory\Model\StockRegistryPreloader; use Magento\CatalogInventory\Observer\AddStockItemsObserver; use Magento\Framework\Event\Observer; -use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -33,46 +30,29 @@ class AddStockItemsObserverTest extends TestCase * @var AddStockItemsObserver */ private $subject; - /** - * @var StockItemCriteriaInterfaceFactory|MockObject - */ - private $criteriaInterfaceFactoryMock; - - /** - * @var StockItemRepositoryInterface|MockObject - */ - private $stockItemRepositoryMock; /** * @var StockConfigurationInterface|MockObject */ private $stockConfigurationMock; + /** + * @var StockRegistryPreloader|MockObject + */ + private $stockRegistryPreloader; /** * @inheritdoc */ protected function setUp(): void { - $objectManager = new ObjectManager($this); - $this->criteriaInterfaceFactoryMock = $this->getMockBuilder(StockItemCriteriaInterfaceFactory::class) - ->setMethods(['create']) - ->disableOriginalConstructor() - ->getMockForAbstractClass(); - $this->stockItemRepositoryMock = $this->getMockBuilder(StockItemRepositoryInterface::class) - ->setMethods(['getList']) - ->disableOriginalConstructor() - ->getMockForAbstractClass(); $this->stockConfigurationMock = $this->getMockBuilder(StockConfigurationInterface::class) ->setMethods(['getDefaultScopeId']) ->disableOriginalConstructor() ->getMockForAbstractClass(); - $this->subject = $objectManager->getObject( - AddStockItemsObserver::class, - [ - 'criteriaInterfaceFactory' => $this->criteriaInterfaceFactoryMock, - 'stockItemRepository' => $this->stockItemRepositoryMock, - 'stockConfiguration' => $this->stockConfigurationMock - ] + $this->stockRegistryPreloader = $this->createMock(StockRegistryPreloader::class); + $this->subject = new AddStockItemsObserver( + $this->stockConfigurationMock, + $this->stockRegistryPreloader, ); } @@ -84,26 +64,6 @@ public function testExecute() $productId = 1; $defaultScopeId = 0; - $criteria = $this->getMockBuilder(StockItemCriteriaInterface::class) - ->setMethods(['setProductsFilter', 'setScopeFilter']) - ->disableOriginalConstructor() - ->getMockForAbstractClass(); - $criteria->expects(self::once()) - ->method('setProductsFilter') - ->with(self::identicalTo([$productId])) - ->willReturn(true); - $criteria->expects(self::once()) - ->method('setScopeFilter') - ->with(self::identicalTo($defaultScopeId)) - ->willReturn(true); - - $this->criteriaInterfaceFactoryMock->expects(self::once()) - ->method('create') - ->willReturn($criteria); - $stockItemCollection = $this->getMockBuilder(StockItemCollectionInterface::class) - ->setMethods(['getItems']) - ->disableOriginalConstructor() - ->getMockForAbstractClass(); $stockItem = $this->getMockBuilder(StockItemInterface::class) ->setMethods(['getProductId']) ->disableOriginalConstructor() @@ -112,14 +72,19 @@ public function testExecute() ->method('getProductId') ->willReturn($productId); - $stockItemCollection->expects(self::once()) - ->method('getItems') + $this->stockRegistryPreloader->expects(self::once()) + ->method('preloadStockItems') + ->with([$productId]) ->willReturn([$stockItem]); - $this->stockItemRepositoryMock->expects(self::once()) - ->method('getList') - ->with(self::identicalTo($criteria)) - ->willReturn($stockItemCollection); + $this->stockRegistryPreloader->expects(self::once()) + ->method('preloadStockStatuses') + ->with([$productId]) + ->willReturn([]); + + $this->stockRegistryPreloader->expects(self::once()) + ->method('preloadStockItems') + ->willReturn([$stockItem]); $this->stockConfigurationMock->expects(self::once()) ->method('getDefaultScopeId') diff --git a/app/code/Magento/CatalogRule/Model/Rule.php b/app/code/Magento/CatalogRule/Model/Rule.php index f2e8e54d34665..2d92192368960 100644 --- a/app/code/Magento/CatalogRule/Model/Rule.php +++ b/app/code/Magento/CatalogRule/Model/Rule.php @@ -895,4 +895,14 @@ public function getIdentities() { return ['price']; } + + /** + * Clear price rules cache. + * + * @return void; + */ + public function clearPriceRulesData(): void + { + self::$_priceRulesData = []; + } } diff --git a/app/code/Magento/CatalogRule/Test/Mftf/ActionGroup/AdminFillCatalogRuleConditionWithSelectAttributeActionGroup.xml b/app/code/Magento/CatalogRule/Test/Mftf/ActionGroup/AdminFillCatalogRuleConditionWithSelectAttributeActionGroup.xml new file mode 100644 index 0000000000000..08e3e58632101 --- /dev/null +++ b/app/code/Magento/CatalogRule/Test/Mftf/ActionGroup/AdminFillCatalogRuleConditionWithSelectAttributeActionGroup.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminFillCatalogRuleConditionWithSelectAttributeActionGroup" extends="AdminFillCatalogRuleConditionActionGroup"> + <annotations> + <description>EXTENDS: AdminFillCatalogRuleConditionActionGroup. Clicks on the Conditions tab. Fills in the provided condition with attribute type select.</description> + </annotations> + <selectOption selector="{{AdminNewCatalogPriceRuleConditions.activeValueInput}}" userInput="{{conditionValue}}" stepKey="fillConditionValue"/> + <remove keyForRemoval="clickApply"/> + <remove keyForRemoval="waitForApplyButtonInvisibility"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/CatalogRule/Test/Mftf/Test/AdminApplyCatalogPriceRuleByProductAttributeTest.xml b/app/code/Magento/CatalogRule/Test/Mftf/Test/AdminApplyCatalogPriceRuleByProductAttributeTest.xml new file mode 100644 index 0000000000000..547ef356f099d --- /dev/null +++ b/app/code/Magento/CatalogRule/Test/Mftf/Test/AdminApplyCatalogPriceRuleByProductAttributeTest.xml @@ -0,0 +1,198 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminApplyCatalogPriceRuleByProductAttributeTest"> + <annotations> + <features value="CatalogRule"/> + <stories value="Catalog price rule"/> + <title value="Admin should be able to apply the catalog price rule by product attribute"/> + <description value="Admin should be able to apply the catalog price rule by product attribute"/> + <severity value="CRITICAL"/> + <testCaseId value="MC-25351"/> + <group value="catalogRule"/> + </annotations> + <before> + <createData entity="productDropDownAttribute" stepKey="createDropdownAttribute"/> + <!--Create attribute options--> + <createData entity="ProductAttributeOption7" stepKey="createProductAttributeOptionGreen"> + <requiredEntity createDataKey="createDropdownAttribute"/> + </createData> + <createData entity="ProductAttributeOption8" stepKey="createProductAttributeOptionRed"> + <requiredEntity createDataKey="createDropdownAttribute"/> + </createData> + <!--Add attribute to default attribute set--> + <createData entity="AddToDefaultSet" stepKey="addAttributeToDefaultSet"> + <requiredEntity createDataKey="createDropdownAttribute"/> + </createData> + + <createData entity="ApiCategory" stepKey="createCategory"/> + <createData entity="ApiSimpleProduct" stepKey="createFirstProduct"> + <field key="price">40.00</field> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="ApiSimpleProduct" stepKey="createSecondProduct"> + <field key="price">40.00</field> + <requiredEntity createDataKey="createCategory"/> + </createData> + + <!-- Create the configurable product based on the data in the /data folder --> + <createData entity="ApiConfigurableProduct" stepKey="createConfigProduct"> + <requiredEntity createDataKey="createCategory"/> + </createData> + + <!-- Make the configurable product have two options, that are children of the default attribute set --> + <createData entity="productAttributeWithTwoOptionsNotVisible" stepKey="createConfigProductAttribute"/> + <createData entity="productAttributeOption1" stepKey="createFirstConfigProductAttributeOption"> + <requiredEntity createDataKey="createConfigProductAttribute"/> + </createData> + <createData entity="productAttributeOption2" stepKey="createSecondConfigProductAttributeOption"> + <requiredEntity createDataKey="createConfigProductAttribute"/> + </createData> + <createData entity="AddToDefaultSet" stepKey="createConfigAddToAttributeSet"> + <requiredEntity createDataKey="createConfigProductAttribute"/> + </createData> + <getData entity="ProductAttributeOptionGetter" index="1" stepKey="getFirstConfigAttributeOption"> + <requiredEntity createDataKey="createConfigProductAttribute"/> + </getData> + <getData entity="ProductAttributeOptionGetter" index="2" stepKey="getSecondConfigAttributeOption"> + <requiredEntity createDataKey="createConfigProductAttribute"/> + </getData> + + <!-- Create the 2 children that will be a part of the configurable product --> + <createData entity="ApiSimpleOne" stepKey="createConfigFirstChildProduct"> + <field key="price">60.00</field> + <requiredEntity createDataKey="createConfigProductAttribute"/> + <requiredEntity createDataKey="getFirstConfigAttributeOption"/> + </createData> + <createData entity="ApiSimpleTwo" stepKey="createConfigSecondChildProduct"> + <field key="price">60.00</field> + <requiredEntity createDataKey="createConfigProductAttribute"/> + <requiredEntity createDataKey="getSecondConfigAttributeOption"/> + </createData> + + <!-- Assign the two products to the configurable product --> + <createData entity="ConfigurableProductTwoOptions" stepKey="createConfigProductOption"> + <requiredEntity createDataKey="createConfigProduct"/> + <requiredEntity createDataKey="createConfigProductAttribute"/> + <requiredEntity createDataKey="getFirstConfigAttributeOption"/> + <requiredEntity createDataKey="getSecondConfigAttributeOption"/> + </createData> + <createData entity="ConfigurableProductAddChild" stepKey="createFirstConfigProductAddChild"> + <requiredEntity createDataKey="createConfigProduct"/> + <requiredEntity createDataKey="createConfigFirstChildProduct"/> + </createData> + <createData entity="ConfigurableProductAddChild" stepKey="createSecondConfigProductAddChild"> + <requiredEntity createDataKey="createConfigProduct"/> + <requiredEntity createDataKey="createConfigSecondChildProduct"/> + </createData> + + <actionGroup ref="AdminLoginActionGroup" stepKey="loginToAdmin"/> + <!-- Update first simple product --> + <actionGroup ref="AdminProductPageOpenByIdActionGroup" stepKey="openFirstSimpleProductForEdit"> + <argument name="productId" value="$createFirstProduct.id$"/> + </actionGroup> + <selectOption selector="{{AdminProductFormSection.customSelectField($createDropdownAttribute.attribute[attribute_code]$)}}" + userInput="$createProductAttributeOptionGreen.option[store_labels][0][label]$" stepKey="setAttributeValueForFirstSimple"/> + <actionGroup ref="SaveProductFormActionGroup" stepKey="saveFirstSimpleProduct"/> + <!-- Update second simple product --> + <actionGroup ref="AdminProductPageOpenByIdActionGroup" stepKey="openSecondSimpleProductForEdit"> + <argument name="productId" value="$createSecondProduct.id$"/> + </actionGroup> + <selectOption selector="{{AdminProductFormSection.customSelectField($createDropdownAttribute.attribute[attribute_code]$)}}" + userInput="$createProductAttributeOptionRed.option[store_labels][0][label]$" stepKey="setAttributeValueForSecondSimple"/> + <actionGroup ref="SaveProductFormActionGroup" stepKey="saveSecondSimpleProduct"/> + <!-- Update first child of configurable product --> + <actionGroup ref="AdminProductPageOpenByIdActionGroup" stepKey="openFirstChildProductForEdit"> + <argument name="productId" value="$createConfigFirstChildProduct.id$"/> + </actionGroup> + <selectOption selector="{{AdminProductFormSection.customSelectField($createDropdownAttribute.attribute[attribute_code]$)}}" + userInput="$createProductAttributeOptionGreen.option[store_labels][0][label]$" stepKey="setAttributeValueForFirstChildProduct"/> + <actionGroup ref="SaveProductFormActionGroup" stepKey="saveFirstChildProduct"/> + <!-- Update second child of configurable product --> + <actionGroup ref="AdminProductPageOpenByIdActionGroup" stepKey="openSecondChildProductForEdit"> + <argument name="productId" value="$createConfigSecondChildProduct.id$"/> + </actionGroup> + <selectOption selector="{{AdminProductFormSection.customSelectField($createDropdownAttribute.attribute[attribute_code]$)}}" + userInput="$createProductAttributeOptionGreen.option[store_labels][0][label]$" stepKey="setAttributeValueForSecondChildProduct"/> + <actionGroup ref="SaveProductFormActionGroup" stepKey="saveSecondChildProduct"/> + <actionGroup ref="AdminCatalogPriceRuleDeleteAllActionGroup" stepKey="deleteAllCatalogPriceRule"/> + <magentoCron groups="index" stepKey="reindexInvalidatedIndices"/> + </before> + <after> + <!-- Delete created data --> + <deleteData createDataKey="createDropdownAttribute" stepKey="deleteDropdownAttribute"/> + <deleteData createDataKey="createFirstProduct" stepKey="deleteFirstProduct"/> + <deleteData createDataKey="createSecondProduct" stepKey="deleteSecondProduct"/> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + <deleteData createDataKey="createConfigProduct" stepKey="deleteConfigProduct"/> + <deleteData createDataKey="createConfigFirstChildProduct" stepKey="deleteConfigFirstChildProduct"/> + <deleteData createDataKey="createConfigSecondChildProduct" stepKey="deleteConfigSecondChildProduct"/> + <deleteData createDataKey="createConfigProductAttribute" stepKey="deleteConfigProductAttribute"/> + <actionGroup ref="AdminCatalogPriceRuleDeleteAllActionGroup" stepKey="deleteAllCatalogPriceRule"/> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="resetCatalogRulesGridFilter"/> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logoutFromAdmin"/> + <!-- Reindex invalidated indices after product attribute has been created/deleted --> + <magentoCron groups="index" stepKey="reindexInvalidatedIndices"/> + </after> + <!-- Create Catalog Price Rule --> + <actionGroup ref="AdminOpenNewCatalogPriceRuleFormPageActionGroup" stepKey="startCreatingFirstPriceRule"/> + <actionGroup ref="AdminCatalogPriceRuleFillMainInfoActionGroup" stepKey="fillMainInfoForFirstPriceRule"> + <argument name="groups" value="'NOT LOGGED IN'"/> + </actionGroup> + <actionGroup ref="AdminFillCatalogRuleConditionWithSelectAttributeActionGroup" stepKey="createCatalogPriceRule"> + <argument name="condition" value="$createDropdownAttribute.default_frontend_label$"/> + <argument name="conditionValue" value="$createProductAttributeOptionGreen.option[store_labels][0][label]$"/> + </actionGroup> + <actionGroup ref="AdminCatalogPriceRuleFillActionsActionGroup" stepKey="fillActionsForCatalogPriceRule"> + <argument name="discountAmount" value="{{SimpleCatalogPriceRule.discount_amount}}"/> + </actionGroup> + <actionGroup ref="AdminCatalogPriceRuleSaveAndApplyActionGroup" stepKey="saveAndApplyCatalogPriceRule"/> + <!-- Run cron --> + <actionGroup ref="CliIndexerReindexActionGroup" stepKey="reindex"> + <argument name="indices" value="catalogrule_rule"/> + </actionGroup> + <!-- Open first simple product page on storefront --> + <actionGroup ref="OpenStoreFrontProductPageActionGroup" stepKey="openFirstSimpleProductPage"> + <argument name="productUrlKey" value="$createFirstProduct.custom_attributes[url_key]$"/> + </actionGroup> + <!-- Verify price for simple product with attribute option green=$20 --> + <actionGroup ref="AssertStorefrontProductPricesActionGroup" stepKey="assertFirstSimpleProductPrices"> + <argument name="productPrice" value="$createFirstProduct.price$"/> + <argument name="productFinalPrice" value="$20.00"/> + </actionGroup> + + <!-- Open the configurable product page on storefront --> + <actionGroup ref="OpenStoreFrontProductPageActionGroup" stepKey="openConfigurableProductPage"> + <argument name="productUrlKey" value="$createConfigProduct.custom_attributes[url_key]$"/> + </actionGroup> + <!-- Verify price for configurable product with attribute option green=$30 --> + <selectOption selector="{{AdminCustomerActivitiesConfigureSection.addAttribute}}" userInput="option1" stepKey="selectFirstOptionOfConfigProduct"/> + <actionGroup ref="AssertStorefrontProductPricesActionGroup" stepKey="assertConfigProductWithFirstOptionPrices"> + <argument name="productPrice" value="$createConfigFirstChildProduct.price$"/> + <argument name="productFinalPrice" value="$30.00"/> + </actionGroup> + <!-- Verify price for configurable product with attribute option green=$30 --> + <selectOption selector="{{AdminCustomerActivitiesConfigureSection.addAttribute}}" userInput="option2" stepKey="selectSecondOptionOfConfigProduct"/> + <actionGroup ref="AssertStorefrontProductPricesActionGroup" stepKey="assertConfigProductWithSecondOptionPrices"> + <argument name="productPrice" value="$createConfigSecondChildProduct.price$"/> + <argument name="productFinalPrice" value="$30.00"/> + </actionGroup> + + <!-- Open the second simple product page on storefront --> + <actionGroup ref="OpenStoreFrontProductPageActionGroup" stepKey="openSecondSimpleProductPage"> + <argument name="productUrlKey" value="$createSecondProduct.custom_attributes[url_key]$"/> + </actionGroup> + <!-- Verify Price for second simple product with specialColor red=$40 --> + <actionGroup ref="AssertStorefrontProductPricesActionGroup" stepKey="assertSecondSimpleProductPrices"> + <argument name="productPrice" value="$createSecondProduct.price$"/> + <argument name="productFinalPrice" value="$createSecondProduct.price$"/> + </actionGroup> + </test> +</tests> diff --git a/app/code/Magento/CatalogRule/Test/Mftf/Test/ApplyCatalogPriceRuleByProductAttributeTest.xml b/app/code/Magento/CatalogRule/Test/Mftf/Test/ApplyCatalogPriceRuleByProductAttributeTest.xml index 6817dd4dafc5f..6de7bba59c340 100644 --- a/app/code/Magento/CatalogRule/Test/Mftf/Test/ApplyCatalogPriceRuleByProductAttributeTest.xml +++ b/app/code/Magento/CatalogRule/Test/Mftf/Test/ApplyCatalogPriceRuleByProductAttributeTest.xml @@ -8,16 +8,16 @@ <tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> - <test name="ApplyCatalogPriceRuleByProductAttributeTest"> + <test name="ApplyCatalogPriceRuleByProductAttributeTest" deprecated="Use AdminApplyCatalogPriceRuleByProductAttributeTest"> <annotations> <stories value="Catalog price rule"/> - <title value="Admin should be able to apply the catalog price rule by product attribute"/> + <title value="DEPRECATED. Admin should be able to apply the catalog price rule by product attribute"/> <description value="Admin should be able to apply the catalog price rule by product attribute"/> <severity value="CRITICAL"/> <testCaseId value="MC-148"/> <group value="CatalogRule"/> <skip> - <issueId value="MC-22577"/> + <issueId value="DEPRECATED">Use AdminApplyCatalogPriceRuleByProductAttributeTest instead.</issueId> </skip> </annotations> <before> diff --git a/app/code/Magento/CatalogRule/Test/Mftf/Test/ApplyCatalogRuleForSimpleProductAndFixedMethodTest.xml b/app/code/Magento/CatalogRule/Test/Mftf/Test/ApplyCatalogRuleForSimpleProductAndFixedMethodTest.xml index 8103e6b115950..ece8dc4bacf28 100644 --- a/app/code/Magento/CatalogRule/Test/Mftf/Test/ApplyCatalogRuleForSimpleProductAndFixedMethodTest.xml +++ b/app/code/Magento/CatalogRule/Test/Mftf/Test/ApplyCatalogRuleForSimpleProductAndFixedMethodTest.xml @@ -7,16 +7,19 @@ --> <tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> - <test name="ApplyCatalogRuleForSimpleProductAndFixedMethodTest"> + <test name="ApplyCatalogRuleForSimpleProductAndFixedMethodTest" deprecated="Use StorefrontApplyCatalogRuleForSimpleProductWithSelectFixedMethodTest instead"> <annotations> <features value="CatalogRule"/> <stories value="Apply catalog price rule"/> - <title value="Admin should be able to apply the catalog price rule for simple product with custom options"/> + <title value="DEPRECATED. Admin should be able to apply the catalog price rule for simple product with custom options"/> <description value="Admin should be able to apply the catalog price rule for simple product with custom options"/> <severity value="CRITICAL"/> <testCaseId value="MC-14771"/> <group value="CatalogRule"/> <group value="mtf_migrated"/> + <skip> + <issueId value="DEPRECATED">Use StorefrontApplyCatalogRuleForSimpleProductWithSelectFixedMethodTest instead</issueId> + </skip> </annotations> <before> <!-- Login as Admin --> diff --git a/app/code/Magento/CatalogRule/Test/Mftf/Test/ApplyCatalogRuleForSimpleProductWithCustomOptionsTest.xml b/app/code/Magento/CatalogRule/Test/Mftf/Test/ApplyCatalogRuleForSimpleProductWithCustomOptionsTest.xml index d9b62ef8fc913..45e97f179a11f 100644 --- a/app/code/Magento/CatalogRule/Test/Mftf/Test/ApplyCatalogRuleForSimpleProductWithCustomOptionsTest.xml +++ b/app/code/Magento/CatalogRule/Test/Mftf/Test/ApplyCatalogRuleForSimpleProductWithCustomOptionsTest.xml @@ -7,16 +7,19 @@ --> <tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> - <test name="ApplyCatalogRuleForSimpleProductWithCustomOptionsTest"> + <test name="ApplyCatalogRuleForSimpleProductWithCustomOptionsTest" deprecated="Use StorefrontApplyCatalogRuleForSimpleProductsWithCustomOptionsTest instead"> <annotations> <features value="CatalogRule"/> <stories value="Apply catalog price rule"/> - <title value="Admin should be able to apply the catalog price rule for simple product with 3 custom options"/> + <title value="Deprecated. Admin should be able to apply the catalog price rule for simple product with 3 custom options"/> <description value="Admin should be able to apply the catalog price rule for simple product with 3 custom options"/> <severity value="CRITICAL"/> <testCaseId value="MC-14769"/> <group value="CatalogRule"/> <group value="mtf_migrated"/> + <skip> + <issueId value="DEPRECATED">Use StorefrontApplyCatalogRuleForSimpleProductsWithCustomOptionsTest instead</issueId> + </skip> </annotations> <before> <!-- Login as Admin --> diff --git a/app/code/Magento/CatalogRule/Test/Mftf/Test/StorefrontApplyCatalogRuleForSimpleProductWithSelectFixedMethodTest.xml b/app/code/Magento/CatalogRule/Test/Mftf/Test/StorefrontApplyCatalogRuleForSimpleProductWithSelectFixedMethodTest.xml new file mode 100644 index 0000000000000..c127f19db3749 --- /dev/null +++ b/app/code/Magento/CatalogRule/Test/Mftf/Test/StorefrontApplyCatalogRuleForSimpleProductWithSelectFixedMethodTest.xml @@ -0,0 +1,113 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="StorefrontApplyCatalogRuleForSimpleProductWithSelectFixedMethodTest"> + <annotations> + <features value="CatalogRule"/> + <stories value="Apply catalog price rule"/> + <title value="Admin should be able to apply the catalog price rule for simple product with custom options"/> + <description value="Admin should be able to apply the catalog price rule for simple product with custom options"/> + <severity value="CRITICAL"/> + <testCaseId value="MC-28347"/> + <group value="catalogRule"/> + <group value="mtf_migrated"/> + <group value="catalog"/> + </annotations> + <before> + <!-- Create category --> + <createData entity="_defaultCategory" stepKey="createCategory"/> + + <!-- Create Simple Product --> + <createData entity="_defaultProduct" stepKey="createProduct"> + <requiredEntity createDataKey="createCategory"/> + <field key="price">56.78</field> + </createData> + + <!-- Update all products to have custom options --> + <updateData createDataKey="createProduct" entity="productWithFixedOptions" stepKey="updateProductWithOptions"/> + + <!-- Login as Admin --> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + + <!-- Clear all catalog price rules and reindex before test --> + <actionGroup ref="AdminCatalogPriceRuleDeleteAllActionGroup" stepKey="deleteAllCatalogRulesBeforeTest"/> + <magentoCron groups="index" stepKey="fixInvalidatedIndicesBeforeTest"/> + </before> + <after> + <!-- Delete products and category --> + <deleteData createDataKey="createProduct" stepKey="deleteProduct"/> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + + <!-- Delete the catalog price rule --> + <actionGroup ref="AdminCatalogPriceRuleDeleteAllActionGroup" stepKey="deleteAllCatalogRulesAfterTest"/> + <magentoCron groups="index" stepKey="fixInvalidatedIndicesAfter"/> + <!-- Logout --> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logoutFromAdmin"/> + </after> + <!-- 1. Begin creating a new catalog price rule --> + <actionGroup ref="AdminOpenNewCatalogPriceRuleFormPageActionGroup" stepKey="openNewCatalogPriceRulePage"/> + <actionGroup ref="AdminCatalogPriceRuleFillMainInfoActionGroup" stepKey="fillMainInfoForCatalogPriceRule"> + <argument name="groups" value="'NOT LOGGED IN'"/> + </actionGroup> + <actionGroup ref="AdminFillCatalogRuleConditionActionGroup" stepKey="fillConditionsForCatalogPriceRule"> + <argument name="conditionValue" value="$createCategory.id$"/> + </actionGroup> + <actionGroup ref="AdminCatalogPriceRuleFillActionsActionGroup" stepKey="fillActionsForCatalogPriceRule"> + <argument name="apply" value="by_fixed"/> + <argument name="discountAmount" value="12.3"/> + </actionGroup> + <actionGroup ref="AdminCatalogPriceRuleSaveAndApplyActionGroup" stepKey="saveAndApplyCatalogPriceRule"/> + + <!-- Navigate to category on store front --> + <actionGroup ref="StorefrontNavigateCategoryPageActionGroup" stepKey="goToStorefrontCategoryPage"> + <argument name="category" value="$createCategory$"/> + </actionGroup> + + <!-- Check product name on store front category page --> + <actionGroup ref="AssertProductDetailsOnStorefrontActionGroup" stepKey="assertStorefrontProductName"> + <argument name="productInfo" value="$createProduct.name$"/> + <argument name="productNumber" value="1"/> + </actionGroup> + + <!-- Check product price on store front category page --> + <actionGroup ref="AssertProductDetailsOnStorefrontActionGroup" stepKey="assertStorefrontProductPrice"> + <argument name="productInfo" value="$44.48"/> + <argument name="productNumber" value="1"/> + </actionGroup> + + <!-- Check product regular price on store front category page --> + <actionGroup ref="AssertProductDetailsOnStorefrontActionGroup" stepKey="assertStorefrontProductRegularPrice"> + <argument name="productInfo" value="$56.78"/> + <argument name="productNumber" value="1"/> + </actionGroup> + + <!-- Navigate to product on store front --> + <actionGroup ref="OpenStoreFrontProductPageActionGroup" stepKey="goToProductPage"> + <argument name="productUrlKey" value="$createProduct.custom_attributes[url_key]$"/> + </actionGroup> + + <!-- Assert regular and special price after selecting ProductOptionValueDropdown1 --> + <actionGroup ref="StorefrontSelectCustomOptionRadioAndAssertPricesActionGroup" stepKey="storefrontSelectCustomOptionAndAssertPrices"> + <argument name="customOption" value="ProductOptionRadioButton2"/> + <argument name="customOptionValue" value="ProductOptionValueRadioButtons1"/> + <argument name="productPrice" value="$156.77"/> + <argument name="productFinalPrice" value="$144.47"/> + </actionGroup> + + <!-- Add product 1 to cart --> + <actionGroup ref="AddToCartFromStorefrontProductPageActionGroup" stepKey="addToCartFromStorefrontProductPage"> + <argument name="productName" value="$createProduct.name$"/> + </actionGroup> + + <!-- Assert sub total on mini shopping cart --> + <actionGroup ref="AssertSubTotalOnStorefrontMiniCartActionGroup" stepKey="assertSubTotalOnStorefrontMiniCart"> + <argument name="subTotal" value="$144.47"/> + </actionGroup> + </test> +</tests> diff --git a/app/code/Magento/CatalogRule/Test/Mftf/Test/StorefrontApplyCatalogRuleForSimpleProductsWithCustomOptionsTest.xml b/app/code/Magento/CatalogRule/Test/Mftf/Test/StorefrontApplyCatalogRuleForSimpleProductsWithCustomOptionsTest.xml new file mode 100644 index 0000000000000..a616a7ab172f1 --- /dev/null +++ b/app/code/Magento/CatalogRule/Test/Mftf/Test/StorefrontApplyCatalogRuleForSimpleProductsWithCustomOptionsTest.xml @@ -0,0 +1,183 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="StorefrontApplyCatalogRuleForSimpleProductsWithCustomOptionsTest"> + <annotations> + <features value="CatalogRule"/> + <stories value="Apply catalog price rule"/> + <title value="Admin should be able to apply the catalog price rule for simple product with 3 custom options"/> + <description value="Admin should be able to apply the catalog price rule for simple product with 3 custom options"/> + <severity value="CRITICAL"/> + <testCaseId value="MC-28345"/> + <group value="catalogRule"/> + <group value="mtf_migrated"/> + <group value="catalog"/> + </annotations> + <before> + <createData entity="_defaultCategory" stepKey="createCategory"/> + <createData entity="_defaultProduct" stepKey="createProduct1"> + <requiredEntity createDataKey="createCategory"/> + <field key="price">56.78</field> + </createData> + <createData entity="_defaultProduct" stepKey="createProduct2"> + <requiredEntity createDataKey="createCategory"/> + <field key="price">56.78</field> + </createData> + <createData entity="_defaultProduct" stepKey="createProduct3"> + <requiredEntity createDataKey="createCategory"/> + <field key="price">56.78</field> + </createData> + + <!-- Update all products to have custom options --> + <updateData createDataKey="createProduct1" entity="productWithCustomOptions" stepKey="updateProduc1tWithOptions"/> + <updateData createDataKey="createProduct2" entity="productWithCustomOptions" stepKey="updateProduct2WithOptions"/> + <updateData createDataKey="createProduct3" entity="productWithCustomOptions" stepKey="updateProduct3WithOptions"/> + + <!-- Login as Admin --> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + + <!-- Clear all catalog price rules before test --> + <actionGroup ref="AdminCatalogPriceRuleDeleteAllActionGroup" stepKey="deleteAllCatalogRulesBeforeTest"/> + <magentoCron groups="index" stepKey="fixInvalidatedIndicesBeforeTest"/> + </before> + <after> + <!-- Delete products and category --> + <deleteData createDataKey="createProduct1" stepKey="deleteProduct1"/> + <deleteData createDataKey="createProduct2" stepKey="deleteProduct2"/> + <deleteData createDataKey="createProduct3" stepKey="deleteProduct3"/> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + + <!-- Delete the catalog price rule --> + <actionGroup ref="AdminCatalogPriceRuleDeleteAllActionGroup" stepKey="deleteAllCatalogRulesAfterTest"/> + <magentoCron groups="index" stepKey="fixInvalidatedIndicesAfterTest"/> + + <!-- Logout --> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logoutFromAdmin"/> + </after> + <!-- 1. Begin creating a new catalog price rule --> + <actionGroup ref="AdminOpenNewCatalogPriceRuleFormPageActionGroup" stepKey="openNewCatalogPriceRulePage"/> + <actionGroup ref="AdminCatalogPriceRuleFillMainInfoActionGroup" stepKey="fillMainInfoForCatalogPriceRule"> + <argument name="groups" value="'NOT LOGGED IN'"/> + </actionGroup> + <actionGroup ref="AdminFillCatalogRuleConditionActionGroup" stepKey="fillConditionsForCatalogPriceRule"> + <argument name="conditionValue" value="$createCategory.id$"/> + </actionGroup> + <actionGroup ref="AdminCatalogPriceRuleFillActionsActionGroup" stepKey="fillActionsForCatalogPriceRule"> + <argument name="apply" value="by_percent"/> + <argument name="discountAmount" value="10"/> + </actionGroup> + <actionGroup ref="AdminCatalogPriceRuleSaveAndApplyActionGroup" stepKey="saveAndApplyCatalogPriceRule"/> + + <!-- Navigate to category on store front --> + <actionGroup ref="StorefrontNavigateCategoryPageActionGroup" stepKey="goToStorefrontCategoryPage"> + <argument name="category" value="$createCategory$"/> + </actionGroup> + + <!-- Check product 1 price on store front category page --> + <actionGroup ref="StorefrontAssertProductPriceOnCategoryPageActionGroup" stepKey="assertStorefrontProduct1Price"> + <argument name="productName" value="$createProduct1.name$"/> + <argument name="productPrice" value="$51.10"/> + </actionGroup> + + <!-- Check product 1 regular price on store front category page --> + <actionGroup ref="StorefrontAssertProductPriceOnCategoryPageActionGroup" stepKey="assertStorefrontProduct1RegularPrice"> + <argument name="productName" value="$createProduct1.name$"/> + <argument name="productPrice" value="$56.78"/> + </actionGroup> + + <!-- Check product 2 price on store front category page --> + <actionGroup ref="StorefrontAssertProductPriceOnCategoryPageActionGroup" stepKey="assertStorefrontProduct2Price"> + <argument name="productName" value="$createProduct2.name$"/> + <argument name="productPrice" value="$51.10"/> + </actionGroup> + + <!-- Check product 2 regular price on store front category page --> + <actionGroup ref="StorefrontAssertProductPriceOnCategoryPageActionGroup" stepKey="assertStorefrontProduct2RegularPrice"> + <argument name="productName" value="$createProduct2.name$"/> + <argument name="productPrice" value="$56.78"/> + </actionGroup> + + <!-- Check product 3 price on store front category page --> + <actionGroup ref="StorefrontAssertProductPriceOnCategoryPageActionGroup" stepKey="assertStorefrontProduct3Price"> + <argument name="productName" value="$createProduct3.name$"/> + <argument name="productPrice" value="$51.10"/> + </actionGroup> + + <!-- Check product 3 regular price on store front category page --> + <actionGroup ref="StorefrontAssertProductPriceOnCategoryPageActionGroup" stepKey="assertStorefrontProduct3RegularPrice"> + <argument name="productName" value="$createProduct3.name$"/> + <argument name="productPrice" value="$56.78"/> + </actionGroup> + + <!-- Navigate to product 1 on store front --> + <actionGroup ref="OpenStoreFrontProductPageActionGroup" stepKey="goToProduct1Page"> + <argument name="productUrlKey" value="$createProduct1.custom_attributes[url_key]$"/> + </actionGroup> + + <!-- Assert regular and special price for product 1 after selecting ProductOptionValueDropdown1 --> + <actionGroup ref="StorefrontSelectCustomOptionDropDownAndAssertPricesActionGroup" stepKey="storefrontSelectCustomOptionAndAssertProduct1Prices"> + <argument name="customOption" value="{{ProductOptionValueDropdown1.title}} +$0.01"/> + <argument name="productPrice" value="$56.79"/> + <argument name="productFinalPrice" value="$51.11"/> + </actionGroup> + + <!-- Add product 1 to cart --> + <actionGroup ref="AddToCartFromStorefrontProductPageActionGroup" stepKey="addToCartFromStorefrontProduct1Page"> + <argument name="productName" value="$createProduct1.name$"/> + </actionGroup> + + <!-- Navigate to product 2 on store front --> + <actionGroup ref="OpenStoreFrontProductPageActionGroup" stepKey="goToProduct2Page"> + <argument name="productUrlKey" value="$createProduct2.custom_attributes[url_key]$"/> + </actionGroup> + + <!-- Assert regular and special price for product 2 after selecting ProductOptionValueDropdown3 --> + <actionGroup ref="StorefrontSelectCustomOptionDropDownAndAssertPricesActionGroup" stepKey="storefrontSelectCustomOptionAndAssertProduct2Prices"> + <argument name="customOption" value="{{ProductOptionValueDropdown3.title}} +$5.11"/> + <argument name="productPrice" value="$62.46"/> + <argument name="productFinalPrice" value="$56.21"/> + </actionGroup> + + <!-- Add product 2 to cart --> + <actionGroup ref="AddToCartFromStorefrontProductPageActionGroup" stepKey="addToCartFromStorefrontProduct2Page"> + <argument name="productName" value="$createProduct2.name$"/> + </actionGroup> + + <!-- Navigate to product 3 on store front --> + <actionGroup ref="OpenStoreFrontProductPageActionGroup" stepKey="goToProduct3Page"> + <argument name="productUrlKey" value="$createProduct3.custom_attributes[url_key]$"/> + </actionGroup> + + <!-- Add product 3 to cart with no custom option --> + <actionGroup ref="AddToCartFromStorefrontProductPageActionGroup" stepKey="addToCartFromStorefrontProduct3Page"> + <argument name="productName" value="$createProduct3.name$"/> + </actionGroup> + + <!-- Assert subtotal on mini shopping cart --> + <actionGroup ref="AssertSubTotalOnStorefrontMiniCartActionGroup" stepKey="assertSubTotalOnStorefrontMiniCart"> + <argument name="subTotal" value="$158.42"/> + </actionGroup> + + <!-- Navigate to checkout shipping page --> + <actionGroup ref="OpenStoreFrontCheckoutShippingPageActionGroup" stepKey="onCheckout"/> + + <!-- Fill Shipping information --> + <actionGroup ref="GuestCheckoutFillingShippingSectionActionGroup" stepKey="fillOrderShippingInfo"> + <argument name="customerVar" value="Simple_US_Customer"/> + <argument name="customerAddressVar" value="US_Address_TX"/> + </actionGroup> + + <!-- Verify order summary on payment page --> + <actionGroup ref="VerifyCheckoutPaymentOrderSummaryActionGroup" stepKey="verifyCheckoutPaymentOrderSummaryActionGroup"> + <argument name="orderSummarySubTotal" value="$158.42"/> + <argument name="orderSummaryShippingTotal" value="$15.00"/> + <argument name="orderSummaryTotal" value="$173.42"/> + </actionGroup> + </test> +</tests> diff --git a/app/code/Magento/CatalogRule/Test/Mftf/Test/StorefrontInactiveCatalogRuleTest.xml b/app/code/Magento/CatalogRule/Test/Mftf/Test/StorefrontInactiveCatalogRuleTest.xml index 264c55ba43390..bebf6ce5302d6 100644 --- a/app/code/Magento/CatalogRule/Test/Mftf/Test/StorefrontInactiveCatalogRuleTest.xml +++ b/app/code/Magento/CatalogRule/Test/Mftf/Test/StorefrontInactiveCatalogRuleTest.xml @@ -35,7 +35,7 @@ <actionGroup ref="AdminCatalogPriceRuleSaveAndApplyActionGroup" stepKey="saveAndApplyFirstPriceRule"/> <!-- Perform reindex --> <actionGroup ref="CliIndexerReindexActionGroup" stepKey="reindex"> - <argument name="indices" value="catalogrule_rule"/> + <argument name="indices" value=""/> </actionGroup> </before> diff --git a/app/code/Magento/CatalogSearch/Model/Advanced.php b/app/code/Magento/CatalogSearch/Model/Advanced.php index 5143762a07e08..ddd937b18467c 100644 --- a/app/code/Magento/CatalogSearch/Model/Advanced.php +++ b/app/code/Magento/CatalogSearch/Model/Advanced.php @@ -233,6 +233,11 @@ public function addFilters($values) ? date('Y-m-d\TH:i:s\Z', strtotime($value['to'])) : ''; } + + if ($attribute->getAttributeCode() === 'sku') { + $value = mb_strtolower($value); + } + $condition = $this->_getResource()->prepareCondition( $attribute, $value, @@ -356,9 +361,13 @@ protected function addSearchCriteria($attribute, $value) */ protected function getPreparedSearchCriteria($attribute, $value) { + $from = null; + $to = null; if (is_array($value)) { if (isset($value['from']) && isset($value['to'])) { if (!empty($value['from']) || !empty($value['to'])) { + $from = ''; + $to = ''; if (isset($value['currency'])) { /** @var $currencyModel Currency */ $currencyModel = $this->_currencyFactory->create()->load($value['currency']); diff --git a/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext.php b/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext.php index e226bdc6900e6..f72516d28c46f 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\CatalogSearch\Model\ResourceModel\Fulltext as FulltextResource; use Magento\Framework\App\ObjectManager; use Magento\Framework\Indexer\DimensionProviderInterface; +use Magento\Framework\Indexer\SaveHandler\IndexerInterface; use Magento\Store\Model\StoreDimensionProvider; use Magento\Indexer\Model\ProcessManager; @@ -33,6 +34,11 @@ class Fulltext implements */ const INDEXER_ID = 'catalogsearch_fulltext'; + /** + * Default batch size + */ + private const BATCH_SIZE = 100; + /** * @var array index structure */ @@ -77,6 +83,11 @@ class Fulltext implements */ private $processManager; + /** + * @var int + */ + private $batchSize; + /** * @param FullFactory $fullActionFactory * @param IndexerHandlerFactory $indexerHandlerFactory @@ -86,6 +97,7 @@ class Fulltext implements * @param DimensionProviderInterface $dimensionProvider * @param array $data * @param ProcessManager $processManager + * @param int|null $batchSize * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ public function __construct( @@ -96,7 +108,8 @@ public function __construct( StateFactory $indexScopeStateFactory, DimensionProviderInterface $dimensionProvider, array $data, - ProcessManager $processManager = null + ProcessManager $processManager = null, + ?int $batchSize = null ) { $this->fullAction = $fullActionFactory->create(['data' => $data]); $this->indexerHandlerFactory = $indexerHandlerFactory; @@ -106,6 +119,7 @@ public function __construct( $this->indexScopeState = ObjectManager::getInstance()->get(State::class); $this->dimensionProvider = $dimensionProvider; $this->processManager = $processManager ?: ObjectManager::getInstance()->get(ProcessManager::class); + $this->batchSize = $batchSize ?? self::BATCH_SIZE; } /** @@ -148,13 +162,42 @@ public function executeByDimensions(array $dimensions, \Traversable $entityIds = } else { // internal implementation works only with array $entityIds = iterator_to_array($entityIds); - $productIds = array_unique( - array_merge($entityIds, $this->fulltextResource->getRelationsByChild($entityIds)) - ); - if ($saveHandler->isAvailable($dimensions)) { - $saveHandler->deleteIndex($dimensions, new \ArrayIterator($productIds)); - $saveHandler->saveIndex($dimensions, $this->fullAction->rebuildStoreIndex($storeId, $productIds)); + $currentBatch = []; + $i = 0; + + foreach ($entityIds as $entityId) { + $currentBatch[] = $entityId; + if (++$i === $this->batchSize) { + $this->processBatch($saveHandler, $dimensions, $currentBatch); + $i = 0; + $currentBatch = []; + } } + if (!empty($currentBatch)) { + $this->processBatch($saveHandler, $dimensions, $currentBatch); + } + } + } + + /** + * Process batch + * + * @param IndexerInterface $saveHandler + * @param array $dimensions + * @param array $entityIds + */ + private function processBatch( + IndexerInterface $saveHandler, + array $dimensions, + array $entityIds + ) : void { + $storeId = $dimensions[StoreDimensionProvider::DIMENSION_NAME]->getValue(); + $productIds = array_unique( + array_merge($entityIds, $this->fulltextResource->getRelationsByChild($entityIds)) + ); + if ($saveHandler->isAvailable($dimensions)) { + $saveHandler->deleteIndex($dimensions, new \ArrayIterator($productIds)); + $saveHandler->saveIndex($dimensions, $this->fullAction->rebuildStoreIndex($storeId, $productIds)); } } diff --git a/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/DataProvider.php b/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/DataProvider.php index 8c4690f044764..3a67025230430 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,24 @@ namespace Magento\CatalogSearch\Model\Indexer\Fulltext\Action; use Magento\Catalog\Api\Data\ProductInterface; +use Magento\Catalog\Model\Product; use Magento\Catalog\Model\Product\Attribute\Source\Status; +use Magento\Catalog\Model\Product\Type; +use Magento\Catalog\Model\ResourceModel\Product\Attribute\CollectionFactory; +use Magento\CatalogSearch\Model\ResourceModel\EngineInterface; +use Magento\CatalogSearch\Model\ResourceModel\EngineProvider; +use Magento\Eav\Model\Config; +use Magento\Eav\Model\Entity\Attribute; use Magento\Framework\App\ResourceConnection; +use Magento\Framework\DataObject; +use Magento\Framework\DB\Adapter\AdapterInterface; use Magento\Framework\DB\Select; +use Magento\Framework\EntityManager\EntityMetadata; +use Magento\Framework\EntityManager\MetadataPool; +use Magento\Framework\Event\ManagerInterface; use Magento\Store\Model\Store; +use Magento\Store\Model\StoreManagerInterface; +use Zend_Db; /** * Catalog search full test search data provider. @@ -24,7 +38,7 @@ class DataProvider /** * Searchable attributes cache * - * @var \Magento\Eav\Model\Entity\Attribute[] + * @var Attribute[] */ private $searchableAttributes; @@ -50,40 +64,40 @@ class DataProvider private $productEmulators = []; /** - * @var \Magento\Catalog\Model\ResourceModel\Product\Attribute\CollectionFactory + * @var CollectionFactory */ private $productAttributeCollectionFactory; /** * Eav config * - * @var \Magento\Eav\Model\Config + * @var Config */ private $eavConfig; /** * Catalog product type * - * @var \Magento\Catalog\Model\Product\Type + * @var Type */ private $catalogProductType; /** * Core event manager proxy * - * @var \Magento\Framework\Event\ManagerInterface + * @var ManagerInterface */ private $eventManager; /** * Store manager * - * @var \Magento\Store\Model\StoreManagerInterface + * @var StoreManagerInterface */ private $storeManager; /** - * @var \Magento\CatalogSearch\Model\ResourceModel\EngineInterface + * @var EngineInterface */ private $engine; @@ -93,12 +107,12 @@ class DataProvider private $resource; /** - * @var \Magento\Framework\DB\Adapter\AdapterInterface + * @var AdapterInterface */ private $connection; /** - * @var \Magento\Framework\EntityManager\EntityMetadata + * @var EntityMetadata */ private $metadata; @@ -126,24 +140,24 @@ class DataProvider /** * @param ResourceConnection $resource - * @param \Magento\Catalog\Model\Product\Type $catalogProductType - * @param \Magento\Eav\Model\Config $eavConfig - * @param \Magento\Catalog\Model\ResourceModel\Product\Attribute\CollectionFactory $prodAttributeCollectionFactory - * @param \Magento\CatalogSearch\Model\ResourceModel\EngineProvider $engineProvider - * @param \Magento\Framework\Event\ManagerInterface $eventManager - * @param \Magento\Store\Model\StoreManagerInterface $storeManager - * @param \Magento\Framework\EntityManager\MetadataPool $metadataPool + * @param Type $catalogProductType + * @param Config $eavConfig + * @param CollectionFactory $prodAttributeCollectionFactory + * @param EngineProvider $engineProvider + * @param ManagerInterface $eventManager + * @param StoreManagerInterface $storeManager + * @param MetadataPool $metadataPool * @param int $antiGapMultiplier */ public function __construct( ResourceConnection $resource, - \Magento\Catalog\Model\Product\Type $catalogProductType, - \Magento\Eav\Model\Config $eavConfig, - \Magento\Catalog\Model\ResourceModel\Product\Attribute\CollectionFactory $prodAttributeCollectionFactory, - \Magento\CatalogSearch\Model\ResourceModel\EngineProvider $engineProvider, - \Magento\Framework\Event\ManagerInterface $eventManager, - \Magento\Store\Model\StoreManagerInterface $storeManager, - \Magento\Framework\EntityManager\MetadataPool $metadataPool, + Type $catalogProductType, + Config $eavConfig, + CollectionFactory $prodAttributeCollectionFactory, + EngineProvider $engineProvider, + ManagerInterface $eventManager, + StoreManagerInterface $storeManager, + MetadataPool $metadataPool, int $antiGapMultiplier = 5 ) { $this->resource = $resource; @@ -224,7 +238,7 @@ private function getSelectForSearchableProducts( $batch ) { $websiteId = (int)$this->storeManager->getStore($storeId)->getWebsiteId(); - $lastProductId = (int) $lastProductId; + $lastProductId = (int)$lastProductId; $select = $this->connection->select() ->useStraightJoin(true) @@ -242,7 +256,7 @@ private function getSelectForSearchableProducts( $this->joinAttribute($select, 'status', $storeId, [Status::STATUS_ENABLED]); if ($productIds !== null) { - $select->where('e.entity_id IN (?)', $productIds, \Zend_Db::INT_TYPE); + $select->where('e.entity_id IN (?)', $productIds, Zend_Db::INT_TYPE); } $select->where('e.entity_id > ?', $lastProductId); $select->order('e.entity_id'); @@ -308,14 +322,17 @@ private function joinAttribute(Select $select, $attributeCode, $storeId, array $ */ public function getSearchableAttributes($backendType = null) { + /** TODO: Remove this block in the next minor release and add a new public method instead */ + if ($this->eavConfig->getEntityType(Product::ENTITY)->getNeedRefreshSearchAttributesList()) { + $this->clearSearchableAttributesList(); + } if (null === $this->searchableAttributes) { $this->searchableAttributes = []; - /** @var \Magento\Catalog\Model\ResourceModel\Product\Attribute\Collection $productAttributes */ $productAttributes = $this->productAttributeCollectionFactory->create(); $productAttributes->addToIndexFilter(true); - /** @var \Magento\Eav\Model\Entity\Attribute[] $attributes */ + /** @var Attribute[] $attributes */ $attributes = $productAttributes->getItems(); /** @deprecated */ @@ -329,7 +346,7 @@ public function getSearchableAttributes($backendType = null) ['engine' => $this->engine, 'attributes' => $attributes] ); - $entity = $this->eavConfig->getEntityType(\Magento\Catalog\Model\Product::ENTITY)->getEntity(); + $entity = $this->eavConfig->getEntityType(Product::ENTITY)->getEntity(); foreach ($attributes as $attribute) { $attribute->setEntity($entity); @@ -355,6 +372,18 @@ public function getSearchableAttributes($backendType = null) return $this->searchableAttributes; } + /** + * Remove searchable attributes list. + * + * @return void + */ + private function clearSearchableAttributesList(): void + { + $this->searchableAttributes = null; + $this->searchableAttributesByBackendType = []; + $this->eavConfig->getEntityType(Product::ENTITY)->unsNeedRefreshSearchAttributesList(); + } + /** * Retrieve searchable attribute by Id or code * @@ -369,7 +398,7 @@ public function getSearchableAttribute($attribute) return $attributes[$attribute]; } - return $this->eavConfig->getAttribute(\Magento\Catalog\Model\Product::ENTITY, $attribute); + return $this->eavConfig->getAttribute(Product::ENTITY, $attribute); } /** @@ -386,6 +415,7 @@ private function unifyField($field, $backendType = 'varchar') } else { $expr = $field; } + return $expr; } @@ -411,7 +441,7 @@ public function getProductAttributes($storeId, array $productIds, array $attribu )->where( 'cpe.entity_id IN (?)', $productIds, - \Zend_Db::INT_TYPE + Zend_Db::INT_TYPE ) ); foreach ($attributeTypes as $backendType => $attributeIds) { @@ -479,6 +509,7 @@ private function getProductTypeInstance($typeId) $this->productTypes[$typeId] = $this->catalogProductType->factory($productEmulator); } + return $this->productTypes[$typeId]; } @@ -513,6 +544,7 @@ public function getProductChildIds($productId, $typeId) if ($relation->getWhere() !== null) { $select->where($relation->getWhere()); } + return $this->connection->fetchCol($select); } @@ -528,10 +560,11 @@ public function getProductChildIds($productId, $typeId) private function getProductEmulator($typeId) { if (!isset($this->productEmulators[$typeId])) { - $productEmulator = new \Magento\Framework\DataObject(); + $productEmulator = new DataObject(); $productEmulator->setTypeId($typeId); $this->productEmulators[$typeId] = $productEmulator; } + return $this->productEmulators[$typeId]; } @@ -660,6 +693,7 @@ function ($value) { $attributeOptionValue .= $this->attributeOptions[$optionKey][$attrValueId] . ' '; } } + return empty($attributeOptionValue) ? null : trim($attributeOptionValue); } diff --git a/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext/Plugin/Attribute.php b/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext/Plugin/Attribute.php index 7b5d43ece922d..3f0046b918f28 100644 --- a/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext/Plugin/Attribute.php +++ b/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext/Plugin/Attribute.php @@ -5,12 +5,15 @@ */ namespace Magento\CatalogSearch\Model\Indexer\Fulltext\Plugin; +use Magento\Catalog\Model\Product; use Magento\CatalogSearch\Model\Indexer\Fulltext; +use Magento\CatalogSearch\Model\Indexer\Fulltext\Action\DataProvider; use Magento\Framework\Model\AbstractModel; use Magento\Catalog\Model\ResourceModel\Attribute as AttributeResourceModel; use Magento\Framework\Search\Request\Config; use Magento\Framework\Indexer\IndexerRegistry; use Magento\Catalog\Api\Data\EavAttributeInterface; +use Magento\Eav\Model\Config as EavConfig; /** * Catalog search indexer plugin for catalog attribute. @@ -37,16 +40,24 @@ class Attribute extends AbstractPlugin */ private $saveIsNew; + /** + * @var EavConfig + */ + private $eavConfig; + /** * @param IndexerRegistry $indexerRegistry * @param Config $config + * @param EavConfig $eavConfig */ public function __construct( IndexerRegistry $indexerRegistry, - Config $config + Config $config, + EavConfig $eavConfig ) { parent::__construct($indexerRegistry); $this->config = $config; + $this->eavConfig = $eavConfig; } /** @@ -84,6 +95,11 @@ public function afterSave( } if ($this->saveIsNew || $this->saveNeedInvalidation) { $this->config->reset(); + /** + * TODO: Remove this in next minor release and use public method instead. + * @see DataProvider::getSearchableAttributes + */ + $this->eavConfig->getEntityType(Product::ENTITY)->setNeedRefreshSearchAttributesList(true); } return $result; diff --git a/app/code/Magento/CatalogSearch/Model/Layer/Filter/Attribute.php b/app/code/Magento/CatalogSearch/Model/Layer/Filter/Attribute.php index b1aecc6885bf0..080af5daa0322 100644 --- a/app/code/Magento/CatalogSearch/Model/Layer/Filter/Attribute.php +++ b/app/code/Magento/CatalogSearch/Model/Layer/Filter/Attribute.php @@ -70,7 +70,7 @@ public function apply(\Magento\Framework\App\RequestInterface $request) $label = $this->getOptionText($value); $labels[] = is_array($label) ? $label : [$label]; } - $label = implode(',', array_unique(array_merge(...$labels))); + $label = implode(',', array_unique(array_merge([], ...$labels))); $this->getLayer() ->getState() ->addFilter($this->_createItem($label, $attributeValue)); diff --git a/app/code/Magento/CatalogSearch/Model/ResourceModel/Advanced/Collection.php b/app/code/Magento/CatalogSearch/Model/ResourceModel/Advanced/Collection.php index 47160bff1d571..6005455a6ef83 100644 --- a/app/code/Magento/CatalogSearch/Model/ResourceModel/Advanced/Collection.php +++ b/app/code/Magento/CatalogSearch/Model/ResourceModel/Advanced/Collection.php @@ -6,27 +6,30 @@ namespace Magento\CatalogSearch\Model\ResourceModel\Advanced; +use Magento\Catalog\Model\Category; use Magento\Catalog\Model\Product; +use Magento\Catalog\Model\ResourceModel\Product\Collection\ProductLimitationFactory; +use Magento\CatalogSearch\Model\ResourceModel\Advanced; use Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection\DefaultFilterStrategyApplyChecker; use Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection\DefaultFilterStrategyApplyCheckerInterface; +use Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection\SearchCriteriaResolverFactory; use Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection\SearchCriteriaResolverInterface; +use Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection\SearchResultApplierFactory; use Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection\SearchResultApplierInterface; -use Magento\Framework\Search\EngineResolverInterface; -use Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection\TotalRecordsResolverInterface; use Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection\TotalRecordsResolverFactory; +use Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection\TotalRecordsResolverInterface; +use Magento\Eav\Model\Entity\Attribute\AbstractAttribute; use Magento\Framework\Api\FilterBuilder; -use Magento\Framework\DB\Select; use Magento\Framework\Api\Search\SearchCriteriaBuilder; use Magento\Framework\Api\Search\SearchResultFactory; +use Magento\Framework\Api\Search\SearchResultInterface; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\DB\Select; use Magento\Framework\EntityManager\MetadataPool; use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\Search\EngineResolverInterface; use Magento\Framework\Search\Request\EmptyRequestDataException; use Magento\Framework\Search\Request\NonExistingRequestNameException; -use Magento\Catalog\Model\ResourceModel\Product\Collection\ProductLimitationFactory; -use Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection\SearchCriteriaResolverFactory; -use Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection\SearchResultApplierFactory; -use Magento\Framework\App\ObjectManager; -use Magento\Framework\Api\Search\SearchResultInterface; /** * Advanced search collection @@ -106,6 +109,11 @@ class Collection extends \Magento\Catalog\Model\ResourceModel\Product\Collection */ private $defaultFilterStrategyApplyChecker; + /** + * @var Advanced + */ + private $advancedSearchResource; + /** * Collection constructor * @@ -141,6 +149,7 @@ class Collection extends \Magento\Catalog\Model\ResourceModel\Product\Collection * @param TotalRecordsResolverFactory|null $totalRecordsResolverFactory * @param EngineResolverInterface|null $engineResolver * @param DefaultFilterStrategyApplyCheckerInterface|null $defaultFilterStrategyApplyChecker + * @param Advanced|null $advancedSearchResource * @SuppressWarnings(PHPMD.ExcessiveParameterList) * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ @@ -176,7 +185,8 @@ public function __construct( SearchResultApplierFactory $searchResultApplierFactory = null, TotalRecordsResolverFactory $totalRecordsResolverFactory = null, EngineResolverInterface $engineResolver = null, - DefaultFilterStrategyApplyCheckerInterface $defaultFilterStrategyApplyChecker = null + DefaultFilterStrategyApplyCheckerInterface $defaultFilterStrategyApplyChecker = null, + Advanced $advancedSearchResource = null ) { $this->searchRequestName = $searchRequestName; if ($searchResultFactory === null) { @@ -193,6 +203,8 @@ public function __construct( ->get(EngineResolverInterface::class); $this->defaultFilterStrategyApplyChecker = $defaultFilterStrategyApplyChecker ?: ObjectManager::getInstance() ->get(DefaultFilterStrategyApplyChecker::class); + $this->advancedSearchResource = $advancedSearchResource ?: ObjectManager::getInstance() + ->get(Advanced::class); parent::__construct( $entityFactory, $logger, @@ -258,6 +270,7 @@ public function setOrder($attribute, $dir = Select::SQL_DESC) */ public function addCategoryFilter(\Magento\Catalog\Model\Category $category) { + $this->setAttributeFilterData(Category::ENTITY, 'category_ids', $category->getId()); /** * This changes need in backward compatible reasons for support dynamic improved algorithm * for price aggregation process. @@ -265,7 +278,6 @@ public function addCategoryFilter(\Magento\Catalog\Model\Category $category) if ($this->defaultFilterStrategyApplyChecker->isApplicable()) { parent::addCategoryFilter($category); } else { - $this->addFieldToFilter('category_ids', $category->getId()); $this->_productLimitationPrice(); } @@ -278,14 +290,13 @@ public function addCategoryFilter(\Magento\Catalog\Model\Category $category) */ public function setVisibility($visibility) { + $this->setAttributeFilterData(Product::ENTITY, 'visibility', $visibility); /** * This changes need in backward compatible reasons for support dynamic improved algorithm * for price aggregation process. */ if ($this->defaultFilterStrategyApplyChecker->isApplicable()) { parent::setVisibility($visibility); - } else { - $this->addFieldToFilter('visibility', $visibility); } return $this; @@ -306,6 +317,25 @@ private function setSearchOrder($field, $direction) $this->searchOrders[$field] = $direction; } + /** + * Prepare attribute data to filter. + * + * @param string $entityType + * @param string $attributeCode + * @param mixed $condition + * @return $this + */ + private function setAttributeFilterData(string $entityType, string $attributeCode, $condition): self + { + /** @var AbstractAttribute $attribute */ + $attribute = $this->_eavConfig->getAttribute($entityType, $attributeCode); + $table = $attribute->getBackend()->getTable(); + $condition = $this->advancedSearchResource->prepareCondition($attribute, $condition); + $this->addFieldsToFilter([$table => [$attributeCode => $condition]]); + + return $this; + } + /** * @inheritdoc */ @@ -377,7 +407,7 @@ public function _loadEntities($printQuery = false, $logQuery = false) $query = $this->getSelect(); $rows = $this->_fetchAll($query); } catch (\Exception $e) { - $this->printLogQuery(false, true, $query); + $this->printLogQuery(false, true, $query ?? null); throw $e; } diff --git a/app/code/Magento/CatalogSearch/Model/Search/Category.php b/app/code/Magento/CatalogSearch/Model/Search/Category.php new file mode 100644 index 0000000000000..200bc81526e66 --- /dev/null +++ b/app/code/Magento/CatalogSearch/Model/Search/Category.php @@ -0,0 +1,120 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\CatalogSearch\Model\Search; + +use Magento\Backend\Helper\Data; +use Magento\Catalog\Api\CategoryListInterface; +use Magento\Framework\Api\FilterBuilder; +use Magento\Framework\Api\SearchCriteriaBuilder; +use Magento\Framework\Api\SearchCriteriaBuilderFactory; +use Magento\Framework\DataObject; +use Magento\Framework\Stdlib\StringUtils; + +/** + * Search model for backend search + */ +class Category extends DataObject +{ + /** + * @var Data + */ + private $adminhtmlData = null; + + /** + * @var CategoryListInterface + */ + private $categoryRepository; + + /** + * @var FilterBuilder + */ + private $filterBuilder; + + /** + * @var SearchCriteriaBuilderFactory + */ + private $searchCriteriaBuilderFactory; + + /** + * @var StringUtils + */ + private $string; + + /** + * @var SearchCriteriaBuilder|void + */ + private $searchCriteriaBuilder; + + /** + * @param Data $adminhtmlData + * @param CategoryListInterface $categoryRepository + * @param SearchCriteriaBuilder $searchCriteriaBuilder + * @param SearchCriteriaBuilderFactory $searchCriteriaBuilderFactory + * @param FilterBuilder $filterBuilder + * @param StringUtils $string + */ + public function __construct( + Data $adminhtmlData, + CategoryListInterface $categoryRepository, + SearchCriteriaBuilder $searchCriteriaBuilder, + SearchCriteriaBuilderFactory $searchCriteriaBuilderFactory, + FilterBuilder $filterBuilder, + StringUtils $string + ) { + $this->adminhtmlData = $adminhtmlData; + $this->categoryRepository = $categoryRepository; + $this->searchCriteriaBuilder = $searchCriteriaBuilder; + $this->searchCriteriaBuilderFactory = $searchCriteriaBuilderFactory; + $this->filterBuilder = $filterBuilder; + $this->string = $string; + } + + /** + * Load search results + * + * @return $this + */ + public function load() + { + $result = []; + if (!$this->hasStart() || !$this->hasLimit() || !$this->hasQuery()) { + $this->setResults($result); + return $this; + } + $this->searchCriteriaBuilder = $this->searchCriteriaBuilderFactory->create(); + $this->searchCriteriaBuilder->setCurrentPage($this->getStart()); + $this->searchCriteriaBuilder->setPageSize($this->getLimit()); + $searchFields = ['name']; + + $filters = []; + foreach ($searchFields as $field) { + $filters[] = $this->filterBuilder + ->setField($field) + ->setConditionType('like') + ->setValue(sprintf("%%%s%%", $this->getQuery())) + ->create(); + } + $this->searchCriteriaBuilder->addFilters($filters); + + $searchCriteria = $this->searchCriteriaBuilder->create(); + $searchResults = $this->categoryRepository->getList($searchCriteria); + + foreach ($searchResults->getItems() as $category) { + $description = $category->getDescription() ? strip_tags($category->getDescription()) : ''; + $result[] = [ + 'id' => sprintf('category/1/%d', $category->getId()), + 'type' => __('Category'), + 'name' => $category->getName(), + 'description' => $this->string->substr($description, 0, 30), + 'url' => $this->adminhtmlData->getUrl('catalog/category/edit', ['id' => $category->getId()]), + ]; + } + $this->setResults($result); + return $this; + } +} diff --git a/app/code/Magento/CatalogSearch/Test/Mftf/Test/AdminCategorySearchTest.xml b/app/code/Magento/CatalogSearch/Test/Mftf/Test/AdminCategorySearchTest.xml new file mode 100644 index 0000000000000..b4ee0144657af --- /dev/null +++ b/app/code/Magento/CatalogSearch/Test/Mftf/Test/AdminCategorySearchTest.xml @@ -0,0 +1,52 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminCategorySearchTest"> + <annotations> + <features value="Search Category"/> + <stories value="Search categories in admin panel"/> + <title value="Search for categories"/> + <description value="Global search in backend can search into Categories."/> + <severity value="MINOR"/> + <group value="Search"/> + <testCaseId value="MC-37809"/> + </annotations> + <before> + <!-- Login as admin --> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + + <!-- Create Simple Category --> + <createData entity="SimpleSubCategory" stepKey="createSimpleCategory"/> + </before> + <after> + <!-- Delete created category --> + <deleteData createDataKey="createSimpleCategory" stepKey="deleteCreatedCategory"/> + + <!-- Log out --> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> + </after> + <!-- Add created category name in the search field--> + <actionGroup ref="AdminSetGlobalSearchValueActionGroup" stepKey="setSearch"> + <argument name="textSearch" value="$$createSimpleCategory.name$$"/> + </actionGroup> + + <!-- Wait for suggested results--> + <waitForElementVisible selector="{{AdminGlobalSearchSection.globalSearchSuggestedCategoryText}}" stepKey="waitForSuggestions"/> + + <!-- Click on suggested result in category URL--> + <click selector="{{AdminGlobalSearchSection.globalSearchSuggestedCategoryLink}}" stepKey="openCategory"/> + + <!-- Wait for suggested results--> + <waitForPageLoad stepKey="waitForPageLoad"/> + + <!-- Loaded page should be edit page of created category --> + <seeInField selector="{{AdminCategoryBasicFieldSection.CategoryNameInput}}" userInput="$$createSimpleCategory.name$$" stepKey="checkCategoryName"/> + </test> +</tests> diff --git a/app/code/Magento/CatalogSearch/Test/Mftf/Test/StorefrontQuickSearchConfigurableChildrenTest.xml b/app/code/Magento/CatalogSearch/Test/Mftf/Test/StorefrontQuickSearchConfigurableChildrenTest.xml index 66d695cbb2025..67e8bc6bf183c 100644 --- a/app/code/Magento/CatalogSearch/Test/Mftf/Test/StorefrontQuickSearchConfigurableChildrenTest.xml +++ b/app/code/Magento/CatalogSearch/Test/Mftf/Test/StorefrontQuickSearchConfigurableChildrenTest.xml @@ -32,7 +32,7 @@ <!-- Assign attribute to set --> <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> - <actionGroup ref="GoToAttributeGridPageActionGroup" stepKey="goToAttributeSetPage"/> + <actionGroup ref="AdminOpenAttributeSetGridPageActionGroup" stepKey="goToAttributeSetPage"/> <actionGroup ref="GoToAttributeSetByNameActionGroup" stepKey="openAttributeSetByName"> <argument name="name" value="$createAttributeSet.attribute_set_name$"/> </actionGroup> diff --git a/app/code/Magento/CatalogSearch/Test/Unit/Model/Indexer/Fulltext/Plugin/AttributeTest.php b/app/code/Magento/CatalogSearch/Test/Unit/Model/Indexer/Fulltext/Plugin/AttributeTest.php index befe462184af6..4d8a7de391356 100644 --- a/app/code/Magento/CatalogSearch/Test/Unit/Model/Indexer/Fulltext/Plugin/AttributeTest.php +++ b/app/code/Magento/CatalogSearch/Test/Unit/Model/Indexer/Fulltext/Plugin/AttributeTest.php @@ -7,8 +7,10 @@ namespace Magento\CatalogSearch\Test\Unit\Model\Indexer\Fulltext\Plugin; +use Magento\Catalog\Model\Product; use Magento\CatalogSearch\Model\Indexer\Fulltext; use Magento\CatalogSearch\Model\Indexer\Fulltext\Plugin\Attribute; +use Magento\Eav\Model\Config as EavConfig; use Magento\Framework\Indexer\IndexerInterface; use Magento\Framework\Indexer\IndexerRegistry; use Magento\Framework\Search\Request\Config; @@ -16,6 +18,9 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +/** + * Unit tests for @see \Magento\CatalogSearch\Model\Indexer\Fulltext\Plugin\Attribute. + */ class AttributeTest extends TestCase { /** @@ -53,6 +58,14 @@ class AttributeTest extends TestCase */ private $config; + /** + * @var EavConfig + */ + private $eavConfig; + + /** + * @inheridoc + */ protected function setUp(): void { $this->objectManager = new ObjectManager($this); @@ -78,11 +91,16 @@ protected function setUp(): void ->disableOriginalConstructor() ->setMethods(['reset']) ->getMock(); + $this->eavConfig = $this->createPartialMock( + EavConfig::class, + ['getEntityType'] + ); $this->model = $this->objectManager->getObject( Attribute::class, [ 'indexerRegistry' => $this->indexerRegistryMock, - 'config' => $this->config + 'config' => $this->config, + 'eavConfig' => $this->eavConfig ] ); } @@ -123,21 +141,26 @@ public function testAfterSaveWithInvalidation(bool $saveNeedInvalidation, bool $ [ 'indexerRegistry' => $this->indexerRegistryMock, 'config' => $this->config, + 'eavConfig' => $this->eavConfig, 'saveNeedInvalidation' => $saveNeedInvalidation, 'saveIsNew' => $saveIsNew, ] ); + if ($saveIsNew || $saveNeedInvalidation) { + $this->config->expects($this->once()) + ->method('reset'); + $catalogProductEntity = $this->createMock(Product::class); + $this->eavConfig->expects($this->once()) + ->method('getEntityType') + ->with(Product::ENTITY) + ->willReturn($catalogProductEntity); + } if ($saveNeedInvalidation) { $this->indexerMock->expects($this->once())->method('invalidate'); $this->prepareIndexer(); } - if ($saveIsNew || $saveNeedInvalidation) { - $this->config->expects($this->once()) - ->method('reset'); - } - $this->assertEquals( $this->subjectMock, $model->afterSave($this->subjectMock, $this->subjectMock) 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 d07b15dbfd5d9..241f00de825d9 100644 --- a/app/code/Magento/CatalogSearch/Test/Unit/Model/Indexer/FulltextTest.php +++ b/app/code/Magento/CatalogSearch/Test/Unit/Model/Indexer/FulltextTest.php @@ -144,7 +144,7 @@ private function setupDataProvider($stores) $dimension = $this->getMockBuilder(Dimension::class) ->disableOriginalConstructor() ->getMock(); - $dimension->expects($this->once()) + $dimension->expects($this->any()) ->method('getValue') ->willReturn($storeId); diff --git a/app/code/Magento/CatalogSearch/Test/Unit/Model/Layer/Filter/CategoryTest.php b/app/code/Magento/CatalogSearch/Test/Unit/Model/Layer/Filter/CategoryTest.php index 6351fb5f5a05b..8ce6f172e0d64 100644 --- a/app/code/Magento/CatalogSearch/Test/Unit/Model/Layer/Filter/CategoryTest.php +++ b/app/code/Magento/CatalogSearch/Test/Unit/Model/Layer/Filter/CategoryTest.php @@ -94,7 +94,7 @@ protected function setUp(): void ->setMethods(['getState', 'getProductCollection']) ->getMock(); - $this->fulltextCollection = $this->fulltextCollection = $this->getMockBuilder( + $this->fulltextCollection = $this->getMockBuilder( Collection::class ) ->disableOriginalConstructor() diff --git a/app/code/Magento/CatalogSearch/Test/Unit/Model/Layer/Filter/DecimalTest.php b/app/code/Magento/CatalogSearch/Test/Unit/Model/Layer/Filter/DecimalTest.php index 40703650c7bea..dc85b68abde71 100644 --- a/app/code/Magento/CatalogSearch/Test/Unit/Model/Layer/Filter/DecimalTest.php +++ b/app/code/Magento/CatalogSearch/Test/Unit/Model/Layer/Filter/DecimalTest.php @@ -84,7 +84,7 @@ protected function setUp(): void ->method('create') ->willReturn($this->filterItem); - $this->fulltextCollection = $this->fulltextCollection = $this->getMockBuilder( + $this->fulltextCollection = $this->getMockBuilder( Collection::class ) ->disableOriginalConstructor() diff --git a/app/code/Magento/CatalogSearch/Test/Unit/Model/Layer/Filter/PriceTest.php b/app/code/Magento/CatalogSearch/Test/Unit/Model/Layer/Filter/PriceTest.php index bb14ad2da9a66..8de684fcc17bf 100644 --- a/app/code/Magento/CatalogSearch/Test/Unit/Model/Layer/Filter/PriceTest.php +++ b/app/code/Magento/CatalogSearch/Test/Unit/Model/Layer/Filter/PriceTest.php @@ -99,7 +99,7 @@ protected function setUp(): void ->method('getState') ->willReturn($this->state); - $this->fulltextCollection = $this->fulltextCollection = $this->getMockBuilder( + $this->fulltextCollection = $this->getMockBuilder( Collection::class ) ->disableOriginalConstructor() diff --git a/app/code/Magento/CatalogSearch/etc/di.xml b/app/code/Magento/CatalogSearch/etc/di.xml index 6ff9119e78c2a..f8e2a262d73ca 100644 --- a/app/code/Magento/CatalogSearch/etc/di.xml +++ b/app/code/Magento/CatalogSearch/etc/di.xml @@ -66,6 +66,10 @@ <item name="class" xsi:type="string">Magento\CatalogSearch\Model\Search\Catalog</item> <item name="acl" xsi:type="string">Magento_Catalog::catalog</item> </item> + <item name="categories" xsi:type="array"> + <item name="class" xsi:type="string">Magento\CatalogSearch\Model\Search\Category</item> + <item name="acl" xsi:type="string">Magento_Catalog::categories</item> + </item> </argument> </arguments> </type> diff --git a/app/code/Magento/CatalogSearch/etc/mview.xml b/app/code/Magento/CatalogSearch/etc/mview.xml index e5580d86d1ef8..494b97a816886 100644 --- a/app/code/Magento/CatalogSearch/etc/mview.xml +++ b/app/code/Magento/CatalogSearch/etc/mview.xml @@ -19,6 +19,7 @@ <table name="catalog_product_bundle_selection" entity_column="parent_product_id" /> <table name="catalog_product_super_link" entity_column="product_id" /> <table name="catalog_product_link" entity_column="product_id" /> + <table name="catalog_category_product" entity_column="product_id" /> </subscriptions> </view> </config> diff --git a/app/code/Magento/CatalogSearch/etc/search_request.xml b/app/code/Magento/CatalogSearch/etc/search_request.xml index 2111c469986ec..ecda6112ba03e 100644 --- a/app/code/Magento/CatalogSearch/etc/search_request.xml +++ b/app/code/Magento/CatalogSearch/etc/search_request.xml @@ -66,6 +66,7 @@ <queryReference clause="should" ref="sku_query"/> <queryReference clause="should" ref="price_query"/> <queryReference clause="should" ref="category_query"/> + <queryReference clause="must" ref="visibility_query"/> </query> <query name="sku_query" xsi:type="filteredQuery"> <filterReference clause="must" ref="sku_query_filter"/> @@ -76,11 +77,15 @@ <query name="category_query" xsi:type="filteredQuery"> <filterReference clause="must" ref="category_filter"/> </query> + <query name="visibility_query" xsi:type="filteredQuery"> + <filterReference clause="must" ref="visibility_filter"/> + </query> </queries> <filters> <filter xsi:type="wildcardFilter" name="sku_query_filter" field="sku" value="$sku$"/> <filter xsi:type="rangeFilter" name="price_query_filter" field="price" from="$price.from$" to="$price.to$"/> <filter xsi:type="termFilter" name="category_filter" field="category_ids" value="$category_ids$"/> + <filter xsi:type="termFilter" name="visibility_filter" field="visibility" value="$visibility$"/> </filters> <from>0</from> <size>10000</size> diff --git a/app/code/Magento/CatalogUrlRewrite/Model/Category/Plugin/Store/Group.php b/app/code/Magento/CatalogUrlRewrite/Model/Category/Plugin/Store/Group.php index 308b82e38c43a..50875b1a418d0 100644 --- a/app/code/Magento/CatalogUrlRewrite/Model/Category/Plugin/Store/Group.php +++ b/app/code/Magento/CatalogUrlRewrite/Model/Category/Plugin/Store/Group.php @@ -121,7 +121,7 @@ public function afterSave( */ protected function generateProductUrls($websiteId, $originWebsiteId) { - $urls = [[]]; + $urls = []; $websiteIds = $websiteId != $originWebsiteId ? [$websiteId, $originWebsiteId] : [$websiteId]; @@ -136,7 +136,7 @@ protected function generateProductUrls($websiteId, $originWebsiteId) $urls[] = $this->productUrlRewriteGenerator->generate($product); } - return array_merge(...$urls); + return array_merge([], ...$urls); } /** @@ -148,7 +148,7 @@ protected function generateProductUrls($websiteId, $originWebsiteId) */ protected function generateCategoryUrls($rootCategoryId, $storeIds) { - $urls = [[]]; + $urls = []; $categories = $this->categoryFactory->create()->getCategories($rootCategoryId, 1, false, true); foreach ($categories as $category) { /** @var \Magento\Catalog\Model\Category $category */ @@ -157,6 +157,6 @@ protected function generateCategoryUrls($rootCategoryId, $storeIds) $urls[] = $this->categoryUrlRewriteGenerator->generate($category); } - return array_merge(...$urls); + return array_merge([], ...$urls); } } diff --git a/app/code/Magento/CatalogUrlRewrite/Model/CategoryUrlRewriteGenerator.php b/app/code/Magento/CatalogUrlRewrite/Model/CategoryUrlRewriteGenerator.php index d48bcd446fcfd..38ca12eaab905 100644 --- a/app/code/Magento/CatalogUrlRewrite/Model/CategoryUrlRewriteGenerator.php +++ b/app/code/Magento/CatalogUrlRewrite/Model/CategoryUrlRewriteGenerator.php @@ -54,6 +54,11 @@ class CategoryUrlRewriteGenerator */ private $mergeDataProviderPrototype; + /** + * @var CategoryRepositoryInterface + */ + private $categoryRepository; + /** * @var bool */ @@ -124,10 +129,11 @@ protected function generateForGlobalScope( $mergeDataProvider = clone $this->mergeDataProviderPrototype; $categoryId = $category->getId(); foreach ($category->getStoreIds() as $storeId) { - $category->setStoreId($storeId); if (!$this->isGlobalScope($storeId) && $this->isOverrideUrlsForStore($storeId, $categoryId, $overrideStoreUrls) ) { + $category = clone $category; // prevent undesired side effects on original object + $category->setStoreId($storeId); $this->updateCategoryUrlForStore($storeId, $category); $mergeDataProvider->merge($this->generateForSpecificStoreView($storeId, $category, $rootCategoryId)); } diff --git a/app/code/Magento/CatalogUrlRewrite/Model/ProductScopeRewriteGenerator.php b/app/code/Magento/CatalogUrlRewrite/Model/ProductScopeRewriteGenerator.php index 9d26184e2c2d4..7bf1da2b814e3 100644 --- a/app/code/Magento/CatalogUrlRewrite/Model/ProductScopeRewriteGenerator.php +++ b/app/code/Magento/CatalogUrlRewrite/Model/ProductScopeRewriteGenerator.php @@ -6,6 +6,7 @@ namespace Magento\CatalogUrlRewrite\Model; use Magento\Catalog\Api\CategoryRepositoryInterface; +use Magento\Catalog\Api\Data\CategoryInterface; use Magento\Catalog\Model\Category; use Magento\Catalog\Model\Product; use Magento\CatalogUrlRewrite\Model\Product\AnchorUrlRewriteGenerator; @@ -15,12 +16,14 @@ use Magento\CatalogUrlRewrite\Service\V1\StoreViewService; use Magento\Framework\App\Config\ScopeConfigInterface; use Magento\Framework\App\ObjectManager; +use Magento\Framework\Exception\NoSuchEntityException; use Magento\Store\Model\Store; use Magento\Store\Model\StoreManagerInterface; use Magento\UrlRewrite\Model\MergeDataProviderFactory; /** - * Class ProductScopeRewriteGenerator + * Generates Product/Category URLs for different scopes + * * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class ProductScopeRewriteGenerator @@ -174,7 +177,6 @@ public function generateForSpecificStoreView($storeId, $productCategories, Produ continue; } - // category should be loaded per appropriate store if category's URL key has been changed $categories[] = $this->getCategoryWithOverriddenUrlKey($storeId, $category); } @@ -240,9 +242,15 @@ public function isCategoryProperForGenerating(Category $category, $storeId) * Checks if URL key has been changed for provided category and returns reloaded category, * in other case - returns provided category. * + * Category should be loaded per appropriate store at all times. This is because whilst the URL key on the + * category in focus might be unchanged, parent category URL keys might be. If the category store ID + * and passed store ID are the same then return current category as it is correct but may have changed in memory + * * @param int $storeId * @param Category $category - * @return Category + * + * @return CategoryInterface + * @throws NoSuchEntityException */ private function getCategoryWithOverriddenUrlKey($storeId, Category $category) { @@ -252,9 +260,10 @@ private function getCategoryWithOverriddenUrlKey($storeId, Category $category) Category::ENTITY ); - if (!$isUrlKeyOverridden) { + if (!$isUrlKeyOverridden && $storeId === $category->getStoreId()) { return $category; } + return $this->categoryRepository->get($category->getEntityId(), $storeId); } diff --git a/app/code/Magento/CatalogUrlRewrite/Observer/AfterImportDataObserver.php b/app/code/Magento/CatalogUrlRewrite/Observer/AfterImportDataObserver.php index b1dfa79373a05..b467771408ec0 100644 --- a/app/code/Magento/CatalogUrlRewrite/Observer/AfterImportDataObserver.php +++ b/app/code/Magento/CatalogUrlRewrite/Observer/AfterImportDataObserver.php @@ -37,8 +37,6 @@ use RuntimeException; /** - * Class AfterImportDataObserver - * * @SuppressWarnings(PHPMD.TooManyFields) * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ @@ -459,8 +457,7 @@ private function categoriesUrlRewriteGenerate(): array } } } - $result = !empty($urls) ? array_merge(...$urls) : []; - return $result; + return array_merge([], ...$urls); } /** diff --git a/app/code/Magento/CatalogUrlRewrite/Plugin/Model/CategorySetSaveRewriteHistory.php b/app/code/Magento/CatalogUrlRewrite/Plugin/Model/CategorySetSaveRewriteHistory.php new file mode 100644 index 0000000000000..b7e4ef12f76d2 --- /dev/null +++ b/app/code/Magento/CatalogUrlRewrite/Plugin/Model/CategorySetSaveRewriteHistory.php @@ -0,0 +1,71 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\CatalogUrlRewrite\Plugin\Model; + +use Magento\Catalog\Model\Category; +use Magento\Framework\Webapi\Rest\Request as RestRequest; +use Magento\CatalogUrlRewrite\Model\CategoryUrlRewriteGenerator; + +class CategorySetSaveRewriteHistory +{ + private const SAVE_REWRITES_HISTORY = 'save_rewrites_history'; + + /** + * @var RestRequest + */ + private $request; + + /** + * @param RestRequest $request + */ + public function __construct(RestRequest $request) + { + $this->request = $request; + } + + /** + * Add 'save_rewrites_history' param to the category for list + * + * @param CategoryUrlRewriteGenerator $subject + * @param Category $category + * @param bool $overrideStoreUrls + * @param int|null $rootCategoryId + * @return array + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function beforeGenerate( + CategoryUrlRewriteGenerator $subject, + Category $category, + bool $overrideStoreUrls = false, + ?int $rootCategoryId = null + ) { + $requestBodyParams = $this->request->getBodyParams(); + + if ($this->isCustomAttributesExists($requestBodyParams, CategoryUrlRewriteGenerator::ENTITY_TYPE)) { + foreach ($requestBodyParams[CategoryUrlRewriteGenerator::ENTITY_TYPE]['custom_attributes'] as $attribute) { + if ($attribute['attribute_code'] === self::SAVE_REWRITES_HISTORY) { + $category->setData(self::SAVE_REWRITES_HISTORY, (bool)$attribute['value']); + } + } + } + + return [$category, $overrideStoreUrls, $rootCategoryId]; + } + + /** + * Check is any custom options exists in data + * + * @param array $requestBodyParams + * @param string $entityCode + * @return bool + */ + private function isCustomAttributesExists(array $requestBodyParams, string $entityCode): bool + { + return !empty($requestBodyParams[$entityCode]['custom_attributes']); + } +} diff --git a/app/code/Magento/CatalogUrlRewrite/Test/Mftf/Test/StorefrontCategoryUrlRewriteDifferentStoreTest.xml b/app/code/Magento/CatalogUrlRewrite/Test/Mftf/Test/StorefrontCategoryUrlRewriteDifferentStoreTest.xml new file mode 100644 index 0000000000000..776b5b9b70f33 --- /dev/null +++ b/app/code/Magento/CatalogUrlRewrite/Test/Mftf/Test/StorefrontCategoryUrlRewriteDifferentStoreTest.xml @@ -0,0 +1,80 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="StorefrontCategoryUrlRewriteDifferentStoreTest"> + <annotations> + <stories value="Url rewrites"/> + <title value="Verify url category for different store view."/> + <description value="Verify url category for different store view, after change ukr_key category for one of them store view."/> + <features value="CatalogUrlRewrite"/> + <severity value="AVERAGE"/> + <testCaseId value="MC-38053"/> + </annotations> + <before> + <magentoCLI command="config:set catalog/seo/product_use_categories 1" stepKey="setEnableUseCategoriesPath"/> + <createData entity="SubCategory" stepKey="rootCategory"/> + <createData entity="SimpleSubCategoryDifferentUrlStore" stepKey="subCategory"> + <requiredEntity createDataKey="rootCategory"/> + </createData> + <createData entity="_defaultProduct" stepKey="createProduct"> + <requiredEntity createDataKey="subCategory"/> + </createData> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + + <actionGroup ref="CreateStoreViewActionGroup" stepKey="createCustomStoreViewFr"> + <argument name="storeView" value="customStoreFR"/> + </actionGroup> + + <actionGroup ref="CliIndexerReindexActionGroup" stepKey="indexerReindexAfterCreate"> + <argument name="indices" value=""/> + </actionGroup> + <actionGroup ref="CliCacheFlushActionGroup" stepKey="flushCacheBefore"> + <argument name="tags" value=""/> + </actionGroup> + </before> + <after> + <magentoCLI command="config:set catalog/seo/product_use_categories 0" stepKey="setEnableUseCategoriesPath"/> + <deleteData stepKey="deleteProduct" createDataKey="createProduct"/> + <deleteData stepKey="deleteSubCategory" createDataKey="subCategory"/> + <deleteData stepKey="deleteRootCategory" createDataKey="rootCategory"/> + + <actionGroup ref="AdminDeleteStoreViewActionGroup" stepKey="deleteStoreView"> + <argument name="customStore" value="customStoreFR"/> + </actionGroup> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> + </after> + + <actionGroup ref="NavigateToCreatedCategoryActionGroup" stepKey="navigateToCreatedSubCategory"> + <argument name="Category" value="$$subCategory$$"/> + </actionGroup> + <actionGroup ref="AdminSwitchStoreViewActionGroup" stepKey="AdminSwitchCustomStoreViewForSubCategory"> + <argument name="storeView" value="customStoreFR.name"/> + </actionGroup> + <actionGroup ref="ChangeSeoUrlKeyForSubCategoryActionGroup" stepKey="changeSeoUrlKeyForSubCategoryCustomStore"> + <argument name="value" value="{{SimpleSubCategoryDifferentUrlStore.url_key_custom_store}}"/> + </actionGroup> + + <actionGroup ref="StorefrontGoToSubCategoryPageActionGroup" stepKey="goToCategoryC"> + <argument name="categoryName" value="$$rootCategory.name$$"/> + <argument name="subCategoryName" value="$$subCategory.name$$"/> + </actionGroup> + + <click selector="{{StorefrontCategoryProductSection.ProductInfoByName($$createProduct.name$$)}}" stepKey="navigateToCreateProduct"/> + + <actionGroup ref="StorefrontSwitchStoreViewActionGroup" stepKey="switchStore"> + <argument name="storeView" value="customStoreFR" /> + </actionGroup> + + <grabFromCurrentUrl stepKey="grabUrl"/> + <assertStringContainsString stepKey="assertUrl"> + <expectedResult type="string">{{SimpleSubCategoryDifferentUrlStore.url_key_custom_store}}</expectedResult> + <actualResult type="string">{$grabUrl}</actualResult> + </assertStringContainsString> + </test> +</tests> diff --git a/app/code/Magento/CatalogUrlRewrite/Test/Unit/Model/CategoryUrlRewriteGeneratorTest.php b/app/code/Magento/CatalogUrlRewrite/Test/Unit/Model/CategoryUrlRewriteGeneratorTest.php index 62a9699b3988d..1626cca6b7ae7 100644 --- a/app/code/Magento/CatalogUrlRewrite/Test/Unit/Model/CategoryUrlRewriteGeneratorTest.php +++ b/app/code/Magento/CatalogUrlRewrite/Test/Unit/Model/CategoryUrlRewriteGeneratorTest.php @@ -158,6 +158,7 @@ public function testGenerationForGlobalScope() ], $this->categoryUrlRewriteGenerator->generate($this->category, false, $categoryId) ); + $this->assertEquals(0, $this->category->getStoreId(), 'Store ID should not have been modified'); } /** diff --git a/app/code/Magento/CatalogUrlRewrite/Test/Unit/Model/ProductScopeRewriteGeneratorTest.php b/app/code/Magento/CatalogUrlRewrite/Test/Unit/Model/ProductScopeRewriteGeneratorTest.php index 7b18461a580fe..d9c6adce9661f 100644 --- a/app/code/Magento/CatalogUrlRewrite/Test/Unit/Model/ProductScopeRewriteGeneratorTest.php +++ b/app/code/Magento/CatalogUrlRewrite/Test/Unit/Model/ProductScopeRewriteGeneratorTest.php @@ -7,6 +7,7 @@ namespace Magento\CatalogUrlRewrite\Test\Unit\Model; +use Magento\Catalog\Api\CategoryRepositoryInterface; use Magento\Catalog\Model\Category; use Magento\Catalog\Model\Product; use Magento\CatalogUrlRewrite\Model\ObjectRegistry; @@ -69,6 +70,9 @@ class ProductScopeRewriteGeneratorTest extends TestCase /** @var ScopeConfigInterface|MockObject */ private $configMock; + /** @var CategoryRepositoryInterface|MockObject */ + private $categoryRepositoryMock; + protected function setUp(): void { $this->serializer = $this->createMock(Json::class); @@ -126,6 +130,8 @@ function ($value) { $this->configMock = $this->getMockBuilder(ScopeConfigInterface::class) ->getMock(); + $this->categoryRepositoryMock = $this->getMockForAbstractClass(CategoryRepositoryInterface::class); + $this->productScopeGenerator = (new ObjectManager($this))->getObject( ProductScopeRewriteGenerator::class, [ @@ -137,7 +143,8 @@ function ($value) { 'storeViewService' => $this->storeViewService, 'storeManager' => $this->storeManager, 'mergeDataProviderFactory' => $mergeDataProviderFactory, - 'config' => $this->configMock + 'config' => $this->configMock, + 'categoryRepository' => $this->categoryRepositoryMock ] ); $this->categoryMock = $this->getMockBuilder(Category::class) @@ -215,6 +222,8 @@ public function testGenerationForSpecificStore() $this->anchorUrlRewriteGenerator->expects($this->any())->method('generate') ->willReturn([]); + $this->categoryRepositoryMock->expects($this->once())->method('get')->willReturn($this->categoryMock); + $this->assertEquals( ['category-1_1' => $canonical], $this->productScopeGenerator->generateForSpecificStoreView(1, [$this->categoryMock], $product, 1) diff --git a/app/code/Magento/CatalogUrlRewrite/etc/webapi_rest/di.xml b/app/code/Magento/CatalogUrlRewrite/etc/webapi_rest/di.xml index 9c5186a5ec0ac..9348b03d17270 100644 --- a/app/code/Magento/CatalogUrlRewrite/etc/webapi_rest/di.xml +++ b/app/code/Magento/CatalogUrlRewrite/etc/webapi_rest/di.xml @@ -9,4 +9,7 @@ <type name="Magento\Webapi\Controller\Rest\InputParamsResolver"> <plugin name="product_save_rewrites_history_rest_plugin" type="Magento\CatalogUrlRewrite\Plugin\Webapi\Controller\Rest\InputParamsResolver" sortOrder="1" disabled="false" /> </type> + <type name="Magento\CatalogUrlRewrite\Model\CategoryUrlRewriteGenerator"> + <plugin name="category_set_save_rewrites_history_rest_plugin" type="Magento\CatalogUrlRewrite\Plugin\Model\CategorySetSaveRewriteHistory" disabled="false" /> + </type> </config> diff --git a/app/code/Magento/CatalogWidget/Block/Product/ProductsList.php b/app/code/Magento/CatalogWidget/Block/Product/ProductsList.php index 9934cc9ad106a..7e6693ce68ef9 100644 --- a/app/code/Magento/CatalogWidget/Block/Product/ProductsList.php +++ b/app/code/Magento/CatalogWidget/Block/Product/ProductsList.php @@ -337,9 +337,13 @@ public function createCollection() $collection->setVisibility($this->catalogProductVisibility->getVisibleInCatalogIds()); + /** + * Change sorting attribute to entity_id because created_at can be the same for products fastly created + * one by one and sorting by created_at is indeterministic in this case. + */ $collection = $this->_addProductAttributesAndPrices($collection) ->addStoreFilter() - ->addAttributeToSort('created_at', 'desc') + ->addAttributeToSort('entity_id', 'desc') ->setPageSize($this->getPageSize()) ->setCurPage($this->getRequest()->getParam($this->getData('page_var_name'), 1)); @@ -506,7 +510,7 @@ public function getPagerHtml() */ public function getIdentities() { - $identities = [[]]; + $identities = []; if ($this->getProductCollection()) { foreach ($this->getProductCollection() as $product) { if ($product instanceof IdentityInterface) { @@ -514,7 +518,7 @@ public function getIdentities() } } } - $identities = array_merge(...$identities); + $identities = array_merge([], ...$identities); return $identities ?: [Product::CACHE_TAG]; } diff --git a/app/code/Magento/CatalogWidget/Test/Mftf/Test/CatalogProductListCheckWidgetOrderTest.xml b/app/code/Magento/CatalogWidget/Test/Mftf/Test/CatalogProductListCheckWidgetOrderTest.xml new file mode 100644 index 0000000000000..1d5e369d50e1d --- /dev/null +++ b/app/code/Magento/CatalogWidget/Test/Mftf/Test/CatalogProductListCheckWidgetOrderTest.xml @@ -0,0 +1,85 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="CatalogProductListCheckWidgetOrderTest"> + <annotations> + <features value="CatalogWidget"/> + <stories value="Product list widget"/> + <title value="Checking order of products in the 'catalog Products List' widget"/> + <description value="Check that products are ordered with recently added products first"/> + <severity value="MAJOR"/> + <testCaseId value="MC-27616"/> + <useCaseId value="MC-5905"/> + <group value="catalogWidget"/> + <group value="catalog"/> + <group value="WYSIWYGDisabled"/> + </annotations> + <before> + <createData entity="SimpleSubCategory" stepKey="simplecategory"/> + <createData entity="SimpleProduct" stepKey="createFirstProduct"> + <requiredEntity createDataKey="simplecategory"/> + <field key="price">10</field> + </createData> + <createData entity="SimpleProduct" stepKey="createSecondProduct"> + <requiredEntity createDataKey="simplecategory"/> + <field key="price">20</field> + </createData> + <createData entity="SimpleProduct" stepKey="createThirdProduct"> + <requiredEntity createDataKey="simplecategory"/> + <field key="price">30</field> + </createData> + <createData entity="_defaultCmsPage" stepKey="createPreReqPage"/> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + <actionGroup ref="EnabledWYSIWYGActionGroup" stepKey="enableWYSIWYG"/> + </before> + <after> + <actionGroup ref="DisabledWYSIWYGActionGroup" stepKey="disableWYSIWYG"/> + <deleteData createDataKey="createPreReqPage" stepKey="deletePreReqPage" /> + <deleteData createDataKey="simplecategory" stepKey="deleteSimpleCategory"/> + <deleteData createDataKey="createFirstProduct" stepKey="deleteFirstProduct"/> + <deleteData createDataKey="createSecondProduct" stepKey="deleteSecondProduct"/> + <deleteData createDataKey="createThirdProduct" stepKey="deleteThirdProduct"/> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logoutFromAdmin"/> + </after> + <!--Open created cms page--> + <actionGroup ref="AdminOpenCmsPageActionGroup" stepKey="openEditPage"> + <argument name="page_id" value="$createPreReqPage.id$"/> + </actionGroup> + <click selector="{{CmsNewPagePageContentSection.header}}" stepKey="clickExpandContentTabForPage"/> + <waitForLoadingMaskToDisappear stepKey="waitForLoadingMask"/> + <!--Add widget to cms page--> + <waitForElementVisible selector="{{TinyMCESection.InsertWidgetIcon}}" stepKey="waitInsertWidgetIconVisible"/> + <click selector="{{TinyMCESection.InsertWidgetIcon}}" stepKey="clickInsertWidgetIcon" /> + <waitForElementVisible selector="{{WidgetSection.WidgetType}}" stepKey="waitForWidgetTypeSelectorVisible"/> + <selectOption selector="{{WidgetSection.WidgetType}}" userInput="Catalog Products List" stepKey="selectCatalogProductsList" /> + <waitForElementVisible selector="{{WidgetSection.AddParam}}" stepKey="waitForAddParamBtnVisible"/> + <click selector="{{WidgetSection.AddParam}}" stepKey="clickAddParamBtn" /> + <waitForElementVisible selector="{{WidgetSection.ConditionsDropdown}}" stepKey="waitForDropdownVisible"/> + <selectOption selector="{{WidgetSection.ConditionsDropdown}}" userInput="Category" stepKey="selectCategoryCondition" /> + <waitForLoadingMaskToDisappear stepKey="waitForLoadingMaskDisappear2" /> + <waitForElementVisible selector="{{WidgetSection.RuleParam}}" stepKey="waitForRuleParamVisible"/> + <click selector="{{WidgetSection.RuleParam}}" stepKey="clickRuleParam" /> + <waitForElementVisible selector="{{WidgetSection.Chooser}}" stepKey="waitForElement" /> + <click selector="{{WidgetSection.Chooser}}" stepKey="clickChooser" /> + <waitForElementVisible selector="{{WidgetSection.PreCreateCategory('$simplecategory.name$')}}" stepKey="waitForCategoryVisible" /> + <click selector="{{WidgetSection.PreCreateCategory('$simplecategory.name$')}}" stepKey="selectCategory" /> + <click selector="{{WidgetSection.InsertWidget}}" stepKey="clickInsertWidget" /> + <!--Save cms page and go to Storefront--> + <actionGroup ref="SaveCmsPageActionGroup" stepKey="saveCmsPage"/> + <actionGroup ref="NavigateToStorefrontForCreatedPageActionGroup" stepKey="navigateToTheStoreFront1"> + <argument name="page" value="$createPreReqPage.identifier$"/> + </actionGroup> + <!--Check order of products: recently added first--> + <waitForElementVisible selector="{{InsertWidgetSection.checkElementStorefrontByName('1','$createThirdProduct.name$')}}" stepKey="waitForThirdProductVisible"/> + <seeElement selector="{{InsertWidgetSection.checkElementStorefrontByName('1','$createThirdProduct.name$')}}" stepKey="seeElementByName1"/> + <seeElement selector="{{InsertWidgetSection.checkElementStorefrontByName('2','$createSecondProduct.name$')}}" stepKey="seeElementByName2"/> + <seeElement selector="{{InsertWidgetSection.checkElementStorefrontByName('3','$createFirstProduct.name$')}}" stepKey="seeElementByName3"/> + </test> +</tests> diff --git a/app/code/Magento/CatalogWidget/Test/Mftf/Test/CatalogProductListWidgetOrderTest.xml b/app/code/Magento/CatalogWidget/Test/Mftf/Test/CatalogProductListWidgetOrderTest.xml index fd87d58e47125..5bd9981a50236 100644 --- a/app/code/Magento/CatalogWidget/Test/Mftf/Test/CatalogProductListWidgetOrderTest.xml +++ b/app/code/Magento/CatalogWidget/Test/Mftf/Test/CatalogProductListWidgetOrderTest.xml @@ -8,18 +8,18 @@ <tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> - <test name="CatalogProductListWidgetOrderTest"> + <test name="CatalogProductListWidgetOrderTest" deprecated="Use CatalogProductListCheckWidgetOrderTest instead"> <annotations> <features value="CatalogWidget"/> <stories value="MC-5905: Wrong sorting on Products component"/> - <title value="Checking order of products in the 'catalog Products List' widget"/> + <title value="Deprecated. Checking order of products in the 'catalog Products List' widget"/> <description value="Check that products are ordered with recently added products first"/> <severity value="MAJOR"/> <testCaseId value="MC-13794"/> <group value="CatalogWidget"/> <group value="WYSIWYGDisabled"/> <skip> - <issueId value="MC-13923"/> + <issueId value="DEPRECATED">Use CatalogProductListCheckWidgetOrderTest instead</issueId> </skip> </annotations> <before> 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 3feb44ee23acf..87a76ab801a1f 100644 --- a/app/code/Magento/CatalogWidget/Test/Unit/Block/Product/ProductsListTest.php +++ b/app/code/Magento/CatalogWidget/Test/Unit/Block/Product/ProductsListTest.php @@ -314,7 +314,7 @@ public function testCreateCollection($pagerEnable, $productsCount, $productsPerP $collection->expects($this->once())->method('addAttributeToSelect')->willReturnSelf(); $collection->expects($this->once())->method('addUrlRewrite')->willReturnSelf(); $collection->expects($this->once())->method('addStoreFilter')->willReturnSelf(); - $collection->expects($this->once())->method('addAttributeToSort')->with('created_at', 'desc')->willReturnSelf(); + $collection->expects($this->once())->method('addAttributeToSort')->with('entity_id', 'desc')->willReturnSelf(); $collection->expects($this->once())->method('setPageSize')->with($expectedPageSize)->willReturnSelf(); $collection->expects($this->once())->method('setCurPage')->willReturnSelf(); $collection->expects($this->once())->method('distinct')->willReturnSelf(); diff --git a/app/code/Magento/Checkout/Api/Exception/PaymentProcessingRateLimitExceededException.php b/app/code/Magento/Checkout/Api/Exception/PaymentProcessingRateLimitExceededException.php new file mode 100644 index 0000000000000..e398bf400391b --- /dev/null +++ b/app/code/Magento/Checkout/Api/Exception/PaymentProcessingRateLimitExceededException.php @@ -0,0 +1,19 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +declare(strict_types=1); + +namespace Magento\Checkout\Api\Exception; + +use Magento\Framework\Exception\LocalizedException; + +/** + * Thrown when too many payment processing requests have been initiated by a user. + */ +class PaymentProcessingRateLimitExceededException extends LocalizedException +{ + +} diff --git a/app/code/Magento/Checkout/Api/PaymentProcessingRateLimiterInterface.php b/app/code/Magento/Checkout/Api/PaymentProcessingRateLimiterInterface.php new file mode 100644 index 0000000000000..d81b79fc8e201 --- /dev/null +++ b/app/code/Magento/Checkout/Api/PaymentProcessingRateLimiterInterface.php @@ -0,0 +1,25 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +declare(strict_types=1); + +namespace Magento\Checkout\Api; + +use Magento\Checkout\Api\Exception\PaymentProcessingRateLimitExceededException; + +/** + * Limits number of times a user can initiate payment processing. + */ +interface PaymentProcessingRateLimiterInterface +{ + /** + * Limit an attempt to initiate a new payment processing. + * + * @return void + * @throws PaymentProcessingRateLimitExceededException + */ + public function limit(): void; +} diff --git a/app/code/Magento/Checkout/Model/CaptchaPaymentProcessingRateLimiter.php b/app/code/Magento/Checkout/Model/CaptchaPaymentProcessingRateLimiter.php new file mode 100644 index 0000000000000..6f71423acbcaa --- /dev/null +++ b/app/code/Magento/Checkout/Model/CaptchaPaymentProcessingRateLimiter.php @@ -0,0 +1,123 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +declare(strict_types=1); + +namespace Magento\Checkout\Model; + +use Magento\Authorization\Model\UserContextInterface; +use Magento\Checkout\Api\Exception\PaymentProcessingRateLimitExceededException; +use Magento\Checkout\Api\PaymentProcessingRateLimiterInterface; +use Magento\Customer\Api\CustomerRepositoryInterface; +use Magento\Captcha\Model\DefaultModel as Captcha; +use Magento\Captcha\Helper\Data as CaptchaHelper; +use Magento\Captcha\Observer\CaptchaStringResolver as CaptchaResolver; +use Magento\Framework\App\RequestInterface; + +/** + * Utilize CAPTCHA as a rate-limiting mechanism. + */ +class CaptchaPaymentProcessingRateLimiter implements PaymentProcessingRateLimiterInterface +{ + public const CAPTCHA_FORM = 'payment_processing_request'; + + /** + * @var UserContextInterface + */ + private $userContext; + + /** + * @var CustomerRepositoryInterface + */ + private $customerRepo; + + /** + * @var CaptchaHelper + */ + private $captchaHelper; + + /** + * @var RequestInterface + */ + private $request; + + /** + * @var CaptchaResolver + */ + private $captchaResolver; + + /** + * CaptchaPaymentProcessingRateLimiter constructor. + * + * @param UserContextInterface $userContext + * @param CustomerRepositoryInterface $customerRepo + * @param CaptchaHelper $captchaHelper + * @param RequestInterface $request + * @param CaptchaResolver $captchaResolver + */ + public function __construct( + UserContextInterface $userContext, + CustomerRepositoryInterface $customerRepo, + CaptchaHelper $captchaHelper, + RequestInterface $request, + CaptchaResolver $captchaResolver + ) { + $this->userContext = $userContext; + $this->customerRepo = $customerRepo; + $this->captchaHelper = $captchaHelper; + $this->request = $request; + $this->captchaResolver = $captchaResolver; + } + + /** + * @inheritDoc + */ + public function limit(): void + { + if ($this->userContext->getUserType() !== UserContextInterface::USER_TYPE_GUEST + && $this->userContext->getUserType() !== UserContextInterface::USER_TYPE_CUSTOMER + && $this->userContext->getUserType() !== null + ) { + return; + } + + $login = $this->retrieveLogin(); + /** @var Captcha $captcha */ + $captcha = $this->captchaHelper->getCaptcha(self::CAPTCHA_FORM); + /** @var PaymentProcessingRateLimitExceededException|null $exception */ + $exception = null; + if ($captcha->isRequired($login)) { + $value = $this->captchaResolver->resolve($this->request, self::CAPTCHA_FORM); + if ($value && !$captcha->isCorrect($value)) { + $exception = new PaymentProcessingRateLimitExceededException(__('Incorrect CAPTCHA')); + } elseif (!$value) { + $exception = new PaymentProcessingRateLimitExceededException( + __('Please provide CAPTCHA code and try again') + ); + } + } + + $captcha->logAttempt($login); + if ($exception) { + throw $exception; + } + } + + /** + * Retrieve current user login. + * + * @return string|null + */ + private function retrieveLogin(): ?string + { + $login = null; + if ($this->userContext->getUserId()) { + $login = $this->customerRepo->getById($this->userContext->getUserId())->getEmail(); + } + + return $login; + } +} diff --git a/app/code/Magento/Checkout/Model/GuestPaymentInformationManagement.php b/app/code/Magento/Checkout/Model/GuestPaymentInformationManagement.php index 8b8d2602fbfc7..2b2824213df79 100644 --- a/app/code/Magento/Checkout/Model/GuestPaymentInformationManagement.php +++ b/app/code/Magento/Checkout/Model/GuestPaymentInformationManagement.php @@ -7,8 +7,8 @@ namespace Magento\Checkout\Model; +use Magento\Checkout\Api\PaymentProcessingRateLimiterInterface; 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; @@ -56,6 +56,11 @@ class GuestPaymentInformationManagement implements \Magento\Checkout\Api\GuestPa */ private $logger; + /** + * @var PaymentProcessingRateLimiterInterface + */ + private $paymentsRateLimiter; + /** * @param \Magento\Quote\Api\GuestBillingAddressManagementInterface $billingAddressManagement * @param \Magento\Quote\Api\GuestPaymentMethodManagementInterface $paymentMethodManagement @@ -63,6 +68,7 @@ class GuestPaymentInformationManagement implements \Magento\Checkout\Api\GuestPa * @param \Magento\Checkout\Api\PaymentInformationManagementInterface $paymentInformationManagement * @param \Magento\Quote\Model\QuoteIdMaskFactory $quoteIdMaskFactory * @param CartRepositoryInterface $cartRepository + * @param PaymentProcessingRateLimiterInterface|null $paymentsRateLimiter * @codeCoverageIgnore */ public function __construct( @@ -71,7 +77,8 @@ public function __construct( \Magento\Quote\Api\GuestCartManagementInterface $cartManagement, \Magento\Checkout\Api\PaymentInformationManagementInterface $paymentInformationManagement, \Magento\Quote\Model\QuoteIdMaskFactory $quoteIdMaskFactory, - CartRepositoryInterface $cartRepository + CartRepositoryInterface $cartRepository, + ?PaymentProcessingRateLimiterInterface $paymentsRateLimiter = null ) { $this->billingAddressManagement = $billingAddressManagement; $this->paymentMethodManagement = $paymentMethodManagement; @@ -79,6 +86,8 @@ public function __construct( $this->paymentInformationManagement = $paymentInformationManagement; $this->quoteIdMaskFactory = $quoteIdMaskFactory; $this->cartRepository = $cartRepository; + $this->paymentsRateLimiter = $paymentsRateLimiter + ?? ObjectManager::getInstance()->get(PaymentProcessingRateLimiterInterface::class); } /** @@ -121,6 +130,8 @@ public function savePaymentInformation( \Magento\Quote\Api\Data\PaymentInterface $paymentMethod, \Magento\Quote\Api\Data\AddressInterface $billingAddress = null ) { + $this->paymentsRateLimiter->limit(); + $quoteIdMask = $this->quoteIdMaskFactory->create()->load($cartId, 'masked_id'); /** @var Quote $quote */ $quote = $this->cartRepository->getActive($quoteIdMask->getQuoteId()); diff --git a/app/code/Magento/Checkout/Model/PaymentCaptchaConfigProvider.php b/app/code/Magento/Checkout/Model/PaymentCaptchaConfigProvider.php new file mode 100644 index 0000000000000..268e765571205 --- /dev/null +++ b/app/code/Magento/Checkout/Model/PaymentCaptchaConfigProvider.php @@ -0,0 +1,88 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +declare(strict_types=1); + +namespace Magento\Checkout\Model; + +use Magento\Captcha\Helper\Data as Helper; +use Magento\Captcha\Model\DefaultModel; +use Magento\Customer\Model\Session as CustomerSession; +use Magento\Store\Model\Store; +use Magento\Store\Model\StoreManagerInterface; + +/** + * Provides frontend with payments CAPTCHA configuration. + * + * @SuppressWarnings(PHPMD.CookieAndSessionMisuse) + */ +class PaymentCaptchaConfigProvider implements ConfigProviderInterface +{ + /** + * @var StoreManagerInterface + */ + private $storeManager; + + /** + * @var Helper + */ + private $captchaData; + + /** + * @var CustomerSession + */ + private $customerSession; + + /** + * @param StoreManagerInterface $storeManager + * @param Helper $captchaData + * @param CustomerSession $customerSession + */ + public function __construct( + StoreManagerInterface $storeManager, + Helper $captchaData, + CustomerSession $customerSession + ) { + $this->storeManager = $storeManager; + $this->captchaData = $captchaData; + $this->customerSession = $customerSession; + } + + /** + * @inheritDoc + */ + public function getConfig() + { + /** @var Store $store */ + $store = $this->storeManager->getStore(); + /** @var DefaultModel $captchaModel */ + $captchaModel = $this->captchaData->getCaptcha(CaptchaPaymentProcessingRateLimiter::CAPTCHA_FORM); + $login = null; + if ($this->customerSession->isLoggedIn()) { + $login = $this->customerSession->getCustomerData()->getEmail(); + } + $required = $captchaModel->isRequired($login); + if ($required) { + $captchaModel->generate(); + $imageSrc = $captchaModel->getImgSrc(); + } else { + $imageSrc = ''; + } + + return [ + 'captcha' => [ + CaptchaPaymentProcessingRateLimiter::CAPTCHA_FORM => [ + 'isCaseSensitive' => (bool)$captchaModel->isCaseSensitive(), + 'imageHeight' => $captchaModel->getHeight(), + 'imageSrc' => $imageSrc, + 'refreshUrl' => $store->getUrl('captcha/refresh', ['_secure' => $store->isCurrentlySecure()]), + 'isRequired' => $required, + 'timestamp' => time() + ] + ] + ]; + } +} diff --git a/app/code/Magento/Checkout/Model/PaymentInformationManagement.php b/app/code/Magento/Checkout/Model/PaymentInformationManagement.php index 2f68aba5ec6ae..a6e448ecdb87e 100644 --- a/app/code/Magento/Checkout/Model/PaymentInformationManagement.php +++ b/app/code/Magento/Checkout/Model/PaymentInformationManagement.php @@ -6,6 +6,8 @@ namespace Magento\Checkout\Model; +use Magento\Checkout\Api\PaymentProcessingRateLimiterInterface; +use Magento\Framework\App\ObjectManager; use Magento\Framework\Exception\CouldNotSaveException; /** @@ -51,12 +53,18 @@ class PaymentInformationManagement implements \Magento\Checkout\Api\PaymentInfor */ private $cartRepository; + /** + * @var PaymentProcessingRateLimiterInterface + */ + private $paymentRateLimiter; + /** * @param \Magento\Quote\Api\BillingAddressManagementInterface $billingAddressManagement * @param \Magento\Quote\Api\PaymentMethodManagementInterface $paymentMethodManagement * @param \Magento\Quote\Api\CartManagementInterface $cartManagement * @param PaymentDetailsFactory $paymentDetailsFactory * @param \Magento\Quote\Api\CartTotalRepositoryInterface $cartTotalsRepository + * @param PaymentProcessingRateLimiterInterface|null $paymentRateLimiter * @codeCoverageIgnore */ public function __construct( @@ -64,13 +72,16 @@ public function __construct( \Magento\Quote\Api\PaymentMethodManagementInterface $paymentMethodManagement, \Magento\Quote\Api\CartManagementInterface $cartManagement, \Magento\Checkout\Model\PaymentDetailsFactory $paymentDetailsFactory, - \Magento\Quote\Api\CartTotalRepositoryInterface $cartTotalsRepository + \Magento\Quote\Api\CartTotalRepositoryInterface $cartTotalsRepository, + ?PaymentProcessingRateLimiterInterface $paymentRateLimiter = null ) { $this->billingAddressManagement = $billingAddressManagement; $this->paymentMethodManagement = $paymentMethodManagement; $this->cartManagement = $cartManagement; $this->paymentDetailsFactory = $paymentDetailsFactory; $this->cartTotalsRepository = $cartTotalsRepository; + $this->paymentRateLimiter = $paymentRateLimiter + ?? ObjectManager::getInstance()->get(PaymentProcessingRateLimiterInterface::class); } /** @@ -110,6 +121,8 @@ public function savePaymentInformation( \Magento\Quote\Api\Data\PaymentInterface $paymentMethod, \Magento\Quote\Api\Data\AddressInterface $billingAddress = null ) { + $this->paymentRateLimiter->limit(); + if ($billingAddress) { /** @var \Magento\Quote\Api\CartRepositoryInterface $quoteRepository */ $quoteRepository = $this->getCartRepository(); @@ -157,7 +170,7 @@ public function getPaymentInformation($cartId) private function getLogger() { if (!$this->logger) { - $this->logger = \Magento\Framework\App\ObjectManager::getInstance()->get(\Psr\Log\LoggerInterface::class); + $this->logger = ObjectManager::getInstance()->get(\Psr\Log\LoggerInterface::class); } return $this->logger; } @@ -171,7 +184,7 @@ private function getLogger() private function getCartRepository() { if (!$this->cartRepository) { - $this->cartRepository = \Magento\Framework\App\ObjectManager::getInstance() + $this->cartRepository = ObjectManager::getInstance() ->get(\Magento\Quote\Api\CartRepositoryInterface::class); } return $this->cartRepository; diff --git a/app/code/Magento/Checkout/Model/ShippingInformationManagement.php b/app/code/Magento/Checkout/Model/ShippingInformationManagement.php index cbbbd9a9b4d01..f397a8ddc9cf1 100644 --- a/app/code/Magento/Checkout/Model/ShippingInformationManagement.php +++ b/app/code/Magento/Checkout/Model/ShippingInformationManagement.php @@ -209,8 +209,11 @@ public function saveAddressInformation( if (!$quote->getIsVirtual() && !$shippingAddress->getShippingRateByCode($shippingAddress->getShippingMethod()) ) { - throw new NoSuchEntityException( + $errorMessage = $methodCode ? __('Carrier with such method not found: %1, %2', $carrierCode, $methodCode) + : __('The shipping method is missing. Select the shipping method and try again.'); + throw new NoSuchEntityException( + $errorMessage ); } diff --git a/app/code/Magento/Checkout/Model/StoreSwitcher/RedirectDataPostprocessor.php b/app/code/Magento/Checkout/Model/StoreSwitcher/RedirectDataPostprocessor.php new file mode 100644 index 0000000000000..04f3d9aa37722 --- /dev/null +++ b/app/code/Magento/Checkout/Model/StoreSwitcher/RedirectDataPostprocessor.php @@ -0,0 +1,91 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Checkout\Model\StoreSwitcher; + +use Magento\Checkout\Model\Session as CheckoutSession; +use Magento\Customer\Model\Session as CustomerSession; +use Magento\Quote\Api\CartRepositoryInterface; +use Magento\Store\Model\StoreSwitcher\ContextInterface; +use Magento\Store\Model\StoreSwitcher\RedirectDataPostprocessorInterface; +use Psr\Log\LoggerInterface; + +/** + * Process checkout data redirected from origin store + * + * @SuppressWarnings(PHPMD.CookieAndSessionMisuse) + */ +class RedirectDataPostprocessor implements RedirectDataPostprocessorInterface +{ + /** + * @var CartRepositoryInterface + */ + private $quoteRepository; + + /** + * @var CustomerSession + */ + private $customerSession; + + /** + * @var CheckoutSession + */ + private $checkoutSession; + + /** + * @var LoggerInterface + */ + private $logger; + + /** + * @param CartRepositoryInterface $quoteRepository + * @param CustomerSession $customerSession + * @param CheckoutSession $checkoutSession + * @param LoggerInterface $logger + */ + public function __construct( + CartRepositoryInterface $quoteRepository, + CustomerSession $customerSession, + CheckoutSession $checkoutSession, + LoggerInterface $logger + ) { + $this->quoteRepository = $quoteRepository; + $this->customerSession = $customerSession; + $this->checkoutSession = $checkoutSession; + $this->logger = $logger; + } + + /** + * @inheritDoc + */ + public function process(ContextInterface $context, array $data): void + { + if (!empty($data['quote_id']) + && $this->checkoutSession->getQuoteId() === null + && !$this->customerSession->isLoggedIn() + ) { + try { + $quote = $this->quoteRepository->get((int) $data['quote_id']); + if ($quote + && $quote->getIsActive() + && in_array($context->getTargetStore()->getId(), $quote->getSharedStoreIds()) + ) { + $this->checkoutSession->setQuoteId($quote->getId()); + } + } catch (\Throwable $e) { + $this->logger->error($e); + } + } + $quote = $this->checkoutSession->getQuote(); + if ($quote->getIsActive()) { + // Update quote items so that product names are updated for current store view + $quote->setStoreId($context->getTargetStore()->getId()); + $quote->getItemsCollection(false); + $this->quoteRepository->save($quote); + } + } +} diff --git a/app/code/Magento/Checkout/Model/StoreSwitcher/RedirectDataPreprocessor.php b/app/code/Magento/Checkout/Model/StoreSwitcher/RedirectDataPreprocessor.php new file mode 100644 index 0000000000000..6047bb8bcad46 --- /dev/null +++ b/app/code/Magento/Checkout/Model/StoreSwitcher/RedirectDataPreprocessor.php @@ -0,0 +1,59 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Checkout\Model\StoreSwitcher; + +use Magento\Checkout\Model\Session as CheckoutSession; +use Magento\Customer\Model\Session as CustomerSession; +use Magento\Store\Model\StoreSwitcher\ContextInterface; +use Magento\Store\Model\StoreSwitcher\RedirectDataPreprocessorInterface; + +/** + * Collect checkout data to be redirected to target store + * + * @SuppressWarnings(PHPMD.CookieAndSessionMisuse) + */ +class RedirectDataPreprocessor implements RedirectDataPreprocessorInterface +{ + /** + * @var CustomerSession + */ + private $customerSession; + + /** + * @var CheckoutSession + */ + private $checkoutSession; + + /** + * @param CustomerSession $customerSession + * @param CheckoutSession $checkoutSession + */ + public function __construct( + CustomerSession $customerSession, + CheckoutSession $checkoutSession + ) { + $this->customerSession = $customerSession; + $this->checkoutSession = $checkoutSession; + } + /** + * @inheritDoc + */ + public function process(ContextInterface $context, array $data): array + { + if ($this->checkoutSession->getQuoteId() && !$this->customerSession->isLoggedIn()) { + $quote = $this->checkoutSession->getQuote(); + if ($quote + && $quote->getIsActive() + && in_array($context->getTargetStore()->getId(), $quote->getSharedStoreIds()) + ) { + $data['quote_id'] = (int) $quote->getId(); + } + } + return $data; + } +} diff --git a/app/code/Magento/Checkout/Model/TotalsInformationManagement.php b/app/code/Magento/Checkout/Model/TotalsInformationManagement.php index efb638d299864..7328f8845545c 100644 --- a/app/code/Magento/Checkout/Model/TotalsInformationManagement.php +++ b/app/code/Magento/Checkout/Model/TotalsInformationManagement.php @@ -6,7 +6,7 @@ namespace Magento\Checkout\Model; /** - * Class TotalsInformationManagement + * Class for management of totals information. */ class TotalsInformationManagement implements \Magento\Checkout\Api\TotalsInformationManagementInterface { @@ -38,7 +38,7 @@ public function __construct( } /** - * {@inheritDoc} + * @inheritDoc */ public function calculate( $cartId, @@ -52,9 +52,11 @@ public function calculate( $quote->setBillingAddress($addressInformation->getAddress()); } else { $quote->setShippingAddress($addressInformation->getAddress()); - $quote->getShippingAddress()->setCollectShippingRates(true)->setShippingMethod( - $addressInformation->getShippingCarrierCode() . '_' . $addressInformation->getShippingMethodCode() - ); + if ($addressInformation->getShippingCarrierCode() && $addressInformation->getShippingMethodCode()) { + $quote->getShippingAddress()->setCollectShippingRates(true)->setShippingMethod( + $addressInformation->getShippingCarrierCode().'_'.$addressInformation->getShippingMethodCode() + ); + } } $quote->collectTotals(); @@ -62,6 +64,8 @@ public function calculate( } /** + * Check if quote have items. + * * @param \Magento\Quote\Model\Quote $quote * @throws \Magento\Framework\Exception\LocalizedException * @return void diff --git a/app/code/Magento/Checkout/Test/Mftf/ActionGroup/CheckoutFillEstimateShippingAndTaxActionGroup.xml b/app/code/Magento/Checkout/Test/Mftf/ActionGroup/CheckoutFillEstimateShippingAndTaxActionGroup.xml index f564e14989e75..49b950fd51fdc 100644 --- a/app/code/Magento/Checkout/Test/Mftf/ActionGroup/CheckoutFillEstimateShippingAndTaxActionGroup.xml +++ b/app/code/Magento/Checkout/Test/Mftf/ActionGroup/CheckoutFillEstimateShippingAndTaxActionGroup.xml @@ -13,10 +13,11 @@ <argument name="address" defaultValue="US_Address_TX" type="entity"/> </arguments> <conditionalClick selector="{{CheckoutCartSummarySection.estimateShippingAndTax}}" dependentSelector="{{CheckoutCartSummarySection.estimateShippingAndTaxSummary}}" visible="false" stepKey="openShippingDetails"/> + <waitForElementVisible selector="{{CheckoutCartSummarySection.country}}" stepKey="waitForSummarySectionLoad"/> <selectOption selector="{{CheckoutCartSummarySection.country}}" userInput="{{address.country_id}}" stepKey="selectCountry"/> <selectOption selector="{{CheckoutCartSummarySection.stateProvince}}" userInput="{{address.state}}" stepKey="selectState"/> <waitForElementVisible selector="{{CheckoutCartSummarySection.postcode}}" stepKey="waitForPostCodeVisible"/> <fillField selector="{{CheckoutCartSummarySection.postcode}}" userInput="{{address.postcode}}" stepKey="selectPostCode"/> <waitForLoadingMaskToDisappear stepKey="waitForLoadingMaskToDiappear"/> </actionGroup> -</actionGroups> \ No newline at end of file +</actionGroups> diff --git a/app/code/Magento/Checkout/Test/Mftf/ActionGroup/CheckoutSelectPurchaseOrderPaymentActionGroup.xml b/app/code/Magento/Checkout/Test/Mftf/ActionGroup/CheckoutSelectPurchaseOrderPaymentActionGroup.xml new file mode 100644 index 0000000000000..dbc9739a9247f --- /dev/null +++ b/app/code/Magento/Checkout/Test/Mftf/ActionGroup/CheckoutSelectPurchaseOrderPaymentActionGroup.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="CheckoutSelectPurchaseOrderPaymentActionGroup"> + <annotations> + <description>Selects the 'Purchase Order' Payment Method on the Storefront Checkout page.</description> + </annotations> + + <arguments> + <argument name="purchaseOrderNumber" type="string"/> + </arguments> + <waitForLoadingMaskToDisappear stepKey="waitForLoadingMask"/> + <waitForPageLoad stepKey="waitForPageLoad"/> + <remove keyForRemoval="checkCheckMoneyOption"/> + <conditionalClick selector="{{CheckoutPaymentSection.purchaseOrderPayment}}" dependentSelector="{{CheckoutPaymentSection.purchaseOrderPayment}}" visible="true" stepKey="checkPurchaseOrderOption"/> + <fillField selector="{{StorefrontCheckoutPaymentMethodSection.purchaseOrderNumber}}" userInput="{{purchaseOrderNumber}}" stepKey="fillPurchaseOrderNumber"/> + <waitForLoadingMaskToDisappear stepKey="waitForLoadingMaskAfterPaymentMethodSelection"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Checkout/Test/Mftf/ActionGroup/LoggedInUserCheckoutFillingShippingSectionActionGroup.xml b/app/code/Magento/Checkout/Test/Mftf/ActionGroup/LoggedInUserCheckoutFillingShippingSectionActionGroup.xml index 60188224871eb..4b6680442a470 100644 --- a/app/code/Magento/Checkout/Test/Mftf/ActionGroup/LoggedInUserCheckoutFillingShippingSectionActionGroup.xml +++ b/app/code/Magento/Checkout/Test/Mftf/ActionGroup/LoggedInUserCheckoutFillingShippingSectionActionGroup.xml @@ -24,11 +24,12 @@ <selectOption selector="{{CheckoutShippingSection.region}}" userInput="{{customerAddressVar.state}}" stepKey="selectRegion"/> <fillField selector="{{CheckoutShippingSection.postcode}}" userInput="{{customerAddressVar.postcode}}" stepKey="enterPostcode"/> <fillField selector="{{CheckoutShippingSection.telephone}}" userInput="{{customerAddressVar.telephone}}" stepKey="enterTelephone"/> - <waitForLoadingMaskToDisappear stepKey="waitForLoadingMask"/> + <waitForPageLoad stepKey="waitForLoadingMask"/> <click selector="{{CheckoutShippingSection.firstShippingMethod}}" stepKey="selectFirstShippingMethod"/> - <waitForElement selector="{{CheckoutShippingSection.next}}" time="30" stepKey="waitForNextButton"/> + <waitForElementVisible selector="{{CheckoutShippingSection.next}}" time="30" stepKey="waitForNextButton"/> + <waitForPageLoad stepKey="waitForShippingLoadingMask"/> <click selector="{{CheckoutShippingSection.next}}" stepKey="clickNext"/> - <waitForElement selector="{{CheckoutPaymentSection.paymentSectionTitle}}" time="30" stepKey="waitForPaymentSectionLoaded"/> + <waitForElementVisible selector="{{CheckoutPaymentSection.paymentSectionTitle}}" time="30" stepKey="waitForPaymentSectionLoaded"/> <seeInCurrentUrl url="{{CheckoutPage.url}}/#payment" stepKey="assertCheckoutPaymentUrl"/> </actionGroup> </actionGroups> diff --git a/app/code/Magento/Checkout/Test/Mftf/ActionGroup/StorefrontAddCategoryProductToCartWithQuantityActionGroup.xml b/app/code/Magento/Checkout/Test/Mftf/ActionGroup/StorefrontAddCategoryProductToCartWithQuantityActionGroup.xml index c4fe115b70ae4..9b1a6bcceb89f 100644 --- a/app/code/Magento/Checkout/Test/Mftf/ActionGroup/StorefrontAddCategoryProductToCartWithQuantityActionGroup.xml +++ b/app/code/Magento/Checkout/Test/Mftf/ActionGroup/StorefrontAddCategoryProductToCartWithQuantityActionGroup.xml @@ -26,6 +26,8 @@ <waitForText userInput="{{checkQuantity}}" selector="{{StorefrontMinicartSection.productCount}}" time="30" stepKey="assertProductCount"/> <conditionalClick selector="{{StorefrontMinicartSection.showCart}}" dependentSelector="{{StorefrontMinicartSection.miniCartOpened}}" visible="false" stepKey="openMiniCart"/> <waitForElementVisible selector="{{StorefrontMinicartSection.viewAndEditCart}}" stepKey="waitForViewAndEditCartVisible"/> + <doubleClick selector="{{StorefrontMinicartSection.itemQuantity(product.name)}}" stepKey="doubleClickOnQtyInput"/> + <pressKey selector="{{StorefrontMinicartSection.itemQuantity(product.name)}}" parameterArray="[\Facebook\WebDriver\WebDriverKeys::DELETE]" stepKey="clearQty"/> <fillField selector="{{StorefrontMinicartSection.itemQuantity(product.name)}}" userInput="{{quantity}}" stepKey="setProductQtyToFiftyInMiniCart"/> <click selector="{{StorefrontMinicartSection.itemQuantityUpdate(product.name)}}" stepKey="updateQtyInMiniCart"/> </actionGroup> diff --git a/app/code/Magento/Checkout/Test/Mftf/ActionGroup/StorefrontCheckCartActionGroup.xml b/app/code/Magento/Checkout/Test/Mftf/ActionGroup/StorefrontCheckCartActionGroup.xml index bbad2579a47d2..4cc0ac3bc3a06 100644 --- a/app/code/Magento/Checkout/Test/Mftf/ActionGroup/StorefrontCheckCartActionGroup.xml +++ b/app/code/Magento/Checkout/Test/Mftf/ActionGroup/StorefrontCheckCartActionGroup.xml @@ -27,9 +27,7 @@ <scrollTo selector="{{CheckoutCartSummarySection.subtotal}}" stepKey="scrollToSummary"/> <see userInput="{{subtotal}}" selector="{{CheckoutCartSummarySection.subtotal}}" stepKey="assertSubtotal"/> <see userInput="({{shippingMethod}})" selector="{{CheckoutCartSummarySection.shippingMethod}}" stepKey="assertShippingMethod"/> - <reloadPage stepKey="reloadPage" after="assertShippingMethod" /> - <waitForPageLoad stepKey="WaitForPageLoaded" after="reloadPage" /> - <waitForText userInput="{{shipping}}" selector="{{CheckoutCartSummarySection.shipping}}" time="45" stepKey="assertShipping" after="WaitForPageLoaded"/> - <see userInput="{{total}}" selector="{{CheckoutCartSummarySection.total}}" stepKey="assertTotal" after="assertShipping"/> + <waitForText userInput="{{shipping}}" selector="{{CheckoutCartSummarySection.shipping}}" time="45" stepKey="assertShipping"/> + <see userInput="{{total}}" selector="{{CheckoutCartSummarySection.total}}" stepKey="assertTotal"/> </actionGroup> </actionGroups> diff --git a/app/code/Magento/Checkout/Test/Mftf/ActionGroup/StorefrontEnterProductQuantityAndAddToTheCartActionGroup.xml b/app/code/Magento/Checkout/Test/Mftf/ActionGroup/StorefrontEnterProductQuantityAndAddToTheCartActionGroup.xml index 293d1060a6c01..053038b896a68 100644 --- a/app/code/Magento/Checkout/Test/Mftf/ActionGroup/StorefrontEnterProductQuantityAndAddToTheCartActionGroup.xml +++ b/app/code/Magento/Checkout/Test/Mftf/ActionGroup/StorefrontEnterProductQuantityAndAddToTheCartActionGroup.xml @@ -15,10 +15,11 @@ <arguments> <argument name="quantity" type="string"/> </arguments> - + <clearField selector="{{StorefrontBundleProductActionSection.quantityField}}" stepKey="clearTheQuantityField"/> <fillField selector="{{StorefrontBundleProductActionSection.quantityField}}" userInput="{{quantity}}" stepKey="fillTheProductQuantity"/> <click selector="{{StorefrontBundleProductActionSection.addToCartButton}}" stepKey="clickOnAddToButton"/> - <waitForPageLoad stepKey="waitForPageLoad2"/> + <waitForPageLoad stepKey="waitForPageLoad"/> + <waitForElementVisible selector="{{StorefrontMessagesSection.success}}" stepKey="waitForSuccessMessage"/> </actionGroup> </actionGroups> diff --git a/app/code/Magento/Checkout/Test/Mftf/ActionGroup/StorefrontOpenCartFromMinicartActionGroup.xml b/app/code/Magento/Checkout/Test/Mftf/ActionGroup/StorefrontOpenCartFromMinicartActionGroup.xml index 3161d1a63b6f8..de7307add8325 100644 --- a/app/code/Magento/Checkout/Test/Mftf/ActionGroup/StorefrontOpenCartFromMinicartActionGroup.xml +++ b/app/code/Magento/Checkout/Test/Mftf/ActionGroup/StorefrontOpenCartFromMinicartActionGroup.xml @@ -13,6 +13,7 @@ <description>Clicks on the Storefront Mini Shopping Cart icon. Click on 'View and Edit Cart'.</description> </annotations> + <scrollToTopOfPage stepKey="scrollToTopOfThePage" /> <waitForElement selector="{{StorefrontMinicartSection.showCart}}" stepKey="waitForShowMinicart"/> <waitForElement selector="{{StorefrontMinicartSection.viewAndEditCart}}" stepKey="waitForCartLink"/> <click selector="{{StorefrontMinicartSection.showCart}}" stepKey="clickShowMinicart"/> diff --git a/app/code/Magento/Checkout/Test/Mftf/Data/ConfigData.xml b/app/code/Magento/Checkout/Test/Mftf/Data/ConfigData.xml index 9ab8a64c9ab88..216f01a95e890 100644 --- a/app/code/Magento/Checkout/Test/Mftf/Data/ConfigData.xml +++ b/app/code/Magento/Checkout/Test/Mftf/Data/ConfigData.xml @@ -81,6 +81,13 @@ <data key="label">All Allowed Countries</data> <data key="value">0</data> </entity> + <entity name="EnableFlatRateShowMethodNoApplicableConfigData"> + <data key="path">carriers/flatrate/showmethod</data> + <data key="scope">carriers</data> + <data key="scope_id">1</data> + <data key="label">Show Method if Not Applicable</data> + <data key="value">1</data> + </entity> <entity name="DisableFlatRateConfigData"> <data key="path">carriers/flatrate/active</data> <data key="scope">carriers</data> diff --git a/app/code/Magento/Checkout/Test/Mftf/Section/CheckoutCartSummarySection.xml b/app/code/Magento/Checkout/Test/Mftf/Section/CheckoutCartSummarySection.xml index de71fc3f8ad0e..8804fc33e6b31 100644 --- a/app/code/Magento/Checkout/Test/Mftf/Section/CheckoutCartSummarySection.xml +++ b/app/code/Magento/Checkout/Test/Mftf/Section/CheckoutCartSummarySection.xml @@ -9,6 +9,7 @@ <sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> <section name="CheckoutCartSummarySection"> + <element name="itemsInCartLabel" type="text" selector="//*[@id='opc-sidebar']/div[1]/div/div[1]/strong/span[2]"/> <element name="orderTotal" type="input" selector=".grand.totals .amount .price"/> <element name="subTotal" type="input" selector="span[data-th='Subtotal']"/> <element name="expandShoppingCartSummary" type="button" selector="//*[contains(@class, 'items-in-cart')][not(contains(@class, 'active'))]"/> @@ -34,7 +35,7 @@ <element name="shippingMethodLabel" type="text" selector="#co-shipping-method-form dl dt span"/> <element name="methodName" type="text" selector="#co-shipping-method-form label"/> <element name="shippingPrice" type="text" selector="#co-shipping-method-form span .price"/> - <element name="shippingMethodElementId" type="radio" selector="#s_method_{{carrierCode}}_{{methodCode}}" parameterized="true"/> + <element name="shippingMethodElementId" type="radio" selector="#s_method_{{carrierCode}}_{{methodCode}}" parameterized="true" timeout="30"/> <element name="estimateShippingAndTaxForm" type="block" selector="#shipping-zip-form"/> </section> </sections> diff --git a/app/code/Magento/Checkout/Test/Mftf/Section/CheckoutPaymentSection.xml b/app/code/Magento/Checkout/Test/Mftf/Section/CheckoutPaymentSection.xml index 1c9933064154a..100567d503c77 100644 --- a/app/code/Magento/Checkout/Test/Mftf/Section/CheckoutPaymentSection.xml +++ b/app/code/Magento/Checkout/Test/Mftf/Section/CheckoutPaymentSection.xml @@ -32,6 +32,7 @@ <element name="cartItemsArea" type="button" selector="div.block.items-in-cart"/> <element name="cartItemsAreaActive" type="textarea" selector="div.block.items-in-cart.active" timeout="30"/> <element name="checkMoneyOrderPayment" type="radio" selector="input#checkmo.radio" timeout="30"/> + <element name="purchaseOrderPayment" type="radio" selector="input#purchaseorder.radio" timeout="30"/> <element name="placeOrder" type="button" selector=".payment-method._active button.action.primary.checkout" timeout="30"/> <element name="placeOrderWithoutTimeout" type="button" selector=".payment-method._active button.action.primary.checkout"/> <element name="paymentSectionTitle" type="text" selector="//*[@id='checkout-payment-method-load']//div[@data-role='title']" /> diff --git a/app/code/Magento/Checkout/Test/Mftf/Section/CheckoutShippingMethodsSection.xml b/app/code/Magento/Checkout/Test/Mftf/Section/CheckoutShippingMethodsSection.xml index 2f49e4f422a6e..233cc539e08a6 100644 --- a/app/code/Magento/Checkout/Test/Mftf/Section/CheckoutShippingMethodsSection.xml +++ b/app/code/Magento/Checkout/Test/Mftf/Section/CheckoutShippingMethodsSection.xml @@ -20,5 +20,6 @@ <element name="shippingMethodLoader" type="button" selector="//div[contains(@class, 'checkout-shipping-method')]/following-sibling::div[contains(@class, 'loading-mask')]"/> <element name="freeShippingShippingMethod" type="input" selector="#s_method_freeshipping_freeshipping" timeout="30"/> <element name="noQuotesMsg" type="text" selector="#checkout-step-shipping_method div"/> + <element name="price" type="text" selector="//*[@id='checkout-shipping-method-load']//td[@class='col col-price']"/> </section> </sections> diff --git a/app/code/Magento/Checkout/Test/Mftf/Section/StorefrontMinicartSection.xml b/app/code/Magento/Checkout/Test/Mftf/Section/StorefrontMinicartSection.xml index 668d33d26f37a..d1c837ff244f1 100644 --- a/app/code/Magento/Checkout/Test/Mftf/Section/StorefrontMinicartSection.xml +++ b/app/code/Magento/Checkout/Test/Mftf/Section/StorefrontMinicartSection.xml @@ -39,9 +39,11 @@ <element name="emptyMiniCart" type="text" selector="//div[@class='minicart-wrapper']//span[@class='counter qty empty']/../.."/> <element name="minicartContent" type="block" selector="#minicart-content-wrapper"/> <element name="messageEmptyCart" type="text" selector="//*[@id='minicart-content-wrapper']//*[contains(@class,'subtitle empty')]"/> + <element name="emptyCartMessageContent" type="text" selector="#minicart-content-wrapper .minicart.empty.text" timeout="30"/> <element name="visibleItemsCountText" type="text" selector="//div[@class='items-total']"/> <element name="productQuantity" type="input" selector="//*[@id='mini-cart']//a[contains(text(),'{{productName}}')]/../..//div[@class='details-qty qty']//input[@data-item-qty='{{qty}}']" parameterized="true"/> <element name="productImage" type="text" selector="//ol[@id='mini-cart']//img[@class='product-image-photo']"/> <element name="productSubTotal" type="text" selector="//div[@class='subtotal']//span/span[@class='price']"/> + <element name="productCountLabel" type="text" selector="//*[@id='minicart-content-wrapper']/div[2]/div[1]/span[2]"/> </section> </sections> diff --git a/app/code/Magento/Checkout/Test/Mftf/Test/AddressStateFieldForUKCustomerRemainOptionAfterRefreshTest.xml b/app/code/Magento/Checkout/Test/Mftf/Test/AddressStateFieldForUKCustomerRemainOptionAfterRefreshTest.xml index e7e8f9f0ef699..f578a9c02caca 100644 --- a/app/code/Magento/Checkout/Test/Mftf/Test/AddressStateFieldForUKCustomerRemainOptionAfterRefreshTest.xml +++ b/app/code/Magento/Checkout/Test/Mftf/Test/AddressStateFieldForUKCustomerRemainOptionAfterRefreshTest.xml @@ -42,8 +42,8 @@ <actionGroup ref="GoToCheckoutFromMinicartActionGroup" stepKey="guestGoToCheckoutFromMinicart" /> <selectOption stepKey="selectCounty" selector="{{CheckoutShippingSection.country}}" userInput="{{UK_Address.country_id}}"/> <waitForPageLoad stepKey="waitFormToReload"/> - <reloadPage stepKey="refreshPage"/> - <waitForPageLoad stepKey="waitFormToReload1"/> + <actionGroup ref="ReloadPageActionGroup" stepKey="refreshPage"/> + <comment userInput="Replacing reload action and preserve Backward Compatibility" stepKey="waitFormToReload1" /> <fillField selector="{{CheckoutShippingSection.email}}" userInput="{{CustomerEntityOne.email}}" stepKey="enterEmail"/> <fillField selector="{{CheckoutShippingSection.firstName}}" userInput="{{CustomerEntityOne.firstname}}" stepKey="enterFirstName"/> <fillField selector="{{CheckoutShippingSection.lastName}}" userInput="{{CustomerEntityOne.lastname}}" stepKey="enterLastName"/> diff --git a/app/code/Magento/Checkout/Test/Mftf/Test/AdminCheckConfigsChangesIsNotAffectedStartedCheckoutProcessTest.xml b/app/code/Magento/Checkout/Test/Mftf/Test/AdminCheckConfigsChangesIsNotAffectedStartedCheckoutProcessTest.xml index a1065daedd4f8..df229c4b6ed78 100644 --- a/app/code/Magento/Checkout/Test/Mftf/Test/AdminCheckConfigsChangesIsNotAffectedStartedCheckoutProcessTest.xml +++ b/app/code/Magento/Checkout/Test/Mftf/Test/AdminCheckConfigsChangesIsNotAffectedStartedCheckoutProcessTest.xml @@ -90,8 +90,8 @@ <!-- Back to the Checkout and refresh the page --> <switchToPreviousTab stepKey="switchToPreviousTab"/> - <reloadPage stepKey="refreshPage"/> - <waitForPageLoad stepKey="waitPageReload"/> + <actionGroup ref="ReloadPageActionGroup" stepKey="refreshPage"/> + <comment userInput="Replacing reload action and preserve Backward Compatibility" stepKey="waitPageReload"/> <!-- Payment step is opened after refreshing --> <waitForElement selector="{{CheckoutPaymentSection.paymentSectionTitle}}" stepKey="waitForPaymentSection"/> diff --git a/app/code/Magento/Checkout/Test/Mftf/Test/CheckCheckoutSuccessPageTest/CheckCheckoutSuccessPageAsGuestTest.xml b/app/code/Magento/Checkout/Test/Mftf/Test/CheckCheckoutSuccessPageTest/CheckCheckoutSuccessPageAsGuestTest.xml index febeaa05be43e..3b15b9b4e0449 100644 --- a/app/code/Magento/Checkout/Test/Mftf/Test/CheckCheckoutSuccessPageTest/CheckCheckoutSuccessPageAsGuestTest.xml +++ b/app/code/Magento/Checkout/Test/Mftf/Test/CheckCheckoutSuccessPageTest/CheckCheckoutSuccessPageAsGuestTest.xml @@ -23,7 +23,22 @@ </before> <after> + <!--Cancel orders--> + <actionGroup ref="AdminLoginActionGroup" stepKey="adminLogin"/> + <actionGroup ref="AdminOrdersGridClearFiltersActionGroup" stepKey="goToOrdersPage"/> + <actionGroup ref="AdminGridColumnShowActionGroup" stepKey="showCustomerEmailColumn"> + <argument name="columnLabel" value="Customer Email"/> + </actionGroup> + <actionGroup ref="AdminGridFilterFillInputFieldActionGroup" stepKey="filterOrdersByCustomerEmail"> + <argument name="filterInputName" value="customer_email"/> + <argument name="filterValue" value="{{CustomerEntityOne.email}}"/> + </actionGroup> + <actionGroup ref="AdminGridFilterApplyActionGroup" stepKey="applyFilters"/> + <actionGroup ref="AdminGridBulkActionGroup" stepKey="cancelOrders"> + <argument name="actionLabel" value="Cancel"/> + </actionGroup> <actionGroup ref="AdminLogoutActionGroup" stepKey="adminLogout"/> + <deleteData createDataKey="createSimpleProduct" stepKey="deleteProduct"/> </after> diff --git a/app/code/Magento/Checkout/Test/Mftf/Test/CheckCheckoutSuccessPageTest/CheckCheckoutSuccessPageAsRegisterCustomerTest.xml b/app/code/Magento/Checkout/Test/Mftf/Test/CheckCheckoutSuccessPageTest/CheckCheckoutSuccessPageAsRegisterCustomerTest.xml index b1d2f42a872cd..8836d54187cbb 100644 --- a/app/code/Magento/Checkout/Test/Mftf/Test/CheckCheckoutSuccessPageTest/CheckCheckoutSuccessPageAsRegisterCustomerTest.xml +++ b/app/code/Magento/Checkout/Test/Mftf/Test/CheckCheckoutSuccessPageTest/CheckCheckoutSuccessPageAsRegisterCustomerTest.xml @@ -26,10 +26,25 @@ </before> <after> + <!--Cancel orders--> + <actionGroup ref="AdminLoginActionGroup" stepKey="adminLogin"/> + <actionGroup ref="AdminOrdersGridClearFiltersActionGroup" stepKey="goToOrdersPage"/> + <actionGroup ref="AdminGridColumnShowActionGroup" stepKey="showCustomerEmailColumn"> + <argument name="columnLabel" value="Customer Email"/> + </actionGroup> + <actionGroup ref="AdminGridFilterFillInputFieldActionGroup" stepKey="filterOrdersByCustomerEmail"> + <argument name="filterInputName" value="customer_email"/> + <argument name="filterValue" value="$$createSimpleUsCustomer.email$$"/> + </actionGroup> + <actionGroup ref="AdminGridFilterApplyActionGroup" stepKey="applyFilters"/> + <actionGroup ref="AdminGridBulkActionGroup" stepKey="cancelOrders"> + <argument name="actionLabel" value="Cancel"/> + </actionGroup> + <actionGroup ref="AdminLogoutActionGroup" stepKey="adminLogout"/> + <!--Logout from customer account--> <amOnPage url="{{StorefrontCustomerLogoutPage.url}}" stepKey="logoutCustomerOne"/> <waitForPageLoad stepKey="waitLogoutCustomerOne"/> - <actionGroup ref="AdminLogoutActionGroup" stepKey="adminLogout"/> <deleteData createDataKey="createSimpleProduct" stepKey="deleteProduct"/> <deleteData createDataKey="createSimpleUsCustomer" stepKey="deleteCustomer"/> </after> diff --git a/app/code/Magento/Checkout/Test/Mftf/Test/CheckoutDifferentDefaultCountryPerStoreTest.xml b/app/code/Magento/Checkout/Test/Mftf/Test/CheckoutDifferentDefaultCountryPerStoreTest.xml new file mode 100644 index 0000000000000..e6a5f37c764fe --- /dev/null +++ b/app/code/Magento/Checkout/Test/Mftf/Test/CheckoutDifferentDefaultCountryPerStoreTest.xml @@ -0,0 +1,75 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="CheckoutDifferentDefaultCountryPerStoreTest"> + <annotations> + <features value="One Page Checkout"/> + <stories value="Checkout via the Storefront"/> + <title value="Checkout different default country per store"/> + <description value="Checkout display default country per store view"/> + <severity value="MAJOR"/> + <testCaseId value="MC-37707"/> + <useCaseId value="MC-36884"/> + <group value="checkout"/> + </annotations> + <before> + <!-- Create simple product --> + <createData entity="SimpleProduct2" stepKey="createProduct"/> + <!-- Create store view --> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginToAdminArea"/> + <actionGroup ref="AdminCreateStoreViewActionGroup" stepKey="createStoreView"> + <argument name="customStore" value="customStore"/> + </actionGroup> + <!-- Set Germany as default country for created store view --> + <magentoCLI command="config:set --scope=stores --scope-code={{customStore.code}} general/country/default {{DE_Address_Berlin_Not_Default_Address.country_id}}" stepKey="changeDefaultCountry"/> + </before> + <after> + <!--Delete product and store view--> + <deleteData createDataKey="createProduct" stepKey="deleteProduct"/> + <actionGroup ref="AdminDeleteStoreViewActionGroup" stepKey="deleteStoreView"> + <argument name="customStore" value="customStore"/> + </actionGroup> + </after> + <!-- Open product and add product to cart--> + <actionGroup ref="OpenStoreFrontProductPageActionGroup" stepKey="openProductPage"> + <argument name="productUrlKey" value="$createProduct.custom_attributes[url_key]$"/> + </actionGroup> + <actionGroup ref="StorefrontAddProductToCartActionGroup" stepKey="addProductToCart"> + <argument name="product" value="$createProduct$"/> + <argument name="productCount" value="1"/> + </actionGroup> + <!-- Go to cart --> + <actionGroup ref="StorefrontOpenCartFromMinicartActionGroup" stepKey="openCart"/> + <!-- Switch store view --> + <actionGroup ref="StorefrontSwitchStoreViewActionGroup" stepKey="switchStoreViewActionGroup"> + <argument name="storeView" value="customStore"/> + </actionGroup> + <!-- Go to checkout page --> + <actionGroup ref="OpenStoreFrontCheckoutShippingPageActionGroup" stepKey="openCheckoutShippingPage"/> + <!-- Grab country code from checkout page and assert value with default country for created store view --> + <grabValueFrom selector="{{CheckoutShippingSection.country}}" stepKey="grabCountry"/> + <assertEquals stepKey="assertCountryValue"> + <actualResult type="const">$grabCountry</actualResult> + <expectedResult type="string">{{DE_Address_Berlin_Not_Default_Address.country_id}}</expectedResult> + </assertEquals> + <!-- Go to cart --> + <actionGroup ref="StorefrontCartPageOpenActionGroup" stepKey="returnToCartPage"/> + <!-- Switch to default store view --> + <actionGroup ref="StorefrontSwitchDefaultStoreViewActionGroup" stepKey="switchToDefaultStoreView"/> + <!-- Go to checkout page --> + <actionGroup ref="OpenStoreFrontCheckoutShippingPageActionGroup" stepKey="proceedToCheckoutWithDefaultStore"/> + <!-- Grab country code from checkout page and assert value with default country for default store view --> + <grabValueFrom selector="{{CheckoutShippingSection.country}}" stepKey="grabDefaultStoreCountry"/> + <assertEquals stepKey="assertDefaultCountryValue"> + <actualResult type="const">$grabDefaultStoreCountry</actualResult> + <expectedResult type="string">{{US_Address_TX.country_id}}</expectedResult> + </assertEquals> + </test> +</tests> diff --git a/app/code/Magento/Checkout/Test/Mftf/Test/OnePageCheckoutWithAllProductTypesTest.xml b/app/code/Magento/Checkout/Test/Mftf/Test/OnePageCheckoutWithAllProductTypesTest.xml index f9e1326e474af..a519aac72d1b5 100644 --- a/app/code/Magento/Checkout/Test/Mftf/Test/OnePageCheckoutWithAllProductTypesTest.xml +++ b/app/code/Magento/Checkout/Test/Mftf/Test/OnePageCheckoutWithAllProductTypesTest.xml @@ -214,8 +214,7 @@ </assertEquals> <!-- Assert order total --> - <amOnPage url="{{StorefrontCustomerDashboardPage.url}}" stepKey="navigateToCustomerDashboardPage"/> - <waitForPageLoad stepKey="waitForCustomerDashboardPageLoad"/> + <actionGroup ref="StorefrontOpenMyAccountPageActionGroup" stepKey="navigateToCustomerDashboardPage"/> <see selector="{{StorefrontCustomerRecentOrdersSection.orderTotal}}" userInput="$613.23" stepKey="checkOrderTotalInStorefront"/> <!-- Go to Address Book --> diff --git a/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontAddProductWithAllTypesOfCustomOptionToTheShoppingCartTest.xml b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontAddProductWithAllTypesOfCustomOptionToTheShoppingCartTest.xml index 6cf5a390a964d..3a87bc88f9377 100644 --- a/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontAddProductWithAllTypesOfCustomOptionToTheShoppingCartTest.xml +++ b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontAddProductWithAllTypesOfCustomOptionToTheShoppingCartTest.xml @@ -105,7 +105,7 @@ <!-- Assert Product Count in Mini Cart --> <actionGroup ref="StorefrontAssertMiniCartItemCountActionGroup" stepKey="assertProductCountAndTextInMiniCart"> <argument name="productCount" value="2"/> - <argument name="productCountText" value="2 Item in Cart"/> + <argument name="productCountText" value="2 Items in Cart"/> </actionGroup> <!--Assert Product Items in Mini cart--> diff --git a/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontCheckIsCartUpdatedAfterProductDeleteTest.xml b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontCheckIsCartUpdatedAfterProductDeleteTest.xml new file mode 100644 index 0000000000000..fd6a1035a326a --- /dev/null +++ b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontCheckIsCartUpdatedAfterProductDeleteTest.xml @@ -0,0 +1,65 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="StorefrontCheckIsCartUpdatedAfterProductDeleteTest"> + <annotations> + <features value="Checkout"/> + <stories value="Delete Products from Shopping Cart"/> + <title value="Remove product added to shopping cart"/> + <description value="The product has to be deleted from shopping cart if it deleted in admin panel"/> + <testCaseId value="MC-36299"/> + <useCaseId value="MAGETWO-83169"/> + <severity value="CRITICAL"/> + <group value="checkout"/> + <group value="catalog"/> + </annotations> + <before> + <createData entity="Simple_US_Customer" stepKey="createCustomer"/> + <createData entity="SimpleProduct2" stepKey="createFirstProduct"> + <field key="price">10.00</field> + </createData> + <createData entity="SimpleProduct2" stepKey="createSecondProduct"> + <field key="price">20.00</field> + </createData> + </before> + <after> + <actionGroup ref="StorefrontCustomerLogoutActionGroup" stepKey="logoutCustomer"/> + <deleteData createDataKey="createSecondProduct" stepKey="deleteSecondProduct"/> + <deleteData createDataKey="createCustomer" stepKey="deleteCustomer"/> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logoutFromAdmin"/> + </after> + <actionGroup ref="LoginToStorefrontActionGroup" stepKey="loginToStorefront"> + <argument name="Customer" value="$createCustomer$"/> + </actionGroup> + <actionGroup ref="AddSimpleProductToCartActionGroup" stepKey="addFirstProductToCart"> + <argument name="product" value="$createFirstProduct$"/> + </actionGroup> + <actionGroup ref="AddSimpleProductToCartActionGroup" stepKey="addSecondProductToCart"> + <argument name="product" value="$createSecondProduct$"/> + </actionGroup> + <actionGroup ref="ClickViewAndEditCartFromMiniCartActionGroup" stepKey="selectViewAndEditCart"/> + <actionGroup ref="AssertStorefrontShoppingCartSummaryItemsActionGroup" stepKey="assertCartTotals"> + <argument name="subtotal" value="$30.00"/> + <argument name="total" value="$40.00"/> + </actionGroup> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + <actionGroup ref="DeleteProductBySkuActionGroup" stepKey="deleteFirstProduct"> + <argument name="sku" value="$createFirstProduct.sku$"/> + </actionGroup> + <actionGroup ref="StorefrontCartPageOpenActionGroup" stepKey="goToShoppingCartPage"/> + <actionGroup ref="AssertStorefrontCheckoutCartItemsActionGroup" stepKey="assertCartAfterProductDeleted"> + <argument name="productName" value="$createSecondProduct.name$"/> + <argument name="productSku" value="$createSecondProduct.sku$"/> + <argument name="productPrice" value="$createSecondProduct.price$"/> + <argument name="subtotal" value="$createSecondProduct.price$" /> + <argument name="qty" value="1"/> + </actionGroup> + <dontSee selector="{{CheckoutCartProductSection.productName}}" userInput="$createFirstProduct.name$" stepKey="checkFirstProductIsAbsentInCart"/> + </test> +</tests> diff --git a/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontCheckoutForShowShippingMethodNoApplicableTest.xml b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontCheckoutForShowShippingMethodNoApplicableTest.xml new file mode 100644 index 0000000000000..22bc1260e5f33 --- /dev/null +++ b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontCheckoutForShowShippingMethodNoApplicableTest.xml @@ -0,0 +1,63 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="StorefrontCheckoutForShowShippingMethodNoApplicableTest"> + <annotations> + <stories value="Checkout for not applicable shipping method"/> + <title value="Storefront checkout for not applicable shipping method test"/> + <description value="Checkout flow if shipping rates are not applicable"/> + <severity value="AVERAGE"/> + <testCaseId value="MC-37420"/> + <group value="checkout"/> + </annotations> + <before> + <!-- Create simple product --> + <createData entity="SimpleProduct2" stepKey="createProduct"/> + <!-- Enable flat rate shipping to specific country - Afghanistan --> + <magentoCLI command="config:set {{EnableFlatRateConfigData.path}} {{EnableFlatRateConfigData.value}}" stepKey="enableFlatRate"/> + <magentoCLI command="config:set {{EnableFlatRateToSpecificCountriesConfigData.path}} {{EnableFlatRateToSpecificCountriesConfigData.value}}" stepKey="allowFlatRateSpecificCountries"/> + <magentoCLI command="config:set {{EnableFlatRateToAfghanistanConfigData.path}} {{EnableFlatRateToAfghanistanConfigData.value}}" stepKey="enableFlatRateToAfghanistan"/> + <!-- Enable Show Method if Not Applicable--> + <magentoCLI command="config:set {{EnableFlatRateShowMethodNoApplicableConfigData.path}} {{EnableFlatRateShowMethodNoApplicableConfigData.value}}" stepKey="enableShowMethodNoApplicable"/> + <!-- Create Customer with filled Shipping & Billing Address --> + <createData entity="CustomerEntityOne" stepKey="createCustomer"/> + </before> + <after> + <actionGroup ref="StorefrontCustomerLogoutActionGroup" stepKey="logoutFromStorefront"/> + <deleteData createDataKey="createCustomer" stepKey="deleteCustomer"/> + <magentoCLI command="config:set {{EnableFlatRateToAllAllowedCountriesConfigData.path}} {{EnableFlatRateToAllAllowedCountriesConfigData.value}}" stepKey="allowFlatRateToAllCountries"/> + <magentoCLI command="config:set {{EnableFlatRateShowMethodNoApplicableConfigData.path}} 0" stepKey="disableShowMethodNoApplicable"/> + <!-- Delete product --> + <deleteData createDataKey="createProduct" stepKey="deleteProduct"/> + </after> + <!-- Login with created Customer --> + <actionGroup ref="LoginToStorefrontActionGroup" stepKey="loginAsCustomer"> + <argument name="Customer" value="$$createCustomer$$"/> + </actionGroup> + <!-- Add product to cart --> + <actionGroup ref="OpenStoreFrontProductPageActionGroup" stepKey="openProductPage"> + <argument name="productUrlKey" value="$$createProduct.custom_attributes[url_key]$$"/> + </actionGroup> + <actionGroup ref="StorefrontAddProductToCartActionGroup" stepKey="addProductToCart"> + <argument name="product" value="$$createProduct$$"/> + <argument name="productCount" value="1"/> + </actionGroup> + <!-- Go to checkout page --> + <actionGroup ref="OpenStoreFrontCheckoutShippingPageActionGroup" stepKey="openCheckoutShippingPage"/> + <!-- Assert shipping price for US > California --> + <dontSeeElement selector="{{CheckoutShippingMethodsSection.price}}" stepKey="dontSeePrice"/> + <!-- Assert Next button is available --> + <seeElement selector="{{CheckoutShippingMethodsSection.next}}" stepKey="seeNextButton"/> + <click selector="{{CheckoutShippingMethodsSection.next}}" stepKey="clickNextButton"/> + <!-- Assert order cannot be placed and error message will shown. --> + <waitForPageLoad stepKey="waitForError"/> + <see stepKey="seeShippingMethodError" userInput="The shipping method is missing. Select the shipping method and try again."/> + </test> +</tests> diff --git a/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontCheckoutWithDifferentShippingAndBillingAddressAndCreateCustomerAfterCheckoutTest.xml b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontCheckoutWithDifferentShippingAndBillingAddressAndCreateCustomerAfterCheckoutTest.xml new file mode 100644 index 0000000000000..ef1f30e2d9c36 --- /dev/null +++ b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontCheckoutWithDifferentShippingAndBillingAddressAndCreateCustomerAfterCheckoutTest.xml @@ -0,0 +1,95 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="StorefrontCheckoutWithDifferentShippingAndBillingAddressAndCreateCustomerAfterCheckoutTest"> + <annotations> + <stories value="Checkout"/> + <title value="Verify UK customer checkout with different billing and shipping address and register customer after checkout"/> + <description value="Checkout as UK customer with different shipping/billing address and register checkout method"/> + <severity value="CRITICAL"/> + <testCaseId value="MC-28288"/> + <group value="mtf_migrated"/> + <group value="checkout"/> + </annotations> + + <before> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginToAdminPanel"/> + <createData entity="SimpleProduct2" stepKey="simpleProduct"> + <field key="price">50.00</field> + </createData> + </before> + <after> + <!-- Sign out Customer from storefront --> + <actionGroup ref="StorefrontCustomerLogoutActionGroup" stepKey="logoutCustomer"/> + <deleteData createDataKey="simpleProduct" stepKey="deleteProduct"/> + <actionGroup ref="AdminDeleteCustomerActionGroup" stepKey="deleteCustomer"> + <argument name="customerEmail" value="UKCustomer.email"/> + </actionGroup> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearCustomersGridFilter"/> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logoutFromAdmin"/> + </after> + + <!--Open Product page in StoreFront and assert product and price range --> + <actionGroup ref="AssertProductNameAndSkuInStorefrontProductPageByCustomAttributeUrlKeyActionGroup" stepKey="openProductPageAndVerifyProduct"> + <argument name="product" value="$simpleProduct$"/> + </actionGroup> + + <!--Add product to the cart --> + <actionGroup ref="AddSimpleProductToCartActionGroup" stepKey="addProductToCart"> + <argument name="product" value="$simpleProduct$"/> + </actionGroup> + + <!--Open View and edit --> + <actionGroup ref="ClickViewAndEditCartFromMiniCartActionGroup" stepKey="openCartFromMiniCart"/> + + <!-- Fill the Estimate Shipping and Tax section --> + <actionGroup ref="CheckoutFillEstimateShippingAndTaxActionGroup" stepKey="fillEstimateShippingAndTaxFields"/> + <click selector="{{CheckoutCartSummarySection.proceedToCheckout}}" stepKey="goToCheckout"/> + <waitForPageLoad stepKey="waitForPageToLoad"/> + + <!-- Fill the guest form --> + <actionGroup ref="FillGuestCheckoutShippingAddressFormActionGroup" stepKey="fillGuestShippingAddress"> + <argument name="customer" value="UKCustomer"/> + <argument name="customerAddress" value="updateCustomerUKAddress"/> + </actionGroup> + <actionGroup ref="CheckoutSelectFlatRateShippingMethodActionGroup" stepKey="selectFlatRateShipping"/> + <actionGroup ref="StorefrontCheckoutClickNextOnShippingStepActionGroup" stepKey="goToBillingStep"/> + <waitForElementVisible selector="{{CheckoutPaymentSection.billingAddressNotSameCheckbox}}" stepKey="waitForSameBillingAndShippingAddressCheckboxVisible"/> + <uncheckOption selector="{{CheckoutPaymentSection.billingAddressNotSameCheckbox}}" stepKey="uncheckSameBillingAndShippingAddress"/> + <conditionalClick selector="{{CheckoutShippingSection.editAddressButton}}" dependentSelector="{{CheckoutShippingSection.editAddressButton}}" visible="true" stepKey="clickEditBillingAddressButton"/> + + <!-- Fill Billing Address --> + <actionGroup ref="StorefrontFillBillingAddressActionGroup" stepKey="fillBillingAddressForm"/> + <click selector="{{CheckoutPaymentSection.update}}" stepKey="clickOnUpdateBillingAddressButton"/> + + <!--Place order --> + <actionGroup ref="ClickPlaceOrderActionGroup" stepKey="clickOnPlaceOrder"/> + <seeElement selector="{{StorefrontMinicartSection.emptyMiniCart}}" stepKey="assertEmptyCart" /> + <grabTextFrom selector="{{CheckoutSuccessMainSection.orderNumberWithoutLink}}" stepKey="orderId"/> + + <!-- Register customer after checkout --> + <actionGroup ref="StorefrontRegisterCustomerAfterCheckoutActionGroup" stepKey="registerCustomer"/> + + <!-- Open Order Page in admin --> + <actionGroup ref="OpenOrderByIdActionGroup" stepKey="openOrder"> + <argument name="orderId" value="{$orderId}"/> + </actionGroup> + + <!-- Assert Grand Total --> + <see selector="{{AdminOrderTotalSection.grandTotal}}" userInput="$55.00" stepKey="seeGrandTotal"/> + <see selector="{{AdminOrderDetailsInformationSection.orderStatus}}" userInput="Pending" stepKey="seeOrderStatus"/> + + <!-- Ship the order and assert the status --> + <actionGroup ref="GoToShipmentIntoOrderActionGroup" stepKey="goToShipment"/> + <actionGroup ref="SubmitShipmentIntoOrderActionGroup" stepKey="submitShipment"/> + + <!-- Assert order buttons --> + <actionGroup ref="AdminAssertOrderAvailableButtonsActionGroup" stepKey="assertOrderButtons"/> + </test> +</tests> diff --git a/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontCheckoutWithDifferentShippingAndBillingAddressAndRegisterCustomerAfterCheckoutTest.xml b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontCheckoutWithDifferentShippingAndBillingAddressAndRegisterCustomerAfterCheckoutTest.xml index cf10db2352df8..118205e912b5e 100644 --- a/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontCheckoutWithDifferentShippingAndBillingAddressAndRegisterCustomerAfterCheckoutTest.xml +++ b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontCheckoutWithDifferentShippingAndBillingAddressAndRegisterCustomerAfterCheckoutTest.xml @@ -7,14 +7,17 @@ --> <tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> - <test name="StorefrontCheckoutWithDifferentShippingAndBillingAddressAndRegisterCustomerAfterCheckoutTest"> + <test name="StorefrontCheckoutWithDifferentShippingAndBillingAddressAndRegisterCustomerAfterCheckoutTest" deprecated="Use StorefrontCheckoutWithDifferentShippingAndBillingAddressAndCreateCustomerAfterCheckoutTest instead"> <annotations> <stories value="Checkout"/> - <title value="Verify UK customer checkout with different billing and shipping address and register customer after checkout"/> + <title value="DEPRECATED. Verify UK customer checkout with different billing and shipping address and register customer after checkout"/> <description value="Checkout as UK customer with different shipping/billing address and register checkout method"/> <severity value="CRITICAL"/> <testCaseId value="MC-14712"/> <group value="mtf_migrated"/> + <skip> + <issueId value="DEPRECATED">Use StorefrontCheckoutWithDifferentShippingAndBillingAddressAndCreateCustomerAfterCheckoutTest instead</issueId> + </skip> </annotations> <before> diff --git a/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontCheckoutWithPurchaseOrderNumberPressKeyEnterTest.xml b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontCheckoutWithPurchaseOrderNumberPressKeyEnterTest.xml new file mode 100644 index 0000000000000..1055ff25edaef --- /dev/null +++ b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontCheckoutWithPurchaseOrderNumberPressKeyEnterTest.xml @@ -0,0 +1,69 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + + <test name="StorefrontCheckoutWithPurchaseOrderNumberPressKeyEnterTest"> + <annotations> + <features value="Checkout"/> + <stories value="Checkout with Purchase Order Payment. Create Order with Press Key Enter."/> + <title value="Create Checkout with purchase order payment method test. Press key Enter on field Purchase Order Number for create Order."/> + <description value="Create Checkout with purchase order payment method. Press key Enter on field Purchase Order Number for create Order."/> + <severity value="MAJOR"/> + <testCaseId value="MC-37227"/> + <group value="checkout"/> + </annotations> + + <before> + <createData entity="SimpleTwo" stepKey="createSimpleProduct"/> + + <!-- Enable payment method --> + <magentoCLI command="config:set {{PurchaseOrderEnableConfigData.path}} {{PurchaseOrderEnableConfigData.value}}" stepKey="enablePaymentMethod"/> + </before> + + <after> + <actionGroup ref="AdminLogoutActionGroup" stepKey="adminLogout"/> + <deleteData createDataKey="createSimpleProduct" stepKey="deleteProduct"/> + + <!-- Disable payment method --> + <magentoCLI command="config:set {{PurchaseOrderDisabledConfigData.path}} {{PurchaseOrderDisabledConfigData.value}}" stepKey="disablePaymentMethod"/> + </after> + + <!--Go to product page--> + <amOnPage url="$$createSimpleProduct.custom_attributes[url_key]$$.html" stepKey="navigateToSimpleProductPage"/> + <waitForPageLoad stepKey="waitForCatalogPageLoad"/> + + <!--Add Product to Shopping Cart--> + <actionGroup ref="AddToCartFromStorefrontProductPageActionGroup" stepKey="addToCartFromStorefrontProductPage"> + <argument name="productName" value="$$createSimpleProduct.name$$"/> + </actionGroup> + + <!--Go to Checkout--> + <actionGroup ref="GoToCheckoutFromMinicartActionGroup" stepKey="goToCheckoutFromMinicart"/> + + <actionGroup ref="GuestCheckoutFillingShippingSectionActionGroup" stepKey="guestCheckoutFillingShippingSection"> + <argument name="customerVar" value="CustomerEntityOne"/> + <argument name="customerAddressVar" value="CustomerAddressSimple"/> + </actionGroup> + + <!-- Checkout select Purchase Order payment --> + <actionGroup ref="CheckoutSelectPurchaseOrderPaymentActionGroup" stepKey="selectPurchaseOrderPayment"> + <argument name="purchaseOrderNumber" value="12345"/> + </actionGroup> + + <!--Press Key ENTER--> + <pressKey selector="{{StorefrontCheckoutPaymentMethodSection.purchaseOrderNumber}}" parameterArray="[\Facebook\WebDriver\WebDriverKeys::ENTER]" stepKey="pressKeyEnter"/> + <waitForPageLoad stepKey="waitForPageLoad"/> + + <!--See success messages--> + <see selector="{{CheckoutSuccessMainSection.successTitle}}" userInput="Thank you for your purchase!" stepKey="seeSuccessTitle"/> + <see selector="{{CheckoutSuccessMainSection.orderNumberText}}" userInput="Your order # is: " stepKey="seeOrderNumber"/> + + </test> + +</tests> diff --git a/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontCheckoutWithPurchaseOrderNumberTest.xml b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontCheckoutWithPurchaseOrderNumberTest.xml new file mode 100644 index 0000000000000..0b46bbdb7db65 --- /dev/null +++ b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontCheckoutWithPurchaseOrderNumberTest.xml @@ -0,0 +1,67 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="StorefrontCheckoutWithPurchaseOrderNumberTest"> + <annotations> + <features value="Checkout"/> + <stories value="Checkout with Purchase Order Payment"/> + <title value="Create Checkout with purchase order payment method test"/> + <description value="Create Checkout with purchase order payment method"/> + <severity value="MAJOR"/> + <group value="checkout"/> + </annotations> + + <before> + <createData entity="SimpleTwo" stepKey="createSimpleProduct"/> + + <!-- Enable payment method --> + <magentoCLI command="config:set {{PurchaseOrderEnableConfigData.path}} {{PurchaseOrderEnableConfigData.value}}" stepKey="enablePaymentMethod"/> + </before> + + <after> + <actionGroup ref="AdminLogoutActionGroup" stepKey="adminLogout"/> + <deleteData createDataKey="createSimpleProduct" stepKey="deleteProduct"/> + + <!-- Disable payment method --> + <magentoCLI command="config:set {{PurchaseOrderDisabledConfigData.path}} {{PurchaseOrderDisabledConfigData.value}}" stepKey="disablePaymentMethod"/> + </after> + + <!--Go to product page--> + <amOnPage url="$$createSimpleProduct.custom_attributes[url_key]$$.html" stepKey="navigateToSimpleProductPage"/> + <waitForPageLoad stepKey="waitForCatalogPageLoad"/> + + <!--Add Product to Shopping Cart--> + <actionGroup ref="AddToCartFromStorefrontProductPageActionGroup" stepKey="addToCartFromStorefrontProductPage"> + <argument name="productName" value="$$createSimpleProduct.name$$"/> + </actionGroup> + + <!--Go to Checkout--> + <actionGroup ref="GoToCheckoutFromMinicartActionGroup" stepKey="goToCheckoutFromMinicart"/> + + <actionGroup ref="GuestCheckoutFillingShippingSectionActionGroup" stepKey="guestCheckoutFillingShippingSection"> + <argument name="customerVar" value="CustomerEntityOne"/> + <argument name="customerAddressVar" value="CustomerAddressSimple"/> + </actionGroup> + + <!-- Checkout select Purchase Order payment --> + <actionGroup ref="CheckoutSelectPurchaseOrderPaymentActionGroup" stepKey="selectPurchaseOrderPayment"> + <argument name="purchaseOrderNumber" value="12345"/> + </actionGroup> + + <!--Click Place Order button--> + <click selector="{{CheckoutPaymentSection.placeOrder}}" stepKey="clickPlaceOrder"/> + <waitForPageLoad stepKey="waitForPageLoad"/> + + <!--See success messages--> + <see selector="{{CheckoutSuccessMainSection.successTitle}}" userInput="Thank you for your purchase!" stepKey="seeSuccessTitle"/> + <see selector="{{CheckoutSuccessMainSection.orderNumberText}}" userInput="Your order # is: " stepKey="seeOrderNumber"/> + + </test> + +</tests> diff --git a/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontCustomerCheckoutOnLoginWhenGuestCheckoutIsDisabledTest.xml b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontCustomerCheckoutOnLoginWhenGuestCheckoutIsDisabledTest.xml index 4ae6925cc8d55..feab271110356 100644 --- a/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontCustomerCheckoutOnLoginWhenGuestCheckoutIsDisabledTest.xml +++ b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontCustomerCheckoutOnLoginWhenGuestCheckoutIsDisabledTest.xml @@ -60,6 +60,8 @@ <argument name="customerEmail" value="$$createCustomer.email$$"/> <argument name="customerPwd" value="$$createCustomer.password$$"/> </actionGroup> + <waitForElementVisible selector="{{CheckoutCartSummarySection.proceedToCheckout}}" stepKey="waitProceedToCheckout"/> + <scrollTo selector="{{CheckoutCartSummarySection.proceedToCheckout}}" stepKey="scrollToGoToCheckout"/> <click selector="{{CheckoutCartSummarySection.proceedToCheckout}}" stepKey="goToCheckout1"/> <waitForPageLoad stepKey="waitForShippingMethodSectionToLoad"/> <actionGroup ref="StorefrontCheckoutClickNextButtonActionGroup" stepKey="clickOnNextButton"/> diff --git a/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontGuestCheckoutTest/StorefrontCheckoutSummaryItemsInCartLabelPluralizedTest.xml b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontGuestCheckoutTest/StorefrontCheckoutSummaryItemsInCartLabelPluralizedTest.xml new file mode 100644 index 0000000000000..e61ad766565d7 --- /dev/null +++ b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontGuestCheckoutTest/StorefrontCheckoutSummaryItemsInCartLabelPluralizedTest.xml @@ -0,0 +1,52 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="StorefrontCheckoutSummaryItemsInCartLabelPluralizedTest"> + <annotations> + <features value="Checkout"/> + <stories value="Checkout page order summary section 'Item in Cart' is pluralized correctly or not"/> + <title value="'Item in Cart' is pluralized correctly"/> + <description value="When adding more then 1 item and check checkout page order summary section text 'Items in Cart' is pluralized correctly or not"/> + <severity value="AVERAGE"/> + <group value="Checkout"/> + </annotations> + <before> + <createData entity="_defaultCategory" stepKey="createCategory"/> + <createData entity="ApiSimpleProduct" stepKey="createProduct"> + <requiredEntity createDataKey="createCategory"/> + <field key="price">100.00</field> + </createData> + </before> + <after> + <deleteData createDataKey="createProduct" stepKey="deleteSimpleProduct"/> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + </after> + <actionGroup ref="StorefrontOpenProductPageActionGroup" stepKey="openProductPage"> + <argument name="productUrl" value="$createProduct.custom_attributes[url_key]$"/> + </actionGroup> + <actionGroup ref="StorefrontEnterProductQuantityAndAddToTheCartActionGroup" stepKey="enterProductQuantityAndAddToTheCart"> + <argument name="quantity" value="1"/> + </actionGroup> + <actionGroup ref="StorefrontClickOnMiniCartActionGroup" stepKey="clickOnMiniCart"/> + <see selector="{{StorefrontMinicartSection.productCountLabel}}" userInput="Item in Cart" stepKey="seeProductCountLabel"/> + <actionGroup ref="StorefrontOpenCheckoutPageActionGroup" stepKey="openCheckoutPage"/> + <waitForPageLoad stepKey="waitForBundleProductCreatePageToLoad"/> + <see selector="{{CheckoutCartSummarySection.itemsInCartLabel}}" userInput="Item in Cart" stepKey="seeFirstProductInList"/> + <actionGroup ref="StorefrontOpenProductPageActionGroup" stepKey="openProductPageAgain"> + <argument name="productUrl" value="$createProduct.custom_attributes[url_key]$"/> + </actionGroup> + <actionGroup ref="StorefrontEnterProductQuantityAndAddToTheCartActionGroup" stepKey="enterProductQuantityAndAddToTheCartAgain"> + <argument name="quantity" value="4"/> + </actionGroup> + <actionGroup ref="StorefrontClickOnMiniCartActionGroup" stepKey="clickOnMiniCartAgain"/> + <see selector="{{StorefrontMinicartSection.productCountLabel}}" userInput="Items in Cart" stepKey="seeProductCountLabelAgain"/> + <actionGroup ref="StorefrontOpenCheckoutPageActionGroup" stepKey="openCheckoutPageAgain"/> + <waitForPageLoad stepKey="waitForBundleProductCreatePageToLoadAgain"/> + <see selector="{{CheckoutCartSummarySection.itemsInCartLabel}}" userInput="Items in Cart" stepKey="seeFirstProductInListAgain"/> + </test> +</tests> diff --git a/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontGuestCheckoutUsingFreeShippingAndTaxesTest.xml b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontGuestCheckoutUsingFreeShippingAndTaxesTest.xml index 5a0610f5c5b0a..033898bb90557 100644 --- a/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontGuestCheckoutUsingFreeShippingAndTaxesTest.xml +++ b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontGuestCheckoutUsingFreeShippingAndTaxesTest.xml @@ -7,16 +7,16 @@ --> <tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> - <test name="StorefrontGuestCheckoutUsingFreeShippingAndTaxesTest"> + <test name="StorefrontGuestCheckoutUsingFreeShippingAndTaxesTest" deprecated="Use StorefrontVerifyGuestCheckoutUsingFreeShippingAndTaxesTest"> <annotations> <stories value="Checkout"/> - <title value="Verify guest checkout using free shipping and tax variations"/> + <title value="DEPRECATED. Verify guest checkout using free shipping and tax variations"/> <description value="Verify guest checkout using free shipping and tax variations"/> <severity value="CRITICAL"/> <testCaseId value="MC-14709"/> <group value="mtf_migrated"/> <skip> - <issueId value="MC-18802"/> + <issueId value="DEPRECATED">Use StorefrontVerifyGuestCheckoutUsingFreeShippingAndTaxesTest</issueId> </skip> </annotations> @@ -172,8 +172,9 @@ <actionGroup ref="CheckoutFillEstimateShippingAndTaxActionGroup" stepKey="fillEstimateShippingAndTaxFields"> <argument name="address" value="US_Address_NY_Default_Shipping"/> </actionGroup> - <reloadPage stepKey="reloadThePage"/> - <waitForPageLoad stepKey="waitForPageToReload"/> + <actionGroup ref="ReloadPageActionGroup" stepKey="reloadThePage"/> + <comment userInput="Replacing reload action and preserve Backward Compatibility" stepKey="waitForPageToReload"/> + <waitForText selector="{{CheckoutCartSummarySection.taxAmount}}" userInput="$9.60" time="90" stepKey="waitForTaxAmount"/> <!--Select Free Shipping and proceed to checkout --> diff --git a/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontPersistentDataForGuestCustomerWithPhysicalQuoteTest.xml b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontPersistentDataForGuestCustomerWithPhysicalQuoteTest.xml index e42d5e1bae956..a3c093d005371 100644 --- a/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontPersistentDataForGuestCustomerWithPhysicalQuoteTest.xml +++ b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontPersistentDataForGuestCustomerWithPhysicalQuoteTest.xml @@ -49,8 +49,8 @@ <waitForLoadingMaskToDisappear stepKey="waitForLoadingMaskToDisappearAfterFlatRateSelection"/> <see selector="{{CheckoutCartSummarySection.total}}" userInput="15" stepKey="assertOrderTotalField"/> <!-- 5. Refresh browser page (F5) --> - <reloadPage stepKey="reloadPage"/> - <waitForPageLoad stepKey="waitForPageLoad"/> + <actionGroup ref="ReloadPageActionGroup" stepKey="reloadPage"/> + <comment userInput="Replacing reload action and preserve Backward Compatibility" stepKey="waitForPageLoad"/> <actionGroup ref="StorefrontAssertCartEstimateShippingAndTaxActionGroup" stepKey="assertCartEstimateShippingAndTaxAfterPageReload"/> <actionGroup ref="StorefrontAssertCartShippingMethodSelectedActionGroup" stepKey="assertFlatRateShippingMethodIsChecked"> <argument name="carrierCode" value="flatrate"/> @@ -71,8 +71,9 @@ <!-- 9. Fill other fields --> <actionGroup ref="StorefrontFillGuestShippingInfoActionGroup" stepKey="fillOtherFieldsInCheckoutShippingSection"/> <!-- 10. Refresh browser page(F5) --> - <reloadPage stepKey="reloadCheckoutPage"/> - <waitForPageLoad stepKey="waitForCheckoutPageLoad"/> + <actionGroup ref="ReloadPageActionGroup" stepKey="reloadCheckoutPage"/> + <comment userInput="Replacing reload action and preserve Backward Compatibility" stepKey="waitForCheckoutPageLoad"/> + <actionGroup ref="StorefrontAssertGuestShippingInfoActionGroup" stepKey="assertGuestShippingPersistedInfoAfterReloadingCheckoutShippingPage"/> <actionGroup ref="StorefrontAssertCheckoutShippingMethodSelectedActionGroup" stepKey="assertFreeShippingShippingMethodIsChecked"> <argument name="shippingMethod" value="Free Shipping"/> diff --git a/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontUpdatePriceInShoppingCartAfterProductSaveTest.xml b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontUpdatePriceInShoppingCartAfterProductSaveTest.xml index a7a0917532dcb..f014a7a5bd1ee 100644 --- a/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontUpdatePriceInShoppingCartAfterProductSaveTest.xml +++ b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontUpdatePriceInShoppingCartAfterProductSaveTest.xml @@ -62,8 +62,8 @@ <closeTab stepKey="closeTab"/> <!--Check price--> - <reloadPage stepKey="reloadPage"/> - <waitForPageLoad stepKey="waitForCheckoutPageReload"/> + <actionGroup ref="ReloadPageActionGroup" stepKey="reloadPage"/> + <comment userInput="Replacing reload action and preserve Backward Compatibility" stepKey="waitForCheckoutPageReload"/> <conditionalClick selector="{{CheckoutPaymentSection.cartItemsArea}}" dependentSelector="{{CheckoutPaymentSection.cartItemsAreaActive}}" visible="false" stepKey="openItemProductBlock1"/> <see userInput="$120.00" selector="{{CheckoutPaymentSection.orderSummarySubtotal}}" stepKey="checkSummarySubtotal1"/> <see userInput="$120.00" selector="{{CheckoutPaymentSection.productItemPriceByName($$createSimpleProduct.name$$)}}" stepKey="checkItemPrice1"/> diff --git a/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontVerifyGuestCheckoutUsingFreeShippingAndTaxesTest.xml b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontVerifyGuestCheckoutUsingFreeShippingAndTaxesTest.xml new file mode 100644 index 0000000000000..49af0a285b5f4 --- /dev/null +++ b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontVerifyGuestCheckoutUsingFreeShippingAndTaxesTest.xml @@ -0,0 +1,163 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="StorefrontVerifyGuestCheckoutUsingFreeShippingAndTaxesTest"> + <annotations> + <features value="Checkout"/> + <stories value="Checkout via Guest Checkout"/> + <title value="Verify guest checkout using free shipping and tax variations"/> + <description value="Verify guest checkout using free shipping and tax variations"/> + <severity value="CRITICAL"/> + <testCaseId value="MC-28285"/> + <group value="mtf_migrated"/> + <group value="checkout"/> + <group value="tax"/> + </annotations> + <before> + <createData entity="FlatRateShippingMethodConfig" stepKey="enableFlatRate"/> + <createData entity="FreeShippingMethodsSettingConfig" stepKey="freeShippingMethodsSettingConfig"/> + <createData entity="MinimumOrderAmount100" stepKey="minimumOrderAmount"/> + <createData entity="taxRate_US_NY_8_1" stepKey="createTaxRateUSNY"/> + <createData entity="DefaultTaxRuleWithCustomTaxRate" stepKey="createTaxRuleUSNY"> + <requiredEntity createDataKey="createTaxRateUSNY" /> + </createData> + <createData entity="defaultSimpleProduct" stepKey="simpleProduct"> + <field key="price">10.00</field> + </createData> + <createData entity="ApiCategory" stepKey="createCategory"/> + <createData entity="ApiConfigurableProduct" stepKey="configurableProduct"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="productAttributeWithTwoOptions" stepKey="createProductAttribute"/> + <createData entity="productAttributeOption1" stepKey="createProductAttributeOption"> + <requiredEntity createDataKey="createProductAttribute"/> + </createData> + <createData entity="AddToDefaultSet" stepKey="addToDefaultSet"> + <requiredEntity createDataKey="createProductAttribute"/> + </createData> + <getData entity="ProductAttributeOptionGetter" index="1" stepKey="getProductAttributeOption"> + <requiredEntity createDataKey="createProductAttribute"/> + </getData> + <createData entity="ApiSimpleOne" stepKey="configurableChildProduct"> + <requiredEntity createDataKey="createProductAttribute"/> + <requiredEntity createDataKey="getProductAttributeOption"/> + <field key="price">10.00</field> + </createData> + <createData entity="ConfigurableProductTwoOptions" stepKey="createConfigProductOption"> + <requiredEntity createDataKey="configurableProduct"/> + <requiredEntity createDataKey="createProductAttribute"/> + <requiredEntity createDataKey="getProductAttributeOption"/> + </createData> + <createData entity="ConfigurableProductAddChild" stepKey="configurableProductAddChild"> + <requiredEntity createDataKey="configurableProduct"/> + <requiredEntity createDataKey="configurableChildProduct"/> + </createData> + <createData entity="SimpleProduct2" stepKey="firstBundleChildProduct"> + <field key="price">100.00</field> + </createData> + <createData entity="SimpleProduct2" stepKey="secondBundleChildProduct"> + <field key="price">200.00</field> + </createData> + <createData entity="BundleProductPriceViewRange" stepKey="bundleProduct"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="MultipleSelectOption" stepKey="bundleOption"> + <requiredEntity createDataKey="bundleProduct"/> + <field key="required">True</field> + </createData> + <createData entity="ApiBundleLink" stepKey="firstLinkOptionToProduct"> + <requiredEntity createDataKey="bundleProduct"/> + <requiredEntity createDataKey="bundleOption"/> + <requiredEntity createDataKey="firstBundleChildProduct"/> + </createData> + <createData entity="ApiBundleLink" stepKey="secondLinkOptionToProduct"> + <requiredEntity createDataKey="bundleProduct"/> + <requiredEntity createDataKey="bundleOption"/> + <requiredEntity createDataKey="secondBundleChildProduct"/> + </createData> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginToAdminPanel"/> + <actionGroup ref="CliIndexerReindexActionGroup" stepKey="reindex"> + <argument name="indices" value=""/> + </actionGroup> + <actionGroup ref="CliCacheFlushActionGroup" stepKey="flushCache"> + <argument name="tags" value=""/> + </actionGroup> + </before> + <after> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + <deleteData createDataKey="simpleProduct" stepKey="deleteProduct"/> + <deleteData createDataKey="configurableChildProduct" stepKey="deleteConfigurableChildProduct"/> + <deleteData createDataKey="configurableProduct" stepKey="deleteConfigurableProduct"/> + <deleteData createDataKey="createProductAttribute" stepKey="deleteProductAttribute"/> + <deleteData createDataKey="firstBundleChildProduct" stepKey="deleteFirstBundleChild"/> + <deleteData createDataKey="secondBundleChildProduct" stepKey="deleteSecondBundleChild"/> + <deleteData createDataKey="bundleProduct" stepKey="deleteBundleProduct"/> + <deleteData createDataKey="createTaxRuleUSNY" stepKey="deleteTaxRuleUSNY"/> + <deleteData createDataKey="createTaxRateUSNY" stepKey="deleteTaxRateUSNY"/> + <createData entity="DefaultShippingMethodsConfig" stepKey="defaultShippingMethodsConfig"/> + <createData entity="DefaultMinimumOrderAmount" stepKey="defaultMinimumOrderAmount"/> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logoutFromAdminPanel"/> + <magentoCron groups="index" stepKey="reindexInvalidatedIndices"/> + </after> + <actionGroup ref="AssertProductNameAndSkuInStorefrontProductPageByCustomAttributeUrlKeyActionGroup" stepKey="openProductPageAndVerifyProduct"> + <argument name="product" value="$simpleProduct$"/> + </actionGroup> + <actionGroup ref="StorefrontAddProductToCartWithQtyActionGroup" stepKey="addSimpleProductToTheCart"> + <argument name="productQty" value="1"/> + </actionGroup> + <actionGroup ref="StorefrontAddConfigurableProductToTheCartActionGroup" stepKey="addConfigurableProductToCart"> + <argument name="urlKey" value="$configurableProduct.custom_attributes[url_key]$" /> + <argument name="productAttribute" value="$createProductAttribute.default_value$"/> + <argument name="productOption" value="$getProductAttributeOption.label$"/> + <argument name="qty" value="1"/> + </actionGroup> + <actionGroup ref="AssertProductNameAndSkuInStorefrontProductPageByCustomAttributeUrlKeyActionGroup" stepKey="openProductPageAndVerifyBundleProduct"> + <argument name="product" value="$bundleProduct$"/> + </actionGroup> + <actionGroup ref="StorefrontAddBundleProductFromProductToCartWithMultiOptionActionGroup" stepKey="addBundleProductToCart"> + <argument name="productName" value="$bundleProduct.name$"/> + <argument name="optionName" value="$bundleOption.name$"/> + <argument name="value" value="$firstBundleChildProduct.name$ +$100.00"/> + </actionGroup> + <actionGroup ref="ClickViewAndEditCartFromMiniCartActionGroup" stepKey="clickMiniCart"/> + <actionGroup ref="CheckoutFillEstimateShippingAndTaxActionGroup" stepKey="fillEstimateShippingAndTaxFields"> + <argument name="address" value="US_Address_NY_Default_Shipping"/> + </actionGroup> + <click selector="{{CheckoutCartSummarySection.shippingMethodElementId('freeshipping', 'freeshipping')}}" stepKey="selectShippingMethod"/> + <see selector="{{CheckoutCartSummarySection.taxAmount}}" userInput="$9.72" stepKey="seeTaxAmount"/> + <reloadPage stepKey="reloadThePage"/> + <waitForPageLoad stepKey="waitForPageToReload"/> + <see selector="{{CheckoutCartSummarySection.taxAmount}}" userInput="$9.72" stepKey="seeTaxAmountAfterLoadPage"/> + <scrollTo selector="{{CheckoutCartSummarySection.proceedToCheckout}}" stepKey="scrollToProceedToCheckout" /> + <click selector="{{CheckoutCartSummarySection.proceedToCheckout}}" stepKey="goToCheckout"/> + <waitForPageLoad stepKey="waitForPageToLoad"/> + <actionGroup ref="FillGuestCheckoutShippingAddressFormActionGroup" stepKey="fillTheSignInForm"> + <argument name="customer" value="Simple_US_Customer"/> + <argument name="customerAddress" value="US_Address_NY_Default_Shipping"/> + </actionGroup> + <actionGroup ref="StorefrontCheckoutClickNextButtonActionGroup" stepKey="clickOnNextButton"/> + <actionGroup ref="ClickPlaceOrderActionGroup" stepKey="clickOnPlaceOrder"/> + <seeElement selector="{{StorefrontMinicartSection.emptyMiniCart}}" stepKey="assertEmptyCart" /> + <grabTextFrom selector="{{CheckoutSuccessMainSection.orderNumberWithoutLink}}" stepKey="orderId"/> + <actionGroup ref="AdminOrdersPageOpenActionGroup" stepKey="goToOrders"/> + <actionGroup ref="OpenOrderByIdActionGroup" stepKey="openOrderById"> + <argument name="orderId" value="$orderId"/> + </actionGroup> + <actionGroup ref="AdminAssertOrderAvailableButtonsActionGroup" stepKey="assertOrderButtons"/> + <see selector="{{AdminOrderTotalSection.grandTotal}}" userInput="$129.72" stepKey="seeGrandTotal"/> + <actionGroup ref="AdminOrderViewCheckStatusActionGroup" stepKey="seeOrderPendingStatus"/> + <actionGroup ref="AdminShipThePendingOrderActionGroup" stepKey="shipTheOrder"/> + <actionGroup ref="AssertOrderAddressInformationActionGroup" stepKey="assertCustomerInformation"> + <argument name="customer" value=""/> + <argument name="shippingAddress" value="US_Address_NY_Default_Shipping"/> + <argument name="billingAddress" value="US_Address_NY_Default_Shipping"/> + <argument name="customerGroup" value=""/> + </actionGroup> + </test> +</tests> diff --git a/app/code/Magento/Checkout/Test/Mftf/Test/ZeroSubtotalOrdersWithProcessingStatusTest.xml b/app/code/Magento/Checkout/Test/Mftf/Test/ZeroSubtotalOrdersWithProcessingStatusTest.xml index 1828251e68635..f38061dbf6a6c 100644 --- a/app/code/Magento/Checkout/Test/Mftf/Test/ZeroSubtotalOrdersWithProcessingStatusTest.xml +++ b/app/code/Magento/Checkout/Test/Mftf/Test/ZeroSubtotalOrdersWithProcessingStatusTest.xml @@ -41,9 +41,7 @@ <deleteData createDataKey="simplecategory" stepKey="deleteCategory"/> </after> - <!--Open MARKETING > Cart Price Rules--> - <amOnPage url="{{AdminCartPriceRulesPage.url}}" stepKey="amOnCartPriceList"/> - <waitForPageLoad stepKey="waitForRulesPage"/> + <actionGroup ref="AdminOpenCartPriceRulesPageActionGroup" stepKey="amOnCartPriceList"/> <!--Add New Rule--> <click selector="{{AdminCartPriceRulesSection.addNewRuleButton}}" stepKey="clickAddNewRule"/> diff --git a/app/code/Magento/Checkout/Test/Unit/Model/GuestPaymentInformationManagementTest.php b/app/code/Magento/Checkout/Test/Unit/Model/GuestPaymentInformationManagementTest.php index f3edcfe8986f0..4a89443f02f6d 100644 --- a/app/code/Magento/Checkout/Test/Unit/Model/GuestPaymentInformationManagementTest.php +++ b/app/code/Magento/Checkout/Test/Unit/Model/GuestPaymentInformationManagementTest.php @@ -7,6 +7,8 @@ namespace Magento\Checkout\Test\Unit\Model; +use Magento\Checkout\Api\Exception\PaymentProcessingRateLimitExceededException; +use Magento\Checkout\Api\PaymentProcessingRateLimiterInterface; use Magento\Checkout\Model\GuestPaymentInformationManagement; use Magento\Framework\Exception\CouldNotSaveException; use Magento\Framework\Exception\LocalizedException; @@ -67,6 +69,11 @@ class GuestPaymentInformationManagementTest extends TestCase */ private $loggerMock; + /** + * @var PaymentProcessingRateLimiterInterface|MockObject + */ + private $limiterMock; + protected function setUp(): void { $objectManager = new ObjectManager($this); @@ -84,6 +91,7 @@ protected function setUp(): void ['create'] ); $this->loggerMock = $this->getMockForAbstractClass(LoggerInterface::class); + $this->limiterMock = $this->getMockForAbstractClass(PaymentProcessingRateLimiterInterface::class); $this->model = $objectManager->getObject( GuestPaymentInformationManagement::class, [ @@ -91,7 +99,8 @@ protected function setUp(): void 'paymentMethodManagement' => $this->paymentMethodManagementMock, 'cartManagement' => $this->cartManagementMock, 'cartRepository' => $this->cartRepositoryMock, - 'quoteIdMaskFactory' => $this->quoteIdMaskFactoryMock + 'quoteIdMaskFactory' => $this->quoteIdMaskFactoryMock, + 'paymentsRateLimiter' => $this->limiterMock ] ); $objectManager->setBackwardCompatibleProperty($this->model, 'logger', $this->loggerMock); @@ -99,22 +108,21 @@ protected function setUp(): void public function testSavePaymentInformationAndPlaceOrder() { - $cartId = 100; $orderId = 200; - $email = 'email@magento.com'; - $paymentMock = $this->getMockForAbstractClass(PaymentInterface::class); - $billingAddressMock = $this->getMockForAbstractClass(AddressInterface::class); - $this->getMockForAssignBillingAddress($cartId, $billingAddressMock); - - $billingAddressMock->expects($this->once())->method('setEmail')->with($email)->willReturnSelf(); - - $this->paymentMethodManagementMock->expects($this->once())->method('set')->with($cartId, $paymentMock); - $this->cartManagementMock->expects($this->once())->method('placeOrder')->with($cartId)->willReturn($orderId); + $this->assertEquals($orderId, $this->placeOrder($orderId)); + } - $this->assertEquals( - $orderId, - $this->model->savePaymentInformationAndPlaceOrder($cartId, $email, $paymentMock, $billingAddressMock) - ); + /** + * Validate that "testSavePaymentInformationAndPlaceOrderLimited" calls are limited. + * + * @return void + */ + public function testSavePaymentInformationAndPlaceOrderLimited(): void + { + $this->expectException(PaymentProcessingRateLimitExceededException::class); + $this->limiterMock->method('limit') + ->willThrowException(new PaymentProcessingRateLimitExceededException(__('Error'))); + $this->placeOrder(); } public function testSavePaymentInformationAndPlaceOrderException() @@ -141,16 +149,21 @@ public function testSavePaymentInformationAndPlaceOrderException() public function testSavePaymentInformation() { - $cartId = 100; - $email = 'email@magento.com'; - $paymentMock = $this->getMockForAbstractClass(PaymentInterface::class); - $billingAddressMock = $this->getMockForAbstractClass(AddressInterface::class); - $this->getMockForAssignBillingAddress($cartId, $billingAddressMock); - $billingAddressMock->expects($this->once())->method('setEmail')->with($email)->willReturnSelf(); + $this->assertTrue($this->savePayment()); + } - $this->paymentMethodManagementMock->expects($this->once())->method('set')->with($cartId, $paymentMock); + /** + * Validate that this method is rate-limited. + * + * @return void + */ + public function testSavePaymentInformationLimited(): void + { + $this->expectException(PaymentProcessingRateLimitExceededException::class); + $this->limiterMock->method('limit') + ->willThrowException(new PaymentProcessingRateLimitExceededException(__('Error'))); - $this->assertTrue($this->model->savePaymentInformation($cartId, $email, $paymentMock, $billingAddressMock)); + $this->savePayment(); } public function testSavePaymentInformationWithoutBillingAddress() @@ -246,31 +259,75 @@ private function getMockForAssignBillingAddress( $this->cartRepositoryMock->method('getActive') ->with($cartId) ->willReturn($quote); - $quote->expects($this->once()) + $quote->expects($this->any()) ->method('getBillingAddress') ->willReturn($quoteBillingAddress); - $quote->expects($this->once()) + $quote->expects($this->any()) ->method('getShippingAddress') ->willReturn($quoteShippingAddress); - $quoteBillingAddress->expects($this->once()) + $quoteBillingAddress->expects($this->any()) ->method('getId') ->willReturn($billingAddressId); - $quote->expects($this->once()) + $quote->expects($this->any()) ->method('removeAddress') ->with($billingAddressId); - $quote->expects($this->once()) + $quote->expects($this->any()) ->method('setBillingAddress') ->with($billingAddressMock); $quoteShippingAddress->expects($this->any()) ->method('getShippingRateByCode') ->willReturn($shippingRate); - $quote->expects($this->once()) + $quote->expects($this->any()) ->method('setDataChanges') ->willReturnSelf(); $quoteShippingAddress->method('getShippingMethod') ->willReturn('flatrate_flatrate'); - $quoteShippingAddress->expects($this->once()) + $quoteShippingAddress->expects($this->any()) ->method('setLimitCarrier') ->with('flatrate'); } + + /** + * Place order. + * + * @param int $orderId + * @return mixed Method call result. + */ + private function placeOrder(?int $orderId = 200) + { + $cartId = 100; + $email = 'email@magento.com'; + $paymentMock = $this->getMockForAbstractClass(PaymentInterface::class); + $billingAddressMock = $this->getMockForAbstractClass(AddressInterface::class); + $this->getMockForAssignBillingAddress($cartId, $billingAddressMock); + + $billingAddressMock->expects($this->any())->method('setEmail')->with($email)->willReturnSelf(); + + $this->paymentMethodManagementMock->expects($this->any())->method('set')->with($cartId, $paymentMock); + $this->cartManagementMock->expects($this->any()) + ->method('placeOrder') + ->with($cartId) + ->willReturn($orderId); + + return $this->model->savePaymentInformationAndPlaceOrder($cartId, $email, $paymentMock, $billingAddressMock); + } + + /** + * Save payment information. + * + * @return mixed Call result. + */ + private function savePayment() + { + $cartId = 100; + $email = 'email@magento.com'; + $paymentMock = $this->getMockForAbstractClass(PaymentInterface::class); + $billingAddressMock = $this->getMockForAbstractClass(AddressInterface::class); + $this->getMockForAssignBillingAddress($cartId, $billingAddressMock); + $billingAddressMock->expects($this->any())->method('setEmail')->with($email)->willReturnSelf(); + + $this->paymentMethodManagementMock->expects($this->any())->method('set')->with($cartId, $paymentMock); + + return $this->model->savePaymentInformation($cartId, $email, $paymentMock, $billingAddressMock); + } } diff --git a/app/code/Magento/Checkout/Test/Unit/Model/PaymentInformationManagementTest.php b/app/code/Magento/Checkout/Test/Unit/Model/PaymentInformationManagementTest.php index 75445f23aa887..294857765007e 100644 --- a/app/code/Magento/Checkout/Test/Unit/Model/PaymentInformationManagementTest.php +++ b/app/code/Magento/Checkout/Test/Unit/Model/PaymentInformationManagementTest.php @@ -7,6 +7,8 @@ namespace Magento\Checkout\Test\Unit\Model; +use Magento\Checkout\Api\Exception\PaymentProcessingRateLimitExceededException; +use Magento\Checkout\Api\PaymentProcessingRateLimiterInterface; use Magento\Checkout\Model\PaymentInformationManagement; use Magento\Framework\Exception\LocalizedException; use Magento\Framework\Phrase; @@ -59,6 +61,11 @@ class PaymentInformationManagementTest extends TestCase */ private $cartRepositoryMock; + /** + * @var PaymentProcessingRateLimiterInterface|MockObject + */ + private $rateLimiterMock; + protected function setUp(): void { $objectManager = new ObjectManager($this); @@ -73,12 +80,14 @@ protected function setUp(): void $this->loggerMock = $this->getMockForAbstractClass(LoggerInterface::class); $this->cartRepositoryMock = $this->getMockBuilder(CartRepositoryInterface::class) ->getMock(); + $this->rateLimiterMock = $this->getMockForAbstractClass(PaymentProcessingRateLimiterInterface::class); $this->model = $objectManager->getObject( PaymentInformationManagement::class, [ 'billingAddressManagement' => $this->billingAddressManagementMock, 'paymentMethodManagement' => $this->paymentMethodManagementMock, - 'cartManagement' => $this->cartManagementMock + 'cartManagement' => $this->cartManagementMock, + 'paymentRateLimiter' => $this->rateLimiterMock ] ); $objectManager->setBackwardCompatibleProperty($this->model, 'logger', $this->loggerMock); @@ -87,21 +96,27 @@ protected function setUp(): void public function testSavePaymentInformationAndPlaceOrder() { - $cartId = 100; $orderId = 200; - $paymentMock = $this->getMockForAbstractClass(PaymentInterface::class); - $billingAddressMock = $this->getMockForAbstractClass(AddressInterface::class); - - $this->getMockForAssignBillingAddress($cartId, $billingAddressMock); - $this->paymentMethodManagementMock->expects($this->once())->method('set')->with($cartId, $paymentMock); - $this->cartManagementMock->expects($this->once())->method('placeOrder')->with($cartId)->willReturn($orderId); - $this->assertEquals( $orderId, - $this->model->savePaymentInformationAndPlaceOrder($cartId, $paymentMock, $billingAddressMock) + $this->placeOrder($orderId) ); } + /** + * Valdiate that the method is rate-limited. + * + * @return void + */ + public function testSavePaymentInformationAndPlaceOrderLimited(): void + { + $this->rateLimiterMock->method('limit') + ->willThrowException(new PaymentProcessingRateLimitExceededException(__('Error'))); + $this->expectException(PaymentProcessingRateLimitExceededException::class); + + $this->placeOrder(); + } + public function testSavePaymentInformationAndPlaceOrderException() { $this->expectException('Magento\Framework\Exception\CouldNotSaveException'); @@ -110,10 +125,10 @@ public function testSavePaymentInformationAndPlaceOrderException() $billingAddressMock = $this->getMockForAbstractClass(AddressInterface::class); $this->getMockForAssignBillingAddress($cartId, $billingAddressMock); - $this->paymentMethodManagementMock->expects($this->once())->method('set')->with($cartId, $paymentMock); + $this->paymentMethodManagementMock->expects($this->any())->method('set')->with($cartId, $paymentMock); $exception = new \Exception('DB exception'); - $this->loggerMock->expects($this->once())->method('critical'); - $this->cartManagementMock->expects($this->once())->method('placeOrder')->willThrowException($exception); + $this->loggerMock->expects($this->any())->method('critical'); + $this->cartManagementMock->expects($this->any())->method('placeOrder')->willThrowException($exception); $this->model->savePaymentInformationAndPlaceOrder($cartId, $paymentMock, $billingAddressMock); @@ -128,8 +143,8 @@ public function testSavePaymentInformationAndPlaceOrderIfBillingAddressNotExist( $orderId = 200; $paymentMock = $this->getMockForAbstractClass(PaymentInterface::class); - $this->paymentMethodManagementMock->expects($this->once())->method('set')->with($cartId, $paymentMock); - $this->cartManagementMock->expects($this->once())->method('placeOrder')->with($cartId)->willReturn($orderId); + $this->paymentMethodManagementMock->expects($this->any())->method('set')->with($cartId, $paymentMock); + $this->cartManagementMock->expects($this->any())->method('placeOrder')->with($cartId)->willReturn($orderId); $this->assertEquals( $orderId, @@ -139,14 +154,21 @@ public function testSavePaymentInformationAndPlaceOrderIfBillingAddressNotExist( public function testSavePaymentInformation() { - $cartId = 100; - $paymentMock = $this->getMockForAbstractClass(PaymentInterface::class); - $billingAddressMock = $this->getMockForAbstractClass(AddressInterface::class); + $this->assertTrue($this->savePayment()); + } - $this->getMockForAssignBillingAddress($cartId, $billingAddressMock); - $this->paymentMethodManagementMock->expects($this->once())->method('set')->with($cartId, $paymentMock); + /** + * Validate that the method is rate-limited. + * + * @return void + */ + public function testSavePaymentInformationLimited(): void + { + $this->rateLimiterMock->method('limit') + ->willThrowException(new PaymentProcessingRateLimitExceededException(__('Error'))); + $this->expectException(PaymentProcessingRateLimitExceededException::class); - $this->assertTrue($this->model->savePaymentInformation($cartId, $paymentMock, $billingAddressMock)); + $this->savePayment(); } public function testSavePaymentInformationWithoutBillingAddress() @@ -154,7 +176,7 @@ public function testSavePaymentInformationWithoutBillingAddress() $cartId = 100; $paymentMock = $this->getMockForAbstractClass(PaymentInterface::class); - $this->paymentMethodManagementMock->expects($this->once())->method('set')->with($cartId, $paymentMock); + $this->paymentMethodManagementMock->expects($this->any())->method('set')->with($cartId, $paymentMock); $this->assertTrue($this->model->savePaymentInformation($cartId, $paymentMock)); } @@ -169,10 +191,10 @@ public function testSavePaymentInformationAndPlaceOrderWithLocolizedException() $this->getMockForAssignBillingAddress($cartId, $billingAddressMock); - $this->paymentMethodManagementMock->expects($this->once())->method('set')->with($cartId, $paymentMock); + $this->paymentMethodManagementMock->expects($this->any())->method('set')->with($cartId, $paymentMock); $phrase = new Phrase(__('DB exception')); $exception = new LocalizedException($phrase); - $this->cartManagementMock->expects($this->once())->method('placeOrder')->willThrowException($exception); + $this->cartManagementMock->expects($this->any())->method('placeOrder')->willThrowException($exception); $this->model->savePaymentInformationAndPlaceOrder($cartId, $paymentMock, $billingAddressMock); } @@ -197,8 +219,8 @@ public function testSavePaymentInformationAndPlaceOrderWithNewBillingAddress(): $quoteBillingAddress->method('getId')->willReturn($quoteBillingAddressId); $this->cartRepositoryMock->method('getActive')->with($cartId)->willReturn($quoteMock); - $this->paymentMethodManagementMock->expects($this->once())->method('set')->with($cartId, $paymentMock); - $billingAddressMock->expects($this->once())->method('setCustomerId')->with($customerId); + $this->paymentMethodManagementMock->expects($this->any())->method('set')->with($cartId, $paymentMock); + $billingAddressMock->expects($this->any())->method('setCustomerId')->with($customerId); $this->assertTrue($this->model->savePaymentInformation($cartId, $paymentMock, $billingAddressMock)); } @@ -220,14 +242,50 @@ private function getMockForAssignBillingAddress($cartId, $billingAddressMock) ->getMock(); $this->cartRepositoryMock->expects($this->any())->method('getActive')->with($cartId)->willReturn($quoteMock); $quoteMock->method('getBillingAddress')->willReturn($quoteBillingAddress); - $quoteMock->expects($this->once())->method('getShippingAddress')->willReturn($quoteShippingAddress); - $quoteBillingAddress->expects($this->once())->method('getId')->willReturn($billingAddressId); - $quoteBillingAddress->expects($this->once())->method('getId')->willReturn($billingAddressId); - $quoteMock->expects($this->once())->method('removeAddress')->with($billingAddressId); - $quoteMock->expects($this->once())->method('setBillingAddress')->with($billingAddressMock); - $quoteMock->expects($this->once())->method('setDataChanges')->willReturnSelf(); + $quoteMock->expects($this->any())->method('getShippingAddress')->willReturn($quoteShippingAddress); + $quoteBillingAddress->expects($this->any())->method('getId')->willReturn($billingAddressId); + $quoteBillingAddress->expects($this->any())->method('getId')->willReturn($billingAddressId); + $quoteMock->expects($this->any())->method('removeAddress')->with($billingAddressId); + $quoteMock->expects($this->any())->method('setBillingAddress')->with($billingAddressMock); + $quoteMock->expects($this->any())->method('setDataChanges')->willReturnSelf(); $quoteShippingAddress->expects($this->any())->method('getShippingRateByCode')->willReturn($shippingRate); $quoteShippingAddress->expects($this->any())->method('getShippingMethod')->willReturn('flatrate_flatrate'); - $quoteShippingAddress->expects($this->once())->method('setLimitCarrier')->with('flatrate')->willReturnSelf(); + $quoteShippingAddress->expects($this->any())->method('setLimitCarrier')->with('flatrate')->willReturnSelf(); + } + + /** + * Save payment information. + * + * @return mixed + */ + private function savePayment() + { + $cartId = 100; + $paymentMock = $this->getMockForAbstractClass(PaymentInterface::class); + $billingAddressMock = $this->getMockForAbstractClass(AddressInterface::class); + + $this->getMockForAssignBillingAddress($cartId, $billingAddressMock); + $this->paymentMethodManagementMock->expects($this->any())->method('set')->with($cartId, $paymentMock); + + return $this->model->savePaymentInformation($cartId, $paymentMock, $billingAddressMock); + } + + /** + * Call `place order`. + * + * @param int|null $orderId + * @return mixed + */ + private function placeOrder(?int $orderId = 200) + { + $cartId = 100; + $paymentMock = $this->getMockForAbstractClass(PaymentInterface::class); + $billingAddressMock = $this->getMockForAbstractClass(AddressInterface::class); + + $this->getMockForAssignBillingAddress($cartId, $billingAddressMock); + $this->paymentMethodManagementMock->expects($this->any())->method('set')->with($cartId, $paymentMock); + $this->cartManagementMock->expects($this->any())->method('placeOrder')->with($cartId)->willReturn($orderId); + + return $this->model->savePaymentInformationAndPlaceOrder($cartId, $paymentMock, $billingAddressMock); } } diff --git a/app/code/Magento/Checkout/Test/Unit/Model/StoreSwitcher/RedirectDataPostprocessorTest.php b/app/code/Magento/Checkout/Test/Unit/Model/StoreSwitcher/RedirectDataPostprocessorTest.php new file mode 100644 index 0000000000000..f8e17ca8acdec --- /dev/null +++ b/app/code/Magento/Checkout/Test/Unit/Model/StoreSwitcher/RedirectDataPostprocessorTest.php @@ -0,0 +1,167 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Checkout\Test\Unit\Model\StoreSwitcher; + +use Magento\Checkout\Model\Session as CheckoutSession; +use Magento\Checkout\Model\StoreSwitcher\RedirectDataPostprocessor; +use Magento\Customer\Model\Session as CustomerSession; +use Magento\Quote\Api\CartRepositoryInterface; +use Magento\Quote\Model\Quote; +use Magento\Store\Api\Data\StoreInterface; +use Magento\Store\Model\StoreSwitcher\ContextInterface; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +class RedirectDataPostprocessorTest extends TestCase +{ + /** + * @var CartRepositoryInterface + */ + private $quoteRepository; + + /** + * @var CustomerSession + */ + private $customerSession; + + /** + * @var CheckoutSession + */ + private $checkoutSession; + /** + * @var ContextInterface|MockObject + */ + private $context; + /** + * @var RedirectDataPostprocessor + */ + private $model; + + /** + * @inheritDoc + */ + protected function setUp(): void + { + parent::setUp(); + + $this->quoteRepository = $this->createMock(CartRepositoryInterface::class); + $this->customerSession = $this->createMock(CustomerSession::class); + $this->checkoutSession = $this->createMock(CheckoutSession::class); + $logger = $this->createMock(LoggerInterface::class); + $this->model = new RedirectDataPostprocessor( + $this->quoteRepository, + $this->customerSession, + $this->checkoutSession, + $logger + ); + + $store1 = $this->createConfiguredMock( + StoreInterface::class, + [ + 'getCode' => 'en', + 'getId' => 1, + ] + ); + $store2 = $this->createConfiguredMock( + StoreInterface::class, + [ + 'getCode' => 'fr', + 'getId' => 2, + ] + ); + $this->context = $this->createConfiguredMock( + ContextInterface::class, + [ + 'getFromStore' => $store2, + 'getTargetStore' => $store1, + ] + ); + } + + /** + * @dataProvider processDataProvider + * @param array $mock + * @param array $data + * @param bool $isQuoteSet + */ + public function testProcess(array $mock, array $data, bool $isQuoteSet): void + { + $this->customerSession->method('isLoggedIn') + ->willReturn($mock['isLoggedIn']); + $this->checkoutSession->method('getQuoteId') + ->willReturn($mock['getQuoteId']); + $this->checkoutSession->method('getQuote') + ->willReturnCallback( + function () use ($mock) { + return $this->createQuoteMock($mock); + } + ); + $this->quoteRepository->method('get') + ->willReturnCallback( + function ($id) use ($mock) { + return $this->createQuoteMock(array_merge($mock, ['getQuoteId' => $id])); + } + ); + $this->checkoutSession->expects($isQuoteSet ? $this->once() : $this->never()) + ->method('setQuoteId') + ->with($data['quote_id'] ?? null); + + $this->model->process($this->context, $data); + } + + /** + * @return array + */ + public function processDataProvider(): array + { + return [ + [ + ['isLoggedIn' => false, 'getQuoteId' => 4], + ['quote_id' => 2], + false + ], + [ + ['isLoggedIn' => true, 'getQuoteId' => null], + ['quote_id' => 2], + false + ], + [ + ['isLoggedIn' => false, 'getQuoteId' => null], + ['quote_id' => 1], + false + ], + [ + ['isLoggedIn' => false, 'getQuoteId' => null, 'getIsActive' => false], + ['quote_id' => 2], + false + ], + [ + ['isLoggedIn' => false, 'getQuoteId' => null], + ['quote_id' => 2], + true + ], + ]; + } + + /** + * @param array $mock + * @return Quote + */ + private function createQuoteMock(array $mock): Quote + { + return $this->createConfiguredMock( + Quote::class, + [ + 'getIsActive' => $mock['getIsActive'] ?? true, + 'getId' => $mock['getQuoteId'], + 'getSharedStoreIds' => !($mock['getQuoteId'] % 2) ? [1, 2] : [2], + ] + ); + } +} diff --git a/app/code/Magento/Checkout/Test/Unit/Model/StoreSwitcher/RedirectDataPreprocessorTest.php b/app/code/Magento/Checkout/Test/Unit/Model/StoreSwitcher/RedirectDataPreprocessorTest.php new file mode 100644 index 0000000000000..d5c4691d36a14 --- /dev/null +++ b/app/code/Magento/Checkout/Test/Unit/Model/StoreSwitcher/RedirectDataPreprocessorTest.php @@ -0,0 +1,126 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Checkout\Test\Unit\Model\StoreSwitcher; + +use Magento\Checkout\Model\Session as CheckoutSession; +use Magento\Checkout\Model\StoreSwitcher\RedirectDataPreprocessor; +use Magento\Customer\Model\Session as CustomerSession; +use Magento\Quote\Model\Quote; +use Magento\Store\Api\Data\StoreInterface; +use Magento\Store\Model\StoreSwitcher\ContextInterface; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +class RedirectDataPreprocessorTest extends TestCase +{ + /** + * @var CustomerSession + */ + private $customerSession; + /** + * @var CheckoutSession + */ + private $checkoutSession; + /** + * @var RedirectDataPreprocessor + */ + private $model; + /** + * @var ContextInterface|MockObject + */ + private $context; + + /** + * @inheritDoc + */ + protected function setUp(): void + { + parent::setUp(); + + $this->customerSession = $this->createMock(CustomerSession::class); + $this->checkoutSession = $this->createMock(CheckoutSession::class); + $this->model = new RedirectDataPreprocessor( + $this->customerSession, + $this->checkoutSession + ); + + $store1 = $this->createConfiguredMock( + StoreInterface::class, + [ + 'getCode' => 'en', + 'getId' => 1, + ] + ); + $store2 = $this->createConfiguredMock( + StoreInterface::class, + [ + 'getCode' => 'fr', + 'getId' => 2, + ] + ); + $this->context = $this->createConfiguredMock( + ContextInterface::class, + [ + 'getFromStore' => $store2, + 'getTargetStore' => $store1, + ] + ); + } + + /** + * @dataProvider processDataProvider + * @param array $mock + * @param array $data + */ + public function testProcess(array $mock, array $data): void + { + $this->customerSession->method('isLoggedIn') + ->willReturn($mock['isLoggedIn']); + $this->checkoutSession->method('getQuoteId') + ->willReturn($mock['getQuoteId']); + $this->checkoutSession->method('getQuote') + ->willReturnCallback( + function () use ($mock) { + return $this->createConfiguredMock( + Quote::class, + [ + 'getIsActive' => $mock['getIsActive'] ?? true, + 'getId' => $mock['getQuoteId'], + 'getSharedStoreIds' => !($mock['getQuoteId'] % 2) ? [1, 2] : [2], + ] + ); + } + ); + $this->assertEquals($data, $this->model->process($this->context, [])); + } + + /** + * @return array + */ + public function processDataProvider(): array + { + return [ + [ + ['isLoggedIn' => true, 'getQuoteId' => 1], + [] + ], + [ + ['isLoggedIn' => false, 'getQuoteId' => null], + [] + ], + [ + ['isLoggedIn' => false, 'getQuoteId' => 1], + [] + ], + [ + ['isLoggedIn' => false, 'getQuoteId' => 2], + ['quote_id' => 2] + ], + ]; + } +} diff --git a/app/code/Magento/Checkout/Test/Unit/Model/TotalsInformationManagementTest.php b/app/code/Magento/Checkout/Test/Unit/Model/TotalsInformationManagementTest.php new file mode 100644 index 0000000000000..61049b4893476 --- /dev/null +++ b/app/code/Magento/Checkout/Test/Unit/Model/TotalsInformationManagementTest.php @@ -0,0 +1,134 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Checkout\Test\Unit\Model; + +use Magento\Checkout\Api\Data\TotalsInformationInterface; +use Magento\Checkout\Model\TotalsInformationManagement; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; +use Magento\Quote\Api\CartRepositoryInterface; +use Magento\Quote\Api\CartTotalRepositoryInterface; +use Magento\Quote\Model\Quote\Address; + +class TotalsInformationManagementTest extends \PHPUnit\Framework\TestCase +{ + /** + * @var ObjectManager + */ + private $objectManager; + + /** + * @var CartRepositoryInterface|\PHPUnit\Framework\MockObject\MockObject + */ + private $cartRepositoryMock; + + /** + * @var CartTotalRepositoryInterface|\PHPUnit\Framework\MockObject\MockObject + */ + private $cartTotalRepositoryMock; + + /** + * @var TotalsInformationManagement + */ + private $totalsInformationManagement; + + protected function setUp(): void + { + $this->objectManager = new ObjectManager($this); + $this->cartRepositoryMock = $this->createMock( + CartRepositoryInterface::class + ); + $this->cartTotalRepositoryMock = $this->createMock( + CartTotalRepositoryInterface::class + ); + + $this->totalsInformationManagement = $this->objectManager->getObject( + TotalsInformationManagement::class, + [ + 'cartRepository' => $this->cartRepositoryMock, + 'cartTotalRepository' => $this->cartTotalRepositoryMock, + ] + ); + } + + /** + * Test for \Magento\Checkout\Model\TotalsInformationManagement::calculate. + * + * @param string|null $carrierCode + * @param string|null $carrierMethod + * @param int $methodSetCount + * @dataProvider dataProviderCalculate + */ + public function testCalculate(?string $carrierCode, ?string $carrierMethod, int $methodSetCount) + { + $cartId = 1; + $cartMock = $this->createMock( + \Magento\Quote\Model\Quote::class + ); + $cartMock->expects($this->once())->method('getItemsCount')->willReturn(1); + $cartMock->expects($this->once())->method('getIsVirtual')->willReturn(false); + $this->cartRepositoryMock->expects($this->once())->method('get')->with($cartId)->willReturn($cartMock); + $this->cartTotalRepositoryMock->expects($this->once())->method('get')->with($cartId); + + $addressInformationMock = $this->createMock( + TotalsInformationInterface::class + ); + $addressMock = $this->getMockBuilder(Address::class) + ->addMethods( + [ + 'setShippingMethod', + 'setCollectShippingRates', + ] + ) + ->disableOriginalConstructor() + ->getMock(); + + $addressInformationMock->expects($this->once())->method('getAddress')->willReturn($addressMock); + $addressInformationMock->expects($this->any())->method('getShippingCarrierCode')->willReturn($carrierCode); + $addressInformationMock->expects($this->any())->method('getShippingMethodCode')->willReturn($carrierMethod); + $cartMock->expects($this->once())->method('setShippingAddress')->with($addressMock); + $cartMock->expects($this->exactly($methodSetCount))->method('getShippingAddress')->willReturn($addressMock); + $addressMock->expects($this->exactly($methodSetCount)) + ->method('setCollectShippingRates')->with(true)->willReturn($addressMock); + $addressMock->expects($this->exactly($methodSetCount)) + ->method('setShippingMethod')->with($carrierCode . '_' . $carrierMethod); + $cartMock->expects($this->once())->method('collectTotals'); + + $this->totalsInformationManagement->calculate($cartId, $addressInformationMock); + } + + /** + * Data provider for testCalculate. + * + * @return array + */ + public function dataProviderCalculate(): array + { + return [ + [ + null, + null, + 0 + ], + [ + null, + 'carrier_method', + 0 + ], + [ + 'carrier_code', + null, + 0 + ], + [ + 'carrier_code', + 'carrier_method', + 1 + ] + ]; + } +} diff --git a/app/code/Magento/Checkout/composer.json b/app/code/Magento/Checkout/composer.json index 2b4fce7dc011a..5f7b5425667e5 100644 --- a/app/code/Magento/Checkout/composer.json +++ b/app/code/Magento/Checkout/composer.json @@ -24,7 +24,8 @@ "magento/module-tax": "*", "magento/module-theme": "*", "magento/module-ui": "*", - "magento/module-captcha": "*" + "magento/module-captcha": "*", + "magento/module-authorization": "*" }, "suggest": { "magento/module-cookie": "*" diff --git a/app/code/Magento/Checkout/etc/config.xml b/app/code/Magento/Checkout/etc/config.xml index 4db5f5bdc01c9..eac0bd849da35 100644 --- a/app/code/Magento/Checkout/etc/config.xml +++ b/app/code/Magento/Checkout/etc/config.xml @@ -41,6 +41,9 @@ <sales_rule_coupon_request> <label>Applying coupon code</label> </sales_rule_coupon_request> + <payment_processing_request> + <label>Checkout/Placing Order</label> + </payment_processing_request> </areas> </frontend> </captcha> @@ -48,6 +51,7 @@ <captcha> <shown_to_logged_in_user> <sales_rule_coupon_request>1</sales_rule_coupon_request> + <payment_processing_request>1</payment_processing_request> </shown_to_logged_in_user> </captcha> </customer> diff --git a/app/code/Magento/Checkout/etc/di.xml b/app/code/Magento/Checkout/etc/di.xml index 4ebd594a28562..0c1d866dfc2fb 100644 --- a/app/code/Magento/Checkout/etc/di.xml +++ b/app/code/Magento/Checkout/etc/di.xml @@ -49,4 +49,6 @@ </argument> </arguments> </type> + <preference for="Magento\Checkout\Api\PaymentProcessingRateLimiterInterface" + type="Magento\Checkout\Model\CaptchaPaymentProcessingRateLimiter" /> </config> diff --git a/app/code/Magento/Checkout/etc/frontend/di.xml b/app/code/Magento/Checkout/etc/frontend/di.xml index 8f35fe9f37abf..e02d029939425 100644 --- a/app/code/Magento/Checkout/etc/frontend/di.xml +++ b/app/code/Magento/Checkout/etc/frontend/di.xml @@ -49,6 +49,7 @@ <argument name="configProviders" xsi:type="array"> <item name="checkout_default_config_provider" xsi:type="object">Magento\Checkout\Model\DefaultConfigProvider</item> <item name="checkout_summary_config_provider" xsi:type="object">Magento\Checkout\Model\Cart\CheckoutSummaryConfigProvider</item> + <item name="checkout_payment_provider" xsi:type="object">Magento\Checkout\Model\PaymentCaptchaConfigProvider</item> </argument> </arguments> </type> @@ -99,4 +100,25 @@ <type name="Magento\Quote\Model\Quote"> <plugin name="clear_addresses_after_product_delete" type="Magento\Checkout\Plugin\Model\Quote\ResetQuoteAddresses"/> </type> + <type name="Magento\Captcha\CustomerData\Captcha"> + <arguments> + <argument name="formIds" xsi:type="array"> + <item name="payment_processing_request" xsi:type="string">payment_processing_request</item> + </argument> + </arguments> + </type> + <type name="Magento\Store\Model\StoreSwitcher\RedirectDataPreprocessorComposite"> + <arguments> + <argument name="processors" xsi:type="array"> + <item name="checkout_session" xsi:type="object">Magento\Checkout\Model\StoreSwitcher\RedirectDataPreprocessor</item> + </argument> + </arguments> + </type> + <type name="Magento\Store\Model\StoreSwitcher\RedirectDataPostprocessorComposite"> + <arguments> + <argument name="processors" xsi:type="array"> + <item name="checkout_session" xsi:type="object">Magento\Checkout\Model\StoreSwitcher\RedirectDataPostprocessor</item> + </argument> + </arguments> + </type> </config> diff --git a/app/code/Magento/Checkout/i18n/en_US.csv b/app/code/Magento/Checkout/i18n/en_US.csv index 85ee4e1f03f0e..ca118f21f2441 100644 --- a/app/code/Magento/Checkout/i18n/en_US.csv +++ b/app/code/Magento/Checkout/i18n/en_US.csv @@ -185,3 +185,4 @@ Payment,Payment "Close","Close" "Show Cross-sell Items in the Shopping Cart","Show Cross-sell Items in the Shopping Cart" "You added %1 to your <a href=""%2"">shopping cart</a>.","You added %1 to your <a href=""%2"">shopping cart</a>." +"The shipping method is missing. Select the shipping method and try again.","The shipping method is missing. Select the shipping method and try again." diff --git a/app/code/Magento/Checkout/view/frontend/layout/checkout_cart_sidebar_item_renderers.xml b/app/code/Magento/Checkout/view/frontend/layout/checkout_cart_sidebar_item_renderers.xml index 1b9bad3d81c65..1cf965e83718a 100644 --- a/app/code/Magento/Checkout/view/frontend/layout/checkout_cart_sidebar_item_renderers.xml +++ b/app/code/Magento/Checkout/view/frontend/layout/checkout_cart_sidebar_item_renderers.xml @@ -21,7 +21,7 @@ </item> <item name="children" xsi:type="array"> <item name="item.renderer" xsi:type="array"> - <item name="component" xsi:type="string">uiComponent</item> + <item name="component" xsi:type="string">Magento_Checkout/js/view/cart-item-renderer</item> <item name="config" xsi:type="array"> <item name="displayArea" xsi:type="string">defaultRenderer</item> <item name="template" xsi:type="string">Magento_Checkout/minicart/item/default</item> diff --git a/app/code/Magento/Checkout/view/frontend/layout/checkout_index_index.xml b/app/code/Magento/Checkout/view/frontend/layout/checkout_index_index.xml index 192f20653f8c3..e854863a1da1f 100644 --- a/app/code/Magento/Checkout/view/frontend/layout/checkout_index_index.xml +++ b/app/code/Magento/Checkout/view/frontend/layout/checkout_index_index.xml @@ -32,12 +32,10 @@ </item> <item name="children" xsi:type="array"> <item name="errors" xsi:type="array"> - <item name="sortOrder" xsi:type="string">0</item> <item name="component" xsi:type="string">Magento_Ui/js/view/messages</item> <item name="displayArea" xsi:type="string">messages</item> </item> <item name="authentication" xsi:type="array"> - <item name="sortOrder" xsi:type="string">1</item> <item name="component" xsi:type="string">Magento_Checkout/js/view/authentication</item> <item name="displayArea" xsi:type="string">authentication</item> <item name="children" xsi:type="array"> @@ -50,7 +48,6 @@ </item> </item> <item name="progressBar" xsi:type="array"> - <item name="sortOrder" xsi:type="string">0</item> <item name="component" xsi:type="string">Magento_Checkout/js/view/progress-bar</item> <item name="displayArea" xsi:type="string">progressBar</item> <item name="config" xsi:type="array"> @@ -61,7 +58,6 @@ </item> </item> <item name="estimation" xsi:type="array"> - <item name="sortOrder" xsi:type="string">10</item> <item name="component" xsi:type="string">Magento_Checkout/js/view/estimation</item> <item name="displayArea" xsi:type="string">estimation</item> <item name="config" xsi:type="array"> @@ -284,6 +280,12 @@ </item> </item> </item> + <item name="place-order-captcha" xsi:type="array"> + <item name="component" xsi:type="string">Magento_Checkout/js/view/checkout/placeOrderCaptcha</item> + <item name="displayArea" xsi:type="string">place-order-captcha</item> + <item name="formId" xsi:type="string">payment_processing_request</item> + <item name="configSource" xsi:type="string">checkoutConfig</item> + </item> <item name="beforeMethods" xsi:type="array"> <item name="component" xsi:type="string">uiComponent</item> <item name="displayArea" xsi:type="string">beforeMethods</item> @@ -335,7 +337,6 @@ </item> </item> <item name="sidebar" xsi:type="array"> - <item name="sortOrder" xsi:type="string">50</item> <item name="component" xsi:type="string">Magento_Checkout/js/view/sidebar</item> <item name="displayArea" xsi:type="string">sidebar</item> <item name="config" xsi:type="array"> diff --git a/app/code/Magento/Checkout/view/frontend/web/js/action/set-payment-information-extended.js b/app/code/Magento/Checkout/view/frontend/web/js/action/set-payment-information-extended.js index 9de8a93905c99..ae5b0914e83a6 100644 --- a/app/code/Magento/Checkout/view/frontend/web/js/action/set-payment-information-extended.js +++ b/app/code/Magento/Checkout/view/frontend/web/js/action/set-payment-information-extended.js @@ -14,8 +14,9 @@ define([ 'Magento_Customer/js/model/customer', 'Magento_Checkout/js/action/get-totals', 'Magento_Checkout/js/model/full-screen-loader', - 'underscore' -], function (quote, urlBuilder, storage, errorProcessor, customer, getTotalsAction, fullScreenLoader, _) { + 'underscore', + 'Magento_Checkout/js/model/payment/set-payment-hooks' +], function (quote, urlBuilder, storage, errorProcessor, customer, getTotalsAction, fullScreenLoader, _, hooks) { 'use strict'; /** @@ -37,7 +38,8 @@ define([ return function (messageContainer, paymentData, skipBilling) { var serviceUrl, - payload; + payload, + headers = {}; paymentData = filterTemplateData(paymentData); skipBilling = skipBilling || false; @@ -64,8 +66,12 @@ define([ fullScreenLoader.startLoader(); + _.each(hooks.requestModifiers, function (modifier) { + modifier(headers, payload); + }); + return storage.post( - serviceUrl, JSON.stringify(payload) + serviceUrl, JSON.stringify(payload), true, 'application/json', headers ).fail( function (response) { errorProcessor.process(response, messageContainer); @@ -73,6 +79,9 @@ define([ ).always( function () { fullScreenLoader.stopLoader(); + _.each(hooks.afterRequestListeners, function (listener) { + listener(); + }); } ); }; diff --git a/app/code/Magento/Checkout/view/frontend/web/js/checkout-data.js b/app/code/Magento/Checkout/view/frontend/web/js/checkout-data.js index 1858ce946fb07..5c51fbb01f873 100644 --- a/app/code/Magento/Checkout/view/frontend/web/js/checkout-data.js +++ b/app/code/Magento/Checkout/view/frontend/web/js/checkout-data.js @@ -11,8 +11,9 @@ define([ 'jquery', 'Magento_Customer/js/customer-data', + 'mageUtils', 'jquery/jquery-storageapi' -], function ($, storage) { +], function ($, storage, utils) { 'use strict'; var cacheKey = 'checkout-data', @@ -88,7 +89,7 @@ define([ setShippingAddressFromData: function (data) { var obj = getData(); - obj.shippingAddressFromData = data; + obj.shippingAddressFromData = utils.filterFormData(data); saveData(obj); }, @@ -193,7 +194,7 @@ define([ setBillingAddressFromData: function (data) { var obj = getData(); - obj.billingAddressFromData = data; + obj.billingAddressFromData = utils.filterFormData(data); saveData(obj); }, diff --git a/app/code/Magento/Checkout/view/frontend/web/js/model/payment/place-order-hooks.js b/app/code/Magento/Checkout/view/frontend/web/js/model/payment/place-order-hooks.js new file mode 100644 index 0000000000000..5cd31d85c9a29 --- /dev/null +++ b/app/code/Magento/Checkout/view/frontend/web/js/model/payment/place-order-hooks.js @@ -0,0 +1,13 @@ +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +define([], function () { + 'use strict'; + + return { + requestModifiers: [], + afterRequestListeners: [] + }; +}); diff --git a/app/code/Magento/Checkout/view/frontend/web/js/model/payment/set-payment-hooks.js b/app/code/Magento/Checkout/view/frontend/web/js/model/payment/set-payment-hooks.js new file mode 100644 index 0000000000000..5cd31d85c9a29 --- /dev/null +++ b/app/code/Magento/Checkout/view/frontend/web/js/model/payment/set-payment-hooks.js @@ -0,0 +1,13 @@ +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +define([], function () { + 'use strict'; + + return { + requestModifiers: [], + afterRequestListeners: [] + }; +}); diff --git a/app/code/Magento/Checkout/view/frontend/web/js/model/place-order.js b/app/code/Magento/Checkout/view/frontend/web/js/model/place-order.js index c07878fcaea92..701c31944939b 100644 --- a/app/code/Magento/Checkout/view/frontend/web/js/model/place-order.js +++ b/app/code/Magento/Checkout/view/frontend/web/js/model/place-order.js @@ -10,16 +10,23 @@ define( 'mage/storage', 'Magento_Checkout/js/model/error-processor', 'Magento_Checkout/js/model/full-screen-loader', - 'Magento_Customer/js/customer-data' + 'Magento_Customer/js/customer-data', + 'Magento_Checkout/js/model/payment/place-order-hooks', + 'underscore' ], - function (storage, errorProcessor, fullScreenLoader, customerData) { + function (storage, errorProcessor, fullScreenLoader, customerData, hooks, _) { 'use strict'; return function (serviceUrl, payload, messageContainer) { + var headers = {}; + fullScreenLoader.startLoader(); + _.each(hooks.requestModifiers, function (modifier) { + modifier(headers, payload); + }); return storage.post( - serviceUrl, JSON.stringify(payload) + serviceUrl, JSON.stringify(payload), true, 'application/json', headers ).fail( function (response) { errorProcessor.process(response, messageContainer); @@ -44,6 +51,9 @@ define( ).always( function () { fullScreenLoader.stopLoader(); + _.each(hooks.afterRequestListeners, function (listener) { + listener(); + }); } ); }; diff --git a/app/code/Magento/Checkout/view/frontend/web/js/view/billing-address.js b/app/code/Magento/Checkout/view/frontend/web/js/view/billing-address.js index f850386890470..127aa6ef01f55 100644 --- a/app/code/Magento/Checkout/view/frontend/web/js/view/billing-address.js +++ b/app/code/Magento/Checkout/view/frontend/web/js/view/billing-address.js @@ -245,7 +245,7 @@ function ( * @returns {*} */ getCustomAttributeLabel: function (attribute) { - var resultAttribute; + var label; if (typeof attribute === 'string') { return attribute; @@ -255,13 +255,40 @@ function ( return attribute.label; } - if (typeof this.source.get('customAttributes') !== 'undefined') { - resultAttribute = _.findWhere(this.source.get('customAttributes')[attribute['attribute_code']], { - value: attribute.value + if (_.isArray(attribute.value)) { + label = _.map(attribute.value, function (value) { + return this.getCustomAttributeOptionLabel(attribute['attribute_code'], value) || value; + }, this).join(', '); + } else { + label = this.getCustomAttributeOptionLabel(attribute['attribute_code'], attribute.value); + } + + return label || attribute.value; + }, + + /** + * Get option label for given attribute code and option ID + * + * @param {String} attributeCode + * @param {String} value + * @returns {String|null} + */ + getCustomAttributeOptionLabel: function (attributeCode, value) { + var option, + label, + options = this.source.get('customAttributes') || {}; + + if (options[attributeCode]) { + option = _.findWhere(options[attributeCode], { + value: value }); + + if (option) { + label = option.label; + } } - return resultAttribute && resultAttribute.label || attribute.value; + return label; } }); }); diff --git a/app/code/Magento/Checkout/view/frontend/web/js/view/cart-item-renderer.js b/app/code/Magento/Checkout/view/frontend/web/js/view/cart-item-renderer.js new file mode 100644 index 0000000000000..e1568efc3d6c8 --- /dev/null +++ b/app/code/Magento/Checkout/view/frontend/web/js/view/cart-item-renderer.js @@ -0,0 +1,34 @@ +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +define([ + 'uiComponent' +], function (Component) { + 'use strict'; + + return Component.extend({ + /** + * Prepare the product name value to be rendered as HTML + * + * @param {String} productName + * @return {String} + */ + getProductNameUnsanitizedHtml: function (productName) { + // product name has already escaped on backend + return productName; + }, + + /** + * Prepare the given option value to be rendered as HTML + * + * @param {String} optionValue + * @return {String} + */ + getOptionValueUnsanitizedHtml: function (optionValue) { + // option value has already escaped on backend + return optionValue; + } + }); +}); diff --git a/app/code/Magento/Checkout/view/frontend/web/js/view/cart/shipping-estimation.js b/app/code/Magento/Checkout/view/frontend/web/js/view/cart/shipping-estimation.js index a857d89a72b14..d1adb27353e1c 100644 --- a/app/code/Magento/Checkout/view/frontend/web/js/view/cart/shipping-estimation.js +++ b/app/code/Magento/Checkout/view/frontend/web/js/view/cart/shipping-estimation.js @@ -79,7 +79,13 @@ define( if (!quote.isVirtual()) { checkoutProvider.on('shippingAddress', function (shippingAddressData) { - checkoutData.setShippingAddressFromData(shippingAddressData); + //jscs:disable requireCamelCaseOrUpperCaseIdentifiers + if (quote.shippingAddress().countryId !== shippingAddressData.country_id || + (shippingAddressData.postcode || shippingAddressData.region_id) + ) { + checkoutData.setShippingAddressFromData(shippingAddressData); + } + //jscs:enable requireCamelCaseOrUpperCaseIdentifiers }); } else { checkoutProvider.on('shippingAddress', function (shippingAddressData) { diff --git a/app/code/Magento/Checkout/view/frontend/web/js/view/checkout/placeOrderCaptcha.js b/app/code/Magento/Checkout/view/frontend/web/js/view/checkout/placeOrderCaptcha.js new file mode 100644 index 0000000000000..d0e27ad8e0abb --- /dev/null +++ b/app/code/Magento/Checkout/view/frontend/web/js/view/checkout/placeOrderCaptcha.js @@ -0,0 +1,38 @@ +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +define([ + 'Magento_Captcha/js/view/checkout/defaultCaptcha', + 'Magento_Captcha/js/model/captchaList', + 'underscore', + 'Magento_Checkout/js/model/payment/place-order-hooks' +], +function (defaultCaptcha, captchaList, _, placeOrderHooks) { + 'use strict'; + + return defaultCaptcha.extend({ + /** @inheritdoc */ + initialize: function () { + var self = this, + currentCaptcha; + + this._super(); + currentCaptcha = captchaList.getCaptchaByFormId(this.formId); + + if (currentCaptcha != null) { + currentCaptcha.setIsVisible(true); + this.setCurrentCaptcha(currentCaptcha); + placeOrderHooks.requestModifiers.push(function (headers) { + if (self.isRequired()) { + headers['X-Captcha'] = self.captchaValue()(); + } + }); + placeOrderHooks.afterRequestListeners.push(function () { + self.refresh(); + }); + } + } + }); +}); diff --git a/app/code/Magento/Checkout/view/frontend/web/js/view/checkout/setPaymentCaptcha.js b/app/code/Magento/Checkout/view/frontend/web/js/view/checkout/setPaymentCaptcha.js new file mode 100644 index 0000000000000..93f3bb8b2a45c --- /dev/null +++ b/app/code/Magento/Checkout/view/frontend/web/js/view/checkout/setPaymentCaptcha.js @@ -0,0 +1,38 @@ +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +define([ + 'Magento_Captcha/js/view/checkout/defaultCaptcha', + 'Magento_Captcha/js/model/captchaList', + 'underscore', + 'Magento_Checkout/js/model/payment/set-payment-hooks' +], +function (defaultCaptcha, captchaList, _, setPaymentHooks) { + 'use strict'; + + return defaultCaptcha.extend({ + /** @inheritdoc */ + initialize: function () { + var self = this, + currentCaptcha; + + this._super(); + currentCaptcha = captchaList.getCaptchaByFormId(this.formId); + + if (currentCaptcha != null) { + currentCaptcha.setIsVisible(true); + this.setCurrentCaptcha(currentCaptcha); + setPaymentHooks.requestModifiers.push(function (headers) { + if (self.isRequired()) { + headers['X-Captcha'] = self.captchaValue()(); + } + }); + setPaymentHooks.afterRequestListeners.push(function () { + self.refresh(); + }); + } + } + }); +}); diff --git a/app/code/Magento/Checkout/view/frontend/web/js/view/shipping-address/address-renderer/default.js b/app/code/Magento/Checkout/view/frontend/web/js/view/shipping-address/address-renderer/default.js index 1f8cc90fe1622..3a4f34c26e5d7 100644 --- a/app/code/Magento/Checkout/view/frontend/web/js/view/shipping-address/address-renderer/default.js +++ b/app/code/Magento/Checkout/view/frontend/web/js/view/shipping-address/address-renderer/default.js @@ -55,7 +55,7 @@ define([ * @returns {*} */ getCustomAttributeLabel: function (attribute) { - var resultAttribute; + var label; if (typeof attribute === 'string') { return attribute; @@ -65,13 +65,40 @@ define([ return attribute.label; } - if (typeof this.source.get('customAttributes') !== 'undefined') { - resultAttribute = _.findWhere(this.source.get('customAttributes')[attribute['attribute_code']], { - value: attribute.value + if (_.isArray(attribute.value)) { + label = _.map(attribute.value, function (value) { + return this.getCustomAttributeOptionLabel(attribute['attribute_code'], value) || value; + }, this).join(', '); + } else { + label = this.getCustomAttributeOptionLabel(attribute['attribute_code'], attribute.value); + } + + return label || attribute.value; + }, + + /** + * Get option label for given attribute code and option ID + * + * @param {String} attributeCode + * @param {String} value + * @returns {String|null} + */ + getCustomAttributeOptionLabel: function (attributeCode, value) { + var option, + label, + options = this.source.get('customAttributes') || {}; + + if (options[attributeCode]) { + option = _.findWhere(options[attributeCode], { + value: value }); + + if (option) { + label = option.label; + } } - return resultAttribute && resultAttribute.label || attribute.value; + return label; }, /** Set selected customer shipping address */ diff --git a/app/code/Magento/Checkout/view/frontend/web/js/view/shipping-information/address-renderer/default.js b/app/code/Magento/Checkout/view/frontend/web/js/view/shipping-information/address-renderer/default.js index 6ec9fde554dc2..03591c95e46cb 100644 --- a/app/code/Magento/Checkout/view/frontend/web/js/view/shipping-information/address-renderer/default.js +++ b/app/code/Magento/Checkout/view/frontend/web/js/view/shipping-information/address-renderer/default.js @@ -32,7 +32,7 @@ define([ * @returns {*} */ getCustomAttributeLabel: function (attribute) { - var resultAttribute; + var label; if (typeof attribute === 'string') { return attribute; @@ -42,13 +42,40 @@ define([ return attribute.label; } - if (typeof this.source.get('customAttributes') !== 'undefined') { - resultAttribute = _.findWhere(this.source.get('customAttributes')[attribute['attribute_code']], { - value: attribute.value + if (_.isArray(attribute.value)) { + label = _.map(attribute.value, function (value) { + return this.getCustomAttributeOptionLabel(attribute['attribute_code'], value) || value; + }, this).join(', '); + } else { + label = this.getCustomAttributeOptionLabel(attribute['attribute_code'], attribute.value); + } + + return label || attribute.value; + }, + + /** + * Get option label for given attribute code and option ID + * + * @param {String} attributeCode + * @param {String} value + * @returns {String|null} + */ + getCustomAttributeOptionLabel: function (attributeCode, value) { + var option, + label, + options = this.source.get('customAttributes') || {}; + + if (options[attributeCode]) { + option = _.findWhere(options[attributeCode], { + value: value }); + + if (option) { + label = option.label; + } } - return resultAttribute && resultAttribute.label || attribute.value; + return label; } }); }); diff --git a/app/code/Magento/Checkout/view/frontend/web/js/view/shipping.js b/app/code/Magento/Checkout/view/frontend/web/js/view/shipping.js index fe8d7782e5eae..2a52b64647749 100644 --- a/app/code/Magento/Checkout/view/frontend/web/js/view/shipping.js +++ b/app/code/Magento/Checkout/view/frontend/web/js/view/shipping.js @@ -121,7 +121,9 @@ define([ ); } checkoutProvider.on('shippingAddress', function (shippingAddrsData) { - checkoutData.setShippingAddressFromData(shippingAddrsData); + if (shippingAddrsData.street && !_.isEmpty(shippingAddrsData.street[0])) { + checkoutData.setShippingAddressFromData(shippingAddrsData); + } }); shippingRatesValidator.initFields(fieldsetName); }); @@ -299,6 +301,12 @@ define([ this.source.set('params.invalid', false); this.triggerShippingDataValidateEvent(); + if (!quote.shippingMethod()['method_code']) { + this.errorValidationMessage( + $t('The shipping method is missing. Select the shipping method and try again.') + ); + } + if (emailValidationResult && this.source.get('params.invalid') || !quote.shippingMethod()['method_code'] || diff --git a/app/code/Magento/Checkout/view/frontend/web/template/minicart/content.html b/app/code/Magento/Checkout/view/frontend/web/template/minicart/content.html index a47f11e5787c3..08e66ef0401e0 100644 --- a/app/code/Magento/Checkout/view/frontend/web/template/minicart/content.html +++ b/app/code/Magento/Checkout/view/frontend/web/template/minicart/content.html @@ -35,12 +35,12 @@ <span class="count" if="maxItemsToDisplay < getCartLineItemsCount()" text="maxItemsToDisplay"/> <translate args="'of'" if="maxItemsToDisplay < getCartLineItemsCount()"/> <span class="count" text="getCartParam('summary_count')"/> - <!-- ko if: (getCartLineItemsCount() === 1) --> - <span translate="'Item in Cart'"/> - <!--/ko--> - <!-- ko if: (getCartLineItemsCount() > 1) --> + <!-- ko if: (getCartParam('summary_count') > 1) --> <span translate="'Items in Cart'"/> <!--/ko--> + <!-- ko if: (getCartParam('summary_count') === 1) --> + <span translate="'Item in Cart'"/> + <!--/ko--> </div> <each args="getRegion('subtotalContainer')" render=""/> 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 053b15b4ad343..b15b7952d2cbd 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,10 +24,10 @@ <div class="product-item-details"> <strong class="product-item-name"> <!-- ko if: product_has_url --> - <a data-bind="attr: {href: product_url}, html: product_name"></a> + <a data-bind="attr: {href: product_url}, html: $parent.getProductNameUnsanitizedHtml(product_name)"></a> <!-- /ko --> <!-- ko ifnot: product_has_url --> - <!-- ko text: product_name --><!-- /ko --> + <span data-bind="html: $parent.getProductNameUnsanitizedHtml(product_name)"></span> <!-- /ko --> </strong> @@ -42,10 +42,10 @@ <dt class="label"><!-- ko text: option.label --><!-- /ko --></dt> <dd class="values"> <!-- ko if: Array.isArray(option.value) --> - <span data-bind="html: option.value.join('<br>')"></span> + <span data-bind="html: $parents[1].getOptionValueUnsanitizedHtml(option.value.join('<br/>'))"></span> <!-- /ko --> <!-- ko if: (!Array.isArray(option.value) && ['file', 'html'].includes(option.option_type)) --> - <span data-bind="html: option.value"></span> + <span data-bind="html: $parents[1].getOptionValueUnsanitizedHtml(option.value)"></span> <!-- /ko --> <!-- ko if: (!Array.isArray(option.value) && !['file', 'html'].includes(option.option_type)) --> <span data-bind="text: option.value"></span> diff --git a/app/code/Magento/Checkout/view/frontend/web/template/payment.html b/app/code/Magento/Checkout/view/frontend/web/template/payment.html index a3e1a0f7aca90..1e3d3fed3876f 100644 --- a/app/code/Magento/Checkout/view/frontend/web/template/payment.html +++ b/app/code/Magento/Checkout/view/frontend/web/template/payment.html @@ -21,6 +21,10 @@ <legend class="legend"> <span data-bind="i18n: 'Payment Information'"></span> </legend><br /> + <!-- ko foreach: getRegion('place-order-captcha') --> + <!-- ko template: getTemplate() --><!-- /ko --> + <!-- /ko --> + <br /> <!-- ko foreach: getRegion('beforeMethods') --> <!-- ko template: getTemplate() --><!-- /ko --> <!-- /ko --> diff --git a/app/code/Magento/Checkout/view/frontend/web/template/shipping-address/shipping-method-item.html b/app/code/Magento/Checkout/view/frontend/web/template/shipping-address/shipping-method-item.html index 11e419054582f..fd6be02657e3e 100644 --- a/app/code/Magento/Checkout/view/frontend/web/template/shipping-address/shipping-method-item.html +++ b/app/code/Magento/Checkout/view/frontend/web/template/shipping-address/shipping-method-item.html @@ -15,9 +15,11 @@ attr="'aria-labelledby': 'label_method_' + method.method_code + '_' + method.carrier_code + ' ' + 'label_carrier_' + method.method_code + '_' + method.carrier_code, 'checked': element.rates().length == 1 || element.isSelected" /> </td> + <!-- ko ifnot: (method.error_message) --> <td class="col col-price"> <each args="element.getRegion('price')" render="" /> </td> + <!-- /ko --> <td class="col col-method" attr="'id': 'label_method_' + method.method_code + '_' + method.carrier_code" text="method.method_title" /> diff --git a/app/code/Magento/Checkout/view/frontend/web/template/summary/cart-items.html b/app/code/Magento/Checkout/view/frontend/web/template/summary/cart-items.html index 8fc514990d567..0ea72fb275403 100644 --- a/app/code/Magento/Checkout/view/frontend/web/template/summary/cart-items.html +++ b/app/code/Magento/Checkout/view/frontend/web/template/summary/cart-items.html @@ -10,8 +10,8 @@ <translate args="maxCartItemsToDisplay" if="maxCartItemsToDisplay < getCartLineItemsCount()"/> <translate args="'of'" if="maxCartItemsToDisplay < getCartLineItemsCount()"/> <span data-bind="text: getCartSummaryItemsCount()"></span> - <translate args="'Item in Cart'" if="getCartLineItemsCount() === 1"/> - <translate args="'Items in Cart'" if="getCartLineItemsCount() > 1"/> + <translate args="'Item in Cart'" if="getCartSummaryItemsCount() === 1"/> + <translate args="'Items in Cart'" if="getCartSummaryItemsCount() > 1"/> </strong> </div> <div class="content minicart-items" data-role="content"> diff --git a/app/code/Magento/CheckoutAgreements/Model/AgreementsValidator.php b/app/code/Magento/CheckoutAgreements/Model/AgreementsValidator.php index 2643e69ba1efd..c78c807f9ea20 100644 --- a/app/code/Magento/CheckoutAgreements/Model/AgreementsValidator.php +++ b/app/code/Magento/CheckoutAgreements/Model/AgreementsValidator.php @@ -35,12 +35,12 @@ public function __construct($list = null) public function isValid($agreementIds = []) { $agreementIds = $agreementIds === null ? [] : $agreementIds; - $requiredAgreements = [[]]; + $requiredAgreements = []; foreach ($this->agreementsProviders as $agreementsProvider) { $requiredAgreements[] = $agreementsProvider->getRequiredAgreementIds(); } - $agreementsDiff = array_diff(array_merge(...$requiredAgreements), $agreementIds); + $agreementsDiff = array_diff(array_merge([], ...$requiredAgreements), $agreementIds); return empty($agreementsDiff); } diff --git a/app/code/Magento/Cms/Block/Adminhtml/Wysiwyg/Images/Tree.php b/app/code/Magento/Cms/Block/Adminhtml/Wysiwyg/Images/Tree.php index 41e9358e160cf..c033e09ca8db0 100644 --- a/app/code/Magento/Cms/Block/Adminhtml/Wysiwyg/Images/Tree.php +++ b/app/code/Magento/Cms/Block/Adminhtml/Wysiwyg/Images/Tree.php @@ -58,6 +58,7 @@ public function __construct( * Json tree builder * * @return string + * @throws \Magento\Framework\Exception\ValidatorException */ public function getTreeJson() { @@ -75,8 +76,8 @@ public function getTreeJson() 'path' => substr($item->getFilename(), strlen($storageRoot)), 'cls' => 'folder', ]; - - $hasNestedDirectories = count(glob($item->getFilename() . '/*', GLOB_ONLYDIR)) > 0; + $nestedDirectories = $this->getMediaDirectory()->readRecursively($item->getFilename()); + $hasNestedDirectories = count($nestedDirectories) > 0; // if no nested directories inside dir, add 'leaf' state so that jstree hides dropdown arrow next to dir if (!$hasNestedDirectories) { diff --git a/app/code/Magento/Cms/Block/Block.php b/app/code/Magento/Cms/Block/Block.php index 86cf059525e1e..afc95d369f67d 100644 --- a/app/code/Magento/Cms/Block/Block.php +++ b/app/code/Magento/Cms/Block/Block.php @@ -10,6 +10,8 @@ /** * Cms block content block + * @deprecated This class introduces caching issues and should no longer be used + * @see \Magento\Cms\Block\BlockByIdentifier */ class Block extends AbstractBlock implements \Magento\Framework\DataObject\IdentityInterface { diff --git a/app/code/Magento/Cms/Block/BlockByIdentifier.php b/app/code/Magento/Cms/Block/BlockByIdentifier.php new file mode 100644 index 0000000000000..eb8bf3d5fe352 --- /dev/null +++ b/app/code/Magento/Cms/Block/BlockByIdentifier.php @@ -0,0 +1,175 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Cms\Block; + +use Magento\Cms\Api\Data\BlockInterface; +use Magento\Cms\Api\GetBlockByIdentifierInterface; +use Magento\Cms\Model\Block as BlockModel; +use Magento\Cms\Model\Template\FilterProvider; +use Magento\Framework\DataObject\IdentityInterface; +use Magento\Framework\Exception\NoSuchEntityException; +use Magento\Framework\View\Element\AbstractBlock; +use Magento\Framework\View\Element\Context; +use Magento\Store\Model\StoreManagerInterface; + +/** + * This class is replacement of \Magento\Cms\Block\Block, that accepts only `string` identifier of CMS Block + */ +class BlockByIdentifier extends AbstractBlock implements IdentityInterface +{ + public const CACHE_KEY_PREFIX = 'CMS_BLOCK'; + + /** + * @var GetBlockByIdentifierInterface + */ + private $blockByIdentifier; + + /** + * @var StoreManagerInterface + */ + private $storeManager; + + /** + * @var FilterProvider + */ + private $filterProvider; + + /** + * @var BlockInterface + */ + private $cmsBlock; + + /** + * @param GetBlockByIdentifierInterface $blockByIdentifier + * @param StoreManagerInterface $storeManager + * @param FilterProvider $filterProvider + * @param Context $context + * @param array $data + */ + public function __construct( + GetBlockByIdentifierInterface $blockByIdentifier, + StoreManagerInterface $storeManager, + FilterProvider $filterProvider, + Context $context, + array $data = [] + ) { + parent::__construct($context, $data); + $this->blockByIdentifier = $blockByIdentifier; + $this->storeManager = $storeManager; + $this->filterProvider = $filterProvider; + } + + /** + * @inheritDoc + */ + protected function _toHtml(): string + { + try { + return $this->filterOutput( + $this->getCmsBlock()->getContent() + ); + } catch (NoSuchEntityException $e) { + return ''; + } + } + + /** + * Returns the value of `identifier` injected in `<block>` definition + * + * @return string|null + */ + private function getIdentifier(): ?string + { + return $this->getData('identifier') ?: null; + } + + /** + * Filters the Content + * + * @param string $content + * @return string + * @throws NoSuchEntityException + */ + private function filterOutput(string $content): string + { + return $this->filterProvider->getBlockFilter() + ->setStoreId($this->getCurrentStoreId()) + ->filter($content); + } + + /** + * Loads the CMS block by `identifier` provided as an argument + * + * @return BlockInterface|BlockModel + * @throws \InvalidArgumentException + * @throws NoSuchEntityException + */ + private function getCmsBlock(): BlockInterface + { + if (!$this->getIdentifier()) { + throw new \InvalidArgumentException('Expected value of `identifier` was not provided'); + } + + if (null === $this->cmsBlock) { + $this->cmsBlock = $this->blockByIdentifier->execute( + (string)$this->getIdentifier(), + $this->getCurrentStoreId() + ); + + if (!$this->cmsBlock->isActive()) { + throw new NoSuchEntityException( + __('The CMS block with identifier "%identifier" is not enabled.', $this->getIdentifier()) + ); + } + } + + return $this->cmsBlock; + } + + /** + * Returns the current Store ID + * + * @return int + * @throws \Magento\Framework\Exception\NoSuchEntityException + */ + private function getCurrentStoreId(): int + { + return (int)$this->storeManager->getStore()->getId(); + } + + /** + * Returns array of Block Identifiers used to determine Cache Tags + * + * This implementation supports different CMS blocks caching having the same identifier, + * resolving the bug introduced in scope of \Magento\Cms\Block\Block + * + * @return string[] + */ + public function getIdentities(): array + { + if (!$this->getIdentifier()) { + return []; + } + + $identities = [ + self::CACHE_KEY_PREFIX . '_' . $this->getIdentifier(), + self::CACHE_KEY_PREFIX . '_' . $this->getIdentifier() . '_' . $this->getCurrentStoreId() + ]; + + try { + $cmsBlock = $this->getCmsBlock(); + if ($cmsBlock instanceof IdentityInterface) { + $identities = array_merge($identities, $cmsBlock->getIdentities()); + } + // phpcs:ignore Magento2.CodeAnalysis.EmptyBlock.DetectedCatch + } catch (NoSuchEntityException $e) { + } + + return $identities; + } +} diff --git a/app/code/Magento/Cms/Command/WysiwygRestrictCommand.php b/app/code/Magento/Cms/Command/WysiwygRestrictCommand.php new file mode 100644 index 0000000000000..e676cb1fe0ee5 --- /dev/null +++ b/app/code/Magento/Cms/Command/WysiwygRestrictCommand.php @@ -0,0 +1,72 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +declare(strict_types=1); + +namespace Magento\Cms\Command; + +use Magento\Cms\Model\Wysiwyg\Validator; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Magento\Framework\App\Config\ConfigResource\ConfigInterface as ConfigWriter; +use Magento\Framework\App\Cache\TypeListInterface as Cache; + +/** + * Command to toggle WYSIWYG content validation on/off. + */ +class WysiwygRestrictCommand extends Command +{ + /** + * @var ConfigWriter + */ + private $configWriter; + + /** + * @var Cache + */ + private $cache; + + /** + * @param ConfigWriter $configWriter + * @param Cache $cache + */ + public function __construct(ConfigWriter $configWriter, Cache $cache) + { + parent::__construct(); + + $this->configWriter = $configWriter; + $this->cache = $cache; + } + + /** + * @inheritDoc + */ + protected function configure() + { + $this->setName('cms:wysiwyg:restrict'); + $this->setDescription('Set whether to enforce user HTML content validation or show a warning instead'); + $this->setDefinition([new InputArgument('restrict', InputArgument::REQUIRED, 'y\n')]); + + parent::configure(); + } + + /** + * @inheritDoc + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $restrictArg = mb_strtolower((string)$input->getArgument('restrict')); + $restrict = $restrictArg === 'y' ? '1' : '0'; + $this->configWriter->saveConfig(Validator::CONFIG_PATH_THROW_EXCEPTION, $restrict); + $this->cache->cleanType('config'); + + $output->writeln('HTML user content validation is now ' .($restrictArg === 'y' ? 'enforced' : 'suggested')); + + return 0; + } +} diff --git a/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Directive.php b/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Directive.php index a3370b2666264..5172ff8088bf8 100644 --- a/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Directive.php +++ b/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Directive.php @@ -13,6 +13,8 @@ use Magento\Cms\Model\Template\Filter; use Magento\Cms\Model\Wysiwyg\Config; use Magento\Framework\App\Action\HttpGetActionInterface; +use Magento\Framework\App\Filesystem\DirectoryList; +use Magento\Framework\Filesystem; use Magento\Framework\Image\Adapter\AdapterInterface; use Magento\Framework\Image\AdapterFactory; use Psr\Log\LoggerInterface; @@ -27,6 +29,7 @@ * Process template text for wysiwyg editor. * * Class Directive + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) usage of $this->file eliminated, but it's still there due to BC */ class Directive extends Action implements HttpGetActionInterface { @@ -70,8 +73,13 @@ class Directive extends Action implements HttpGetActionInterface /** * @var File + * @deprecated use $filesystem instead */ private $file; + /** + * @var Filesystem|null + */ + private $filesystem; /** * Constructor @@ -84,6 +92,7 @@ class Directive extends Action implements HttpGetActionInterface * @param Config|null $config * @param Filter|null $filter * @param File|null $file + * @param Filesystem|null $filesystem */ public function __construct( Context $context, @@ -93,7 +102,8 @@ public function __construct( LoggerInterface $logger = null, Config $config = null, Filter $filter = null, - File $file = null + File $file = null, + Filesystem $filesystem = null ) { parent::__construct($context); $this->urlDecoder = $urlDecoder; @@ -103,17 +113,21 @@ public function __construct( $this->config = $config ?: ObjectManager::getInstance()->get(Config::class); $this->filter = $filter ?: ObjectManager::getInstance()->get(Filter::class); $this->file = $file ?: ObjectManager::getInstance()->get(File::class); + $this->filesystem = $filesystem ?: ObjectManager::getInstance()->get(Filesystem::class); } /** * Template directives callback * * @return Raw + * @throws \Magento\Framework\Exception\FileSystemException */ public function execute() { $directive = $this->getRequest()->getParam('___directive'); $directive = $this->urlDecoder->decode($directive); + $image = null; + $resultRaw = null; try { /** @var Filter $filter */ $imagePath = $this->filter->filter($directive); @@ -141,7 +155,8 @@ public function execute() // To avoid issues with PNG images with alpha blending we return raw file // after validation as an image source instead of generating the new PNG image // with image adapter - $content = $this->file->fileGetContents($imagePath); + $content = $this->filesystem->getDirectoryWrite(DirectoryList::MEDIA)->getDriver() + ->fileGetContents($imagePath); $resultRaw->setHeader('Content-Type', $mimeType); $resultRaw->setContents($content); diff --git a/app/code/Magento/Cms/Model/Block.php b/app/code/Magento/Cms/Model/Block.php index 9da444c72e80c..ab8d65399f37c 100644 --- a/app/code/Magento/Cms/Model/Block.php +++ b/app/code/Magento/Cms/Model/Block.php @@ -6,8 +6,15 @@ namespace Magento\Cms\Model; use Magento\Cms\Api\Data\BlockInterface; +use Magento\Framework\App\ObjectManager; use Magento\Framework\DataObject\IdentityInterface; use Magento\Framework\Model\AbstractModel; +use Magento\Framework\Validation\ValidationException; +use Magento\Framework\Validator\HTML\WYSIWYGValidatorInterface; +use Magento\Framework\Model\Context; +use Magento\Framework\Registry; +use Magento\Framework\Model\ResourceModel\AbstractResource; +use Magento\Framework\Data\Collection\AbstractDb; /** * CMS block model @@ -40,6 +47,32 @@ class Block extends AbstractModel implements BlockInterface, IdentityInterface */ protected $_eventPrefix = 'cms_block'; + /** + * @var WYSIWYGValidatorInterface + */ + private $wysiwygValidator; + + /** + * @param Context $context + * @param Registry $registry + * @param AbstractResource|null $resource + * @param AbstractDb|null $resourceCollection + * @param array $data + * @param WYSIWYGValidatorInterface|null $wysiwygValidator + */ + public function __construct( + Context $context, + Registry $registry, + AbstractResource $resource = null, + AbstractDb $resourceCollection = null, + array $data = [], + ?WYSIWYGValidatorInterface $wysiwygValidator = null + ) { + parent::__construct($context, $registry, $resource, $resourceCollection, $data); + $this->wysiwygValidator = $wysiwygValidator + ?? ObjectManager::getInstance()->get(WYSIWYGValidatorInterface::class); + } + /** * Construct. * @@ -63,12 +96,26 @@ public function beforeSave() } $needle = 'block_id="' . $this->getId() . '"'; - if (false == strstr($this->getContent(), (string) $needle)) { - return parent::beforeSave(); + if (strstr($this->getContent(), (string) $needle) !== false) { + throw new \Magento\Framework\Exception\LocalizedException( + __('Make sure that static block content does not reference the block itself.') + ); } - throw new \Magento\Framework\Exception\LocalizedException( - __('Make sure that static block content does not reference the block itself.') - ); + parent::beforeSave(); + + //Validating HTML content. + if ($this->getContent() && $this->getContent() !== $this->getOrigData(self::CONTENT)) { + try { + $this->wysiwygValidator->validate($this->getContent()); + } catch (ValidationException $exception) { + throw new ValidationException( + __('Content field contains restricted HTML elements. %1', $exception->getMessage()), + $exception + ); + } + } + + return $this; } /** diff --git a/app/code/Magento/Cms/Model/BlockRepository.php b/app/code/Magento/Cms/Model/BlockRepository.php index d0df0d2b31caa..ef57f8ca7b849 100644 --- a/app/code/Magento/Cms/Model/BlockRepository.php +++ b/app/code/Magento/Cms/Model/BlockRepository.php @@ -12,14 +12,16 @@ use Magento\Cms\Model\ResourceModel\Block\CollectionFactory as BlockCollectionFactory; use Magento\Framework\Api\DataObjectHelper; use Magento\Framework\Api\SearchCriteria\CollectionProcessorInterface; +use Magento\Framework\App\ObjectManager; use Magento\Framework\Exception\CouldNotDeleteException; use Magento\Framework\Exception\CouldNotSaveException; use Magento\Framework\Exception\NoSuchEntityException; use Magento\Framework\Reflection\DataObjectProcessor; use Magento\Store\Model\StoreManagerInterface; +use Magento\Framework\EntityManager\HydratorInterface; /** - * Class BlockRepository + * Default block repo impl. * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class BlockRepository implements BlockRepositoryInterface @@ -69,6 +71,11 @@ class BlockRepository implements BlockRepositoryInterface */ private $collectionProcessor; + /** + * @var HydratorInterface + */ + private $hydrator; + /** * @param ResourceBlock $resource * @param BlockFactory $blockFactory @@ -79,6 +86,9 @@ class BlockRepository implements BlockRepositoryInterface * @param DataObjectProcessor $dataObjectProcessor * @param StoreManagerInterface $storeManager * @param CollectionProcessorInterface $collectionProcessor + * @param HydratorInterface|null $hydrator + * + * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( ResourceBlock $resource, @@ -89,7 +99,8 @@ public function __construct( DataObjectHelper $dataObjectHelper, DataObjectProcessor $dataObjectProcessor, StoreManagerInterface $storeManager, - CollectionProcessorInterface $collectionProcessor = null + CollectionProcessorInterface $collectionProcessor = null, + ?HydratorInterface $hydrator = null ) { $this->resource = $resource; $this->blockFactory = $blockFactory; @@ -100,6 +111,7 @@ public function __construct( $this->dataObjectProcessor = $dataObjectProcessor; $this->storeManager = $storeManager; $this->collectionProcessor = $collectionProcessor ?: $this->getCollectionProcessor(); + $this->hydrator = $hydrator ?? ObjectManager::getInstance()->get(HydratorInterface::class); } /** @@ -115,6 +127,10 @@ public function save(Data\BlockInterface $block) $block->setStoreId($this->storeManager->getStore()->getId()); } + if ($block->getId() && $block instanceof Block && !$block->getOrigData()) { + $block = $this->hydrator->hydrate($this->getById($block->getId()), $this->hydrator->extract($block)); + } + try { $this->resource->save($block); } catch (\Exception $exception) { @@ -201,6 +217,7 @@ public function deleteById($blockId) */ private function getCollectionProcessor() { + //phpcs:disable Magento2.PHP.LiteralNamespaces if (!$this->collectionProcessor) { $this->collectionProcessor = \Magento\Framework\App\ObjectManager::getInstance()->get( 'Magento\Cms\Model\Api\SearchCriteria\BlockCollectionProcessor' diff --git a/app/code/Magento/Cms/Model/Page.php b/app/code/Magento/Cms/Model/Page.php index 28d013f45f1fa..7e3e3ff44cfa0 100644 --- a/app/code/Magento/Cms/Model/Page.php +++ b/app/code/Magento/Cms/Model/Page.php @@ -13,6 +13,8 @@ use Magento\Framework\DataObject\IdentityInterface; use Magento\Framework\Exception\LocalizedException; use Magento\Framework\Model\AbstractModel; +use Magento\Framework\Validation\ValidationException; +use Magento\Framework\Validator\HTML\WYSIWYGValidatorInterface; /** * Cms Page Model @@ -21,12 +23,13 @@ * @method Page setStoreId(int $storeId) * @method int getStoreId() * @SuppressWarnings(PHPMD.ExcessivePublicCount) + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) * @since 100.0.2 */ class Page extends AbstractModel implements PageInterface, IdentityInterface { /** - * No route page id + * Page ID for the 404 page. */ const NOROUTE_PAGE_ID = 'no-route'; @@ -64,6 +67,11 @@ class Page extends AbstractModel implements PageInterface, IdentityInterface */ private $customLayoutRepository; + /** + * @var WYSIWYGValidatorInterface + */ + private $wysiwygValidator; + /** * @param \Magento\Framework\Model\Context $context * @param \Magento\Framework\Registry $registry @@ -71,6 +79,7 @@ class Page extends AbstractModel implements PageInterface, IdentityInterface * @param \Magento\Framework\Data\Collection\AbstractDb|null $resourceCollection * @param array $data * @param CustomLayoutRepository|null $customLayoutRepository + * @param WYSIWYGValidatorInterface|null $wysiwygValidator */ public function __construct( \Magento\Framework\Model\Context $context, @@ -78,11 +87,14 @@ public function __construct( \Magento\Framework\Model\ResourceModel\AbstractResource $resource = null, \Magento\Framework\Data\Collection\AbstractDb $resourceCollection = null, array $data = [], - ?CustomLayoutRepository $customLayoutRepository = null + ?CustomLayoutRepository $customLayoutRepository = null, + ?WYSIWYGValidatorInterface $wysiwygValidator = null ) { parent::__construct($context, $registry, $resource, $resourceCollection, $data); $this->customLayoutRepository = $customLayoutRepository ?? ObjectManager::getInstance()->get(CustomLayoutRepository::class); + $this->wysiwygValidator = $wysiwygValidator + ?? ObjectManager::getInstance()->get(WYSIWYGValidatorInterface::class); } /** @@ -594,6 +606,8 @@ private function validateNewIdentifier(): void /** * @inheritdoc * @since 101.0.0 + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.NPathComplexity) */ public function beforeSave() { @@ -615,6 +629,26 @@ public function beforeSave() $this->setData('layout_update_selected', $layoutUpdate); $this->customLayoutRepository->validateLayoutSelectedFor($this); + //Validating Content HTML. + $oldValue = null; + if ($this->getId()) { + if ($this->getOrigData()) { + $oldValue = $this->getOrigData(self::CONTENT); + } elseif (array_key_exists(self::CONTENT, $this->getStoredData())) { + $oldValue = $this->getStoredData()[self::CONTENT]; + } + } + if ($this->getContent() && $this->getContent() !== $oldValue) { + try { + $this->wysiwygValidator->validate($this->getContent()); + } catch (ValidationException $exception) { + throw new ValidationException( + __('Content HTML contains restricted elements. %1', $exception->getMessage()), + $exception + ); + } + } + return parent::beforeSave(); } diff --git a/app/code/Magento/Cms/Model/PageRepository.php b/app/code/Magento/Cms/Model/PageRepository.php index 0439fbcd2f799..7e84b93f5a1e6 100644 --- a/app/code/Magento/Cms/Model/PageRepository.php +++ b/app/code/Magento/Cms/Model/PageRepository.php @@ -7,22 +7,29 @@ namespace Magento\Cms\Model; use Magento\Cms\Api\Data; +use Magento\Cms\Api\Data\PageInterface; +use Magento\Cms\Api\Data\PageInterfaceFactory; +use Magento\Cms\Api\Data\PageSearchResultsInterface; use Magento\Cms\Api\PageRepositoryInterface; +use Magento\Cms\Model\Api\SearchCriteria\PageCollectionProcessor; use Magento\Cms\Model\Page\IdentityMap; use Magento\Cms\Model\ResourceModel\Page as ResourcePage; use Magento\Cms\Model\ResourceModel\Page\CollectionFactory as PageCollectionFactory; use Magento\Framework\Api\DataObjectHelper; use Magento\Framework\Api\SearchCriteria\CollectionProcessorInterface; +use Magento\Framework\Api\SearchCriteriaInterface; use Magento\Framework\App\ObjectManager; use Magento\Framework\EntityManager\HydratorInterface; +use Magento\Framework\App\Route\Config; use Magento\Framework\Exception\CouldNotDeleteException; use Magento\Framework\Exception\CouldNotSaveException; +use Magento\Framework\Exception\LocalizedException; use Magento\Framework\Exception\NoSuchEntityException; use Magento\Framework\Reflection\DataObjectProcessor; use Magento\Store\Model\StoreManagerInterface; /** - * @inheritdoc + * Cms page repository * * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ @@ -59,12 +66,12 @@ class PageRepository implements PageRepositoryInterface protected $dataObjectProcessor; /** - * @var \Magento\Cms\Api\Data\PageInterfaceFactory + * @var PageInterfaceFactory */ protected $dataPageFactory; /** - * @var \Magento\Store\Model\StoreManagerInterface + * @var StoreManagerInterface */ private $storeManager; @@ -83,10 +90,15 @@ class PageRepository implements PageRepositoryInterface */ private $hydrator; + /** + * @var Config + */ + private $routeConfig; + /** * @param ResourcePage $resource * @param PageFactory $pageFactory - * @param Data\PageInterfaceFactory $dataPageFactory + * @param PageInterfaceFactory $dataPageFactory * @param PageCollectionFactory $pageCollectionFactory * @param Data\PageSearchResultsInterfaceFactory $searchResultsFactory * @param DataObjectHelper $dataObjectHelper @@ -95,12 +107,13 @@ class PageRepository implements PageRepositoryInterface * @param CollectionProcessorInterface $collectionProcessor * @param IdentityMap|null $identityMap * @param HydratorInterface|null $hydrator + * @param Config|null $routeConfig * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( ResourcePage $resource, PageFactory $pageFactory, - Data\PageInterfaceFactory $dataPageFactory, + PageInterfaceFactory $dataPageFactory, PageCollectionFactory $pageCollectionFactory, Data\PageSearchResultsInterfaceFactory $searchResultsFactory, DataObjectHelper $dataObjectHelper, @@ -108,7 +121,8 @@ public function __construct( StoreManagerInterface $storeManager, CollectionProcessorInterface $collectionProcessor = null, ?IdentityMap $identityMap = null, - ?HydratorInterface $hydrator = null + ?HydratorInterface $hydrator = null, + ?Config $routeConfig = null ) { $this->resource = $resource; $this->pageFactory = $pageFactory; @@ -119,29 +133,39 @@ public function __construct( $this->dataObjectProcessor = $dataObjectProcessor; $this->storeManager = $storeManager; $this->collectionProcessor = $collectionProcessor ?: $this->getCollectionProcessor(); - $this->identityMap = $identityMap ?? ObjectManager::getInstance()->get(IdentityMap::class); - $this->hydrator = $hydrator ?: ObjectManager::getInstance()->get(HydratorInterface::class); + $this->identityMap = $identityMap ?? ObjectManager::getInstance() + ->get(IdentityMap::class); + $this->hydrator = $hydrator ?: ObjectManager::getInstance() + ->get(HydratorInterface::class); + $this->routeConfig = $routeConfig ?? ObjectManager::getInstance() + ->get(Config::class); } /** * Validate new layout update values. * - * @param Data\PageInterface $page + * @param PageInterface $page * @return void * @throws \InvalidArgumentException */ - private function validateLayoutUpdate(Data\PageInterface $page): void + private function validateLayoutUpdate(PageInterface $page): void { //Persisted data - $savedPage = $page->getId() ? $this->getById($page->getId()) : null; + $oldData = null; + if ($page->getId() && $page instanceof Page) { + $oldData = $page->getOrigData(); + } //Custom layout update can be removed or kept as is. if ($page->getCustomLayoutUpdateXml() - && (!$savedPage || $page->getCustomLayoutUpdateXml() !== $savedPage->getCustomLayoutUpdateXml()) + && ( + !$oldData + || $page->getCustomLayoutUpdateXml() !== $oldData[Data\PageInterface::CUSTOM_LAYOUT_UPDATE_XML] + ) ) { throw new \InvalidArgumentException('Custom layout updates must be selected from a file'); } if ($page->getLayoutUpdateXml() - && (!$savedPage || $page->getLayoutUpdateXml() !== $savedPage->getLayoutUpdateXml()) + && (!$oldData || $page->getLayoutUpdateXml() !== $oldData[Data\PageInterface::LAYOUT_UPDATE_XML]) ) { throw new \InvalidArgumentException('Custom layout updates must be selected from a file'); } @@ -150,30 +174,35 @@ private function validateLayoutUpdate(Data\PageInterface $page): void /** * Save Page data * - * @param \Magento\Cms\Api\Data\PageInterface|Page $page + * @param PageInterface|Page $page * @return Page * @throws CouldNotSaveException */ - public function save(\Magento\Cms\Api\Data\PageInterface $page) + public function save(PageInterface $page) { - if ($page->getStoreId() === null) { - $storeId = $this->storeManager->getStore()->getId(); - $page->setStoreId($storeId); - } - $pageId = $page->getId(); - try { - $this->validateLayoutUpdate($page); - if ($pageId) { + $pageId = $page->getId(); + if ($pageId && !($page instanceof Page && $page->getOrigData())) { $page = $this->hydrator->hydrate($this->getById($pageId), $this->hydrator->extract($page)); } + if ($page->getStoreId() === null) { + $storeId = $this->storeManager->getStore()->getId(); + $page->setStoreId($storeId); + } + $this->validateLayoutUpdate($page); + $this->validateRoutesDuplication($page); $this->resource->save($page); $this->identityMap->add($page); - } catch (\Exception $exception) { + } catch (LocalizedException $exception) { throw new CouldNotSaveException( __('Could not save the page: %1', $exception->getMessage()), $exception ); + } catch (\Throwable $exception) { + throw new CouldNotSaveException( + __('Could not save the page: %1', __('Something went wrong while saving the page.')), + $exception + ); } return $page; } @@ -183,7 +212,7 @@ public function save(\Magento\Cms\Api\Data\PageInterface $page) * * @param string $pageId * @return Page - * @throws \Magento\Framework\Exception\NoSuchEntityException + * @throws NoSuchEntityException */ public function getById($pageId) { @@ -202,17 +231,15 @@ public function getById($pageId) * * @SuppressWarnings(PHPMD.CyclomaticComplexity) * @SuppressWarnings(PHPMD.NPathComplexity) - * @param \Magento\Framework\Api\SearchCriteriaInterface $criteria - * @return \Magento\Cms\Api\Data\PageSearchResultsInterface + * @param SearchCriteriaInterface $criteria + * @return PageSearchResultsInterface */ - public function getList(\Magento\Framework\Api\SearchCriteriaInterface $criteria) + public function getList(SearchCriteriaInterface $criteria) { - /** @var \Magento\Cms\Model\ResourceModel\Page\Collection $collection */ $collection = $this->pageCollectionFactory->create(); $this->collectionProcessor->process($criteria, $collection); - /** @var Data\PageSearchResultsInterface $searchResults */ $searchResults = $this->searchResultsFactory->create(); $searchResults->setSearchCriteria($criteria); $searchResults->setItems($collection->getItems()); @@ -223,11 +250,11 @@ public function getList(\Magento\Framework\Api\SearchCriteriaInterface $criteria /** * Delete Page * - * @param \Magento\Cms\Api\Data\PageInterface $page + * @param PageInterface $page * @return bool * @throws CouldNotDeleteException */ - public function delete(\Magento\Cms\Api\Data\PageInterface $page) + public function delete(PageInterface $page) { try { $this->resource->delete($page); @@ -262,11 +289,26 @@ public function deleteById($pageId) private function getCollectionProcessor() { if (!$this->collectionProcessor) { - $this->collectionProcessor = \Magento\Framework\App\ObjectManager::getInstance()->get( - // phpstan:ignore "Class Magento\Cms\Model\Api\SearchCriteria\PageCollectionProcessor not found." - \Magento\Cms\Model\Api\SearchCriteria\PageCollectionProcessor::class - ); + // phpstan:ignore "Class Magento\Cms\Model\Api\SearchCriteria\PageCollectionProcessor not found." + $this->collectionProcessor = ObjectManager::getInstance() + ->get(PageCollectionProcessor::class); } return $this->collectionProcessor; } + + /** + * Checks that page identifier doesn't duplicate existed routes + * + * @param PageInterface $page + * @return void + * @throws CouldNotSaveException + */ + private function validateRoutesDuplication($page): void + { + if ($this->routeConfig->getRouteByFrontName($page->getIdentifier(), 'frontend')) { + throw new CouldNotSaveException( + __('The value specified in the URL Key field would generate a URL that already exists.') + ); + } + } } diff --git a/app/code/Magento/Cms/Model/PageRepository/ValidationComposite.php b/app/code/Magento/Cms/Model/PageRepository/ValidationComposite.php index 9fd94d4c11e1c..fe8817f5f40b4 100644 --- a/app/code/Magento/Cms/Model/PageRepository/ValidationComposite.php +++ b/app/code/Magento/Cms/Model/PageRepository/ValidationComposite.php @@ -11,6 +11,8 @@ use Magento\Cms\Api\Data\PageInterface; use Magento\Cms\Api\PageRepositoryInterface; use Magento\Framework\Api\SearchCriteriaInterface; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\EntityManager\HydratorInterface; /** * Validates and saves a page @@ -27,13 +29,20 @@ class ValidationComposite implements PageRepositoryInterface */ private $validators; + /** + * @var HydratorInterface + */ + private $hydrator; + /** * @param PageRepositoryInterface $repository * @param ValidatorInterface[] $validators + * @param HydratorInterface|null $hydrator */ public function __construct( PageRepositoryInterface $repository, - array $validators = [] + array $validators = [], + ?HydratorInterface $hydrator = null ) { foreach ($validators as $validator) { if (!$validator instanceof ValidatorInterface) { @@ -44,6 +53,7 @@ public function __construct( } $this->repository = $repository; $this->validators = $validators; + $this->hydrator = $hydrator ?? ObjectManager::getInstance()->get(HydratorInterface::class); } /** @@ -51,6 +61,9 @@ public function __construct( */ public function save(PageInterface $page) { + if ($page->getId()) { + $page = $this->hydrator->hydrate($this->getById($page->getId()), $this->hydrator->extract($page)); + } foreach ($this->validators as $validator) { $validator->validate($page); } diff --git a/app/code/Magento/Cms/Model/Wysiwyg/Images/Storage.php b/app/code/Magento/Cms/Model/Wysiwyg/Images/Storage.php index ae88b24bd2682..2c94e2e76914f 100644 --- a/app/code/Magento/Cms/Model/Wysiwyg/Images/Storage.php +++ b/app/code/Magento/Cms/Model/Wysiwyg/Images/Storage.php @@ -22,6 +22,7 @@ * @SuppressWarnings(PHPMD.TooManyFields) * @SuppressWarnings(PHPMD.CouplingBetweenObjects) * @SuppressWarnings(PHPMD.CookieAndSessionMisuse) + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) * * @api * @since 100.0.2 @@ -152,6 +153,11 @@ class Storage extends \Magento\Framework\DataObject */ private $ioFile; + /** + * @var \Magento\Framework\File\Mime|null + */ + private $mime; + /** * Construct * @@ -174,6 +180,7 @@ class Storage extends \Magento\Framework\DataObject * @param \Magento\Framework\Filesystem\DriverInterface $file * @param \Magento\Framework\Filesystem\Io\File|null $ioFile * @param \Psr\Log\LoggerInterface|null $logger + * @param \Magento\Framework\File\Mime $mime * * @throws \Magento\Framework\Exception\FileSystemException * @SuppressWarnings(PHPMD.ExcessiveParameterList) @@ -197,7 +204,8 @@ public function __construct( array $data = [], \Magento\Framework\Filesystem\DriverInterface $file = null, \Magento\Framework\Filesystem\Io\File $ioFile = null, - \Psr\Log\LoggerInterface $logger = null + \Psr\Log\LoggerInterface $logger = null, + \Magento\Framework\File\Mime $mime = null ) { $this->_session = $session; $this->_backendUrl = $backendUrl; @@ -217,6 +225,7 @@ public function __construct( $this->_dirs = $dirs; $this->file = $file ?: ObjectManager::getInstance()->get(\Magento\Framework\Filesystem\Driver\File::class); $this->ioFile = $ioFile ?: ObjectManager::getInstance()->get(\Magento\Framework\Filesystem\Io\File::class); + $this->mime = $mime ?: ObjectManager::getInstance()->get(\Magento\Framework\File\Mime::class); parent::__construct($data); } @@ -354,15 +363,16 @@ public function getFilesCollection($path, $type = null) $collection->setFilesFilter('/\.(' . implode('|', $allowed) . ')$/i'); } - // prepare items foreach ($collection as $item) { $item->setId($this->_cmsWysiwygImages->idEncode($item->getBasename())); $item->setName($item->getBasename()); $item->setShortName($this->_cmsWysiwygImages->getShortFilename($item->getBasename())); $item->setUrl($this->_cmsWysiwygImages->getCurrentUrl() . $item->getBasename()); - $itemStats = $this->file->stat($item->getFilename()); + $driver = $this->_directory->getDriver(); + $itemStats = $driver->stat($item->getFilename()); $item->setSize($itemStats['size']); - $item->setMimeType(\mime_content_type($item->getFilename())); + $mimeType = $itemStats['mimetype'] ?? $this->mime->getMimeType($item->getFilename()); + $item->setMimeType($mimeType); if ($this->isImage($item->getBasename())) { $thumbUrl = $this->getThumbnailUrl($item->getFilename(), true); @@ -372,7 +382,9 @@ public function getFilesCollection($path, $type = null) } try { - $size = getimagesize($item->getFilename()); + $size = getimagesizefromstring( + $driver->fileGetContents($item->getFilename()) + ); if (is_array($size)) { $item->setWidth($size[0]); @@ -429,7 +441,7 @@ public function createDirectory($name, $path) $path = $this->_cmsWysiwygImages->getStorageRoot(); } - $newPath = $path . '/' . $name; + $newPath = rtrim($path, '/') . '/' . $name; $relativeNewPath = $this->_directory->getRelativePath($newPath); if ($this->_directory->isDirectory($relativeNewPath)) { throw new \Magento\Framework\Exception\LocalizedException( @@ -562,7 +574,7 @@ public function uploadFile($targetPath, $type = null) } // create thumbnail - $this->resizeFile($targetPath . '/' . $uploader->getUploadedFileName(), true); + $this->resizeFile($targetPath . '/' . ltrim($uploader->getUploadedFileName(), '/'), true); return $result; } @@ -646,8 +658,8 @@ public function resizeFile($source, $keepRatio = true) $image->keepAspectRatio($keepRatio); - list($imageWidth, $imageHeight) = $this->getResizedParams($source); - + [$imageWidth, $imageHeight] = $this->getResizedParams($source); + $image->resize($imageWidth, $imageHeight); $dest = $targetDir . '/' . $this->ioFile->getPathInfo($source)['basename']; $image->save($dest); @@ -669,8 +681,8 @@ private function getResizedParams(string $source): array $configHeight = $this->_resizeParameters['height']; //phpcs:ignore Generic.PHP.NoSilencedErrors - list($imageWidth, $imageHeight) = @getimagesize($source); - + [$imageWidth, $imageHeight] = @getimagesize($source); + if ($imageWidth && $imageHeight) { $imageWidth = $configWidth > $imageWidth ? $imageWidth : $configWidth; $imageHeight = $configHeight > $imageHeight ? $imageHeight : $configHeight; @@ -679,7 +691,7 @@ private function getResizedParams(string $source): array } return [$configWidth, $configHeight]; } - + /** * Resize images on the fly in controller action * @@ -750,7 +762,7 @@ public function getAllowedExtensions($type = null) */ public function getThumbnailRoot() { - return $this->_cmsWysiwygImages->getStorageRoot() . '/' . self::THUMBS_DIRECTORY_NAME; + return rtrim($this->_cmsWysiwygImages->getStorageRoot(), '/') . '/' . self::THUMBS_DIRECTORY_NAME; } /** @@ -835,7 +847,7 @@ protected function _sanitizePath($path) { return rtrim( preg_replace( - '~[/\\\]+~', + '~[/\\\]+(?<![htps?]://)~', '/', $this->_directory->getDriver()->getRealPathSafety( $this->_directory->getAbsolutePath($path) diff --git a/app/code/Magento/Cms/Model/Wysiwyg/Images/Storage/Collection.php b/app/code/Magento/Cms/Model/Wysiwyg/Images/Storage/Collection.php index f66c0f6b06d91..617c8663d6f80 100644 --- a/app/code/Magento/Cms/Model/Wysiwyg/Images/Storage/Collection.php +++ b/app/code/Magento/Cms/Model/Wysiwyg/Images/Storage/Collection.php @@ -30,7 +30,7 @@ public function __construct( \Magento\Framework\Filesystem $filesystem ) { $this->_filesystem = $filesystem; - parent::__construct($entityFactory); + parent::__construct($entityFactory, $filesystem); } /** @@ -41,10 +41,11 @@ public function __construct( */ protected function _generateRow($filename) { - $filename = preg_replace('~[/\\\]+~', '/', $filename); + $filename = preg_replace('~[/\\\]+(?<![htps?]://)~', '/', $filename); $path = $this->_filesystem->getDirectoryWrite(DirectoryList::MEDIA); return [ - 'filename' => $filename, + 'filename' => rtrim($filename, '/'), + // phpcs:ignore Magento2.Functions.DiscouragedFunction 'basename' => basename($filename), 'mtime' => $path->stat($path->getRelativePath($filename))['mtime'] ]; diff --git a/app/code/Magento/Cms/Model/Wysiwyg/Validator.php b/app/code/Magento/Cms/Model/Wysiwyg/Validator.php new file mode 100644 index 0000000000000..39360e6350967 --- /dev/null +++ b/app/code/Magento/Cms/Model/Wysiwyg/Validator.php @@ -0,0 +1,112 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +declare(strict_types=1); + +namespace Magento\Cms\Model\Wysiwyg; + +use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Framework\Message\ManagerInterface; +use Magento\Framework\Message\MessageInterface; +use Magento\Framework\Validation\ValidationException; +use Magento\Framework\Validator\HTML\WYSIWYGValidatorInterface; +use Psr\Log\LoggerInterface; +use Magento\Framework\Message\Factory as MessageFactory; + +/** + * Processes backend validator results. + */ +class Validator implements WYSIWYGValidatorInterface +{ + public const CONFIG_PATH_THROW_EXCEPTION = 'cms/wysiwyg/force_valid'; + + /** + * @var WYSIWYGValidatorInterface + */ + private $validator; + + /** + * @var ManagerInterface + */ + private $messages; + + /** + * @var ScopeConfigInterface + */ + private $config; + + /** + * @var LoggerInterface + */ + private $logger; + + /** + * @var MessageFactory + */ + private $messageFactory; + + /** + * @param WYSIWYGValidatorInterface $validator + * @param ManagerInterface $messages + * @param ScopeConfigInterface $config + * @param LoggerInterface $logger + * @param MessageFactory $messageFactory + */ + public function __construct( + WYSIWYGValidatorInterface $validator, + ManagerInterface $messages, + ScopeConfigInterface $config, + LoggerInterface $logger, + MessageFactory $messageFactory + ) { + $this->validator = $validator; + $this->messages = $messages; + $this->config = $config; + $this->logger = $logger; + $this->messageFactory = $messageFactory; + } + + /** + * @inheritDoc + */ + public function validate(string $content): void + { + $throwException = $this->config->isSetFlag(self::CONFIG_PATH_THROW_EXCEPTION); + try { + $this->validator->validate($content); + } catch (ValidationException $exception) { + if ($throwException) { + throw $exception; + } else { + $this->messages->addUniqueMessages( + [ + $this->messageFactory->create( + MessageInterface::TYPE_WARNING, + (string)__( + 'Temporarily allowed to save HTML value that contains restricted elements. %1', + $exception->getMessage() + ) + ) + ] + ); + } + } catch (\Throwable $exception) { + if ($throwException) { + throw $exception; + } else { + $this->messages->addUniqueMessages( + [ + $this->messageFactory->create( + MessageInterface::TYPE_WARNING, + (string)__('Invalid HTML provided') + ) + ] + ); + $this->logger->error($exception); + } + } + } +} diff --git a/app/code/Magento/Cms/Test/Mftf/ActionGroup/AdminCmsPageFillOutBasicFieldsActionGroup.xml b/app/code/Magento/Cms/Test/Mftf/ActionGroup/AdminCmsPageFillOutBasicFieldsActionGroup.xml new file mode 100644 index 0000000000000..52b4cee37b03c --- /dev/null +++ b/app/code/Magento/Cms/Test/Mftf/ActionGroup/AdminCmsPageFillOutBasicFieldsActionGroup.xml @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminCmsPageFillOutBasicFieldsActionGroup"> + <annotations> + <description>Fills out the Page details (Page Title, Content and URL Key) on the Admin Page creation/edit page.</description> + </annotations> + <arguments> + <argument name="title" type="string" defaultValue="{{_defaultCmsPage.title}}"/> + <argument name="contentHeading" type="string" defaultValue="{{_defaultCmsPage.content_heading}}"/> + <argument name="content" type="string" defaultValue="{{_defaultCmsPage.content}}"/> + <argument name="urlKey" type="string" defaultValue="{{_defaultCmsPage.identifier}}"/> + </arguments> + + <fillField selector="{{CmsNewPagePageBasicFieldsSection.pageTitle}}" userInput="{{title}}" stepKey="fillTitle"/> + <conditionalClick selector="{{CmsNewPagePageContentSection.header}}" dependentSelector="{{CmsNewPagePageContentSection.contentHeading}}" visible="false" stepKey="expandContentTabIfCollapsed"/> + <fillField selector="{{CmsNewPagePageContentSection.contentHeading}}" userInput="{{contentHeading}}" stepKey="fillContentHeading"/> + <scrollTo selector="{{CmsNewPagePageContentSection.content}}" stepKey="scrollToPageContent"/> + <fillField selector="{{CmsNewPagePageContentSection.content}}" userInput="{{content}}" stepKey="fillContent"/> + <conditionalClick selector="{{CmsNewPagePageSeoSection.header}}" dependentSelector="{{CmsNewPagePageSeoSection.urlKey}}" visible="false" stepKey="clickExpandSearchEngineOptimisationIfCollapsed"/> + <fillField selector="{{CmsNewPagePageSeoSection.urlKey}}" userInput="{{urlKey}}" stepKey="fillUrlKey"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Cms/Test/Mftf/ActionGroup/AdminDeleteCMSBlockFromGridActionGroup.xml b/app/code/Magento/Cms/Test/Mftf/ActionGroup/AdminDeleteCMSBlockFromGridActionGroup.xml new file mode 100644 index 0000000000000..a61f565bac2bc --- /dev/null +++ b/app/code/Magento/Cms/Test/Mftf/ActionGroup/AdminDeleteCMSBlockFromGridActionGroup.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminDeleteCMSBlockFromGridActionGroup"> + <arguments> + <argument name="identifier" type="entity"/> + </arguments> + <click selector="{{BlockPageActionsSection.select(identifier)}}" stepKey="clickSelect"/> + <click selector="{{BlockPageActionsSection.delete(identifier)}}" stepKey="clickDelete"/> + <waitForElementVisible selector="{{BlockPageActionsSection.deleteConfirm}}" stepKey="waitForOkButtonToBeVisible"/> + <click selector="{{BlockPageActionsSection.deleteConfirm}}" stepKey="clickOkButton"/> + <waitForPageLoad stepKey="waitForPageLoad"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Cms/Test/Mftf/ActionGroup/AdminDisableWYSIWYGActionGroup.xml b/app/code/Magento/Cms/Test/Mftf/ActionGroup/AdminDisableWYSIWYGActionGroup.xml index 7e035a47824ee..8407860959184 100644 --- a/app/code/Magento/Cms/Test/Mftf/ActionGroup/AdminDisableWYSIWYGActionGroup.xml +++ b/app/code/Magento/Cms/Test/Mftf/ActionGroup/AdminDisableWYSIWYGActionGroup.xml @@ -13,6 +13,6 @@ <description>Runs bin/magento command to disable WYSIWYG</description> </annotations> - <magentoCLI stepKey="disableWYSIWYG" command="config:set cms/wysiwyg/enabled disabled"/> + <magentoCLI command="config:set {{WysiwygDisabledByDefault.path}} {{WysiwygDisabledByDefault.value}}" stepKey="disableWYSIWYG"/> </actionGroup> </actionGroups> diff --git a/app/code/Magento/Cms/Test/Mftf/ActionGroup/AdminEnableWYSIWYGActionGroup.xml b/app/code/Magento/Cms/Test/Mftf/ActionGroup/AdminEnableWYSIWYGActionGroup.xml index 6c9b439e2941b..58c219092d85e 100644 --- a/app/code/Magento/Cms/Test/Mftf/ActionGroup/AdminEnableWYSIWYGActionGroup.xml +++ b/app/code/Magento/Cms/Test/Mftf/ActionGroup/AdminEnableWYSIWYGActionGroup.xml @@ -13,6 +13,6 @@ <description>Runs bin/magento command to enable WYSIWYG</description> </annotations> - <magentoCLI stepKey="enableWYSIWYG" command="config:set cms/wysiwyg/enabled enabled"/> + <magentoCLI command="config:set {{WysiwygEnabledByDefault.path}} {{WysiwygEnabledByDefault.value}}" stepKey="enableWYSIWYG"/> </actionGroup> </actionGroups> diff --git a/app/code/Magento/Cms/Test/Mftf/ActionGroup/AdminNavigateToPageGridActionGroup.xml b/app/code/Magento/Cms/Test/Mftf/ActionGroup/AdminNavigateToPageGridActionGroup.xml index 7dc68e7a5a891..6128db33a2afe 100644 --- a/app/code/Magento/Cms/Test/Mftf/ActionGroup/AdminNavigateToPageGridActionGroup.xml +++ b/app/code/Magento/Cms/Test/Mftf/ActionGroup/AdminNavigateToPageGridActionGroup.xml @@ -8,12 +8,12 @@ <actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> - <actionGroup name="AdminNavigateToPageGridActionGroup"> + <actionGroup name="AdminNavigateToPageGridActionGroup" deprecated="Use AdminOpenCMSPagesGridActionGroup instead."> <annotations> <description>Navigates to CMS page grid.</description> </annotations> <amOnPage url="{{CmsPagesPage.url}}" stepKey="amOnPagePagesGrid"/> - <waitForPageLoad stepKey="waitForPageLoad1"/> + <waitForPageLoad stepKey="waitForPageLoad"/> </actionGroup> </actionGroups> diff --git a/app/code/Magento/Cms/Test/Mftf/ActionGroup/AdminOpenCmsBlocksGridActionGroup.xml b/app/code/Magento/Cms/Test/Mftf/ActionGroup/AdminOpenCmsBlocksGridActionGroup.xml new file mode 100644 index 0000000000000..4b57e0c1274f6 --- /dev/null +++ b/app/code/Magento/Cms/Test/Mftf/ActionGroup/AdminOpenCmsBlocksGridActionGroup.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminOpenCmsBlocksGridActionGroup"> + <annotations> + <description>Goes to the Cms Blocks grid page.</description> + </annotations> + <amOnPage url="{{CmsBlocksPage.url}}" stepKey="navigateToCMSBlocksGrid"/> + <waitForPageLoad stepKey="waitForPageLoad"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Cms/Test/Mftf/ActionGroup/AdminPressAddNewCmsBlockButtonActionGroup.xml b/app/code/Magento/Cms/Test/Mftf/ActionGroup/AdminPressAddNewCmsBlockButtonActionGroup.xml new file mode 100644 index 0000000000000..17fc1cd7bdc50 --- /dev/null +++ b/app/code/Magento/Cms/Test/Mftf/ActionGroup/AdminPressAddNewCmsBlockButtonActionGroup.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminPressAddNewCmsBlockButtonActionGroup"> + <annotations> + <description>Press Add new block button on Cms Blocks gid page</description> + </annotations> + + <click selector="{{BlockPageActionsSection.addNewBlock}}" stepKey="clickOnAddNewBlockButton"/> + <waitForPageLoad stepKey="waitForPageLoad"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Cms/Test/Mftf/ActionGroup/AdminPressSaveCmsBlockButtonActionGroup.xml b/app/code/Magento/Cms/Test/Mftf/ActionGroup/AdminPressSaveCmsBlockButtonActionGroup.xml new file mode 100644 index 0000000000000..a45e5ed6b9fbf --- /dev/null +++ b/app/code/Magento/Cms/Test/Mftf/ActionGroup/AdminPressSaveCmsBlockButtonActionGroup.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminPressSaveCmsBlockButtonActionGroup"> + <annotations> + <description>Press save button on Cms Block page</description> + </annotations> + + <click selector="{{BlockNewPagePageActionsSection.saveBlock}}" stepKey="clickOnSaveBlock"/> + <waitForPageLoad stepKey="waitForPageLoad"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Cms/Test/Mftf/ActionGroup/AdminSaveAndContinueEditCmsPageActionGroup.xml b/app/code/Magento/Cms/Test/Mftf/ActionGroup/AdminSaveAndContinueEditCmsPageActionGroup.xml new file mode 100644 index 0000000000000..5c8b390b59ba0 --- /dev/null +++ b/app/code/Magento/Cms/Test/Mftf/ActionGroup/AdminSaveAndContinueEditCmsPageActionGroup.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminSaveAndContinueEditCmsPageActionGroup"> + <annotations> + <description>Clicks on the Save and Continue button and see success message.</description> + </annotations> + + <scrollToTopOfPage stepKey="scrollToTopOfThePage"/> + <waitForElementVisible selector="{{CmsNewPagePageActionsSection.saveAndContinueEdit}}" stepKey="waitForSaveAndContinueButton"/> + <click selector="{{CmsNewPagePageActionsSection.saveAndContinueEdit}}" stepKey="clickSaveAndContinueButton"/> + <waitForElementVisible selector="{{AdminMessagesSection.success}}" stepKey="waitForSuccessMessage"/> + <see selector="{{AdminMessagesSection.success}}" userInput="You saved the page." stepKey="seeSuccessMessage"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Cms/Test/Mftf/ActionGroup/AdminSearchCMSBlockInGridByIdentifierActionGroup.xml b/app/code/Magento/Cms/Test/Mftf/ActionGroup/AdminSearchCMSBlockInGridByIdentifierActionGroup.xml new file mode 100644 index 0000000000000..1099cd7e753c9 --- /dev/null +++ b/app/code/Magento/Cms/Test/Mftf/ActionGroup/AdminSearchCMSBlockInGridByIdentifierActionGroup.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminSearchCMSBlockInGridByIdentifierActionGroup"> + <arguments> + <argument name="identifier" type="string"/> + </arguments> + <click selector="{{BlockPageActionsSection.FilterBtn}}" stepKey="clickFilterButton"/> + <fillField selector="{{BlockPageActionsSection.URLKey}}" userInput="{{identifier}}" stepKey="fillIdentifierField"/> + <click selector="{{BlockPageActionsSection.ApplyFiltersBtn}}" stepKey="clickApplyFiltersButton"/> + <waitForPageLoad stepKey="waitForPageLoading"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Cms/Test/Mftf/ActionGroup/AdminSelectCMSBlockStoreViewActionGroup.xml b/app/code/Magento/Cms/Test/Mftf/ActionGroup/AdminSelectCMSBlockStoreViewActionGroup.xml new file mode 100644 index 0000000000000..e5b8caf1e209f --- /dev/null +++ b/app/code/Magento/Cms/Test/Mftf/ActionGroup/AdminSelectCMSBlockStoreViewActionGroup.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminSelectCMSBlockStoreViewActionGroup"> + <arguments> + <argument name="storeViewName" type="string"/> + </arguments> + + <selectOption selector="{{BlockNewPageBasicFieldsSection.storeView}}" userInput="{{storeViewName}}" stepKey="selectStoreView"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Cms/Test/Mftf/ActionGroup/AssertAdminCMSBlockIsNotInGridActionGroup.xml b/app/code/Magento/Cms/Test/Mftf/ActionGroup/AssertAdminCMSBlockIsNotInGridActionGroup.xml new file mode 100644 index 0000000000000..1b5a5301eda1b --- /dev/null +++ b/app/code/Magento/Cms/Test/Mftf/ActionGroup/AssertAdminCMSBlockIsNotInGridActionGroup.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AssertAdminCMSBlockIsNotInGridActionGroup"> + <arguments> + <argument name="identifier" type="entity"/> + </arguments> + <dontSee userInput="{{identifier}}" selector="{{AdminBlockGridSection.gridDataRow}}" stepKey="dontSeeCmsBlockInGrid"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Cms/Test/Mftf/ActionGroup/AssertAdminProperUrlIsShownActionGroup.xml b/app/code/Magento/Cms/Test/Mftf/ActionGroup/AssertAdminProperUrlIsShownActionGroup.xml new file mode 100644 index 0000000000000..fb97c9656aca2 --- /dev/null +++ b/app/code/Magento/Cms/Test/Mftf/ActionGroup/AssertAdminProperUrlIsShownActionGroup.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AssertAdminProperUrlIsShownActionGroup"> + <annotations> + <description>Assert current page has proper URL</description> + </annotations> + <arguments> + <argument name="target_path" type="string"/> + </arguments> + + <seeInCurrentUrl url="{{target_path}}" stepKey="seePropertUrl"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Cms/Test/Mftf/ActionGroup/NavigateToMediaFolderActionGroup.xml b/app/code/Magento/Cms/Test/Mftf/ActionGroup/NavigateToMediaFolderActionGroup.xml index c3d84fafd071c..f3cf259842e1b 100644 --- a/app/code/Magento/Cms/Test/Mftf/ActionGroup/NavigateToMediaFolderActionGroup.xml +++ b/app/code/Magento/Cms/Test/Mftf/ActionGroup/NavigateToMediaFolderActionGroup.xml @@ -15,8 +15,9 @@ <arguments> <argument name="FolderName" type="string"/> </arguments> - + <conditionalClick selector="{{MediaGallerySection.StorageRootArrow}}" dependentSelector="{{MediaGallerySection.checkIfArrowExpand}}" stepKey="clickArrowIfClosed" visible="true"/> + <waitForPageLoad time="10" stepKey="waitForDirectoriesTreeBuilding"/> <waitForText userInput="{{FolderName}}" stepKey="waitForNewFolder"/> <click userInput="{{FolderName}}" stepKey="clickOnCreatedFolder"/> <waitForLoadingMaskToDisappear stepKey="waitForLoading"/> diff --git a/app/code/Magento/Cms/Test/Mftf/ActionGroup/SaveAndContinueEditCmsPageActionGroup.xml b/app/code/Magento/Cms/Test/Mftf/ActionGroup/SaveAndContinueEditCmsPageActionGroup.xml index a8dce19153a98..d5ccb4e1c1e71 100644 --- a/app/code/Magento/Cms/Test/Mftf/ActionGroup/SaveAndContinueEditCmsPageActionGroup.xml +++ b/app/code/Magento/Cms/Test/Mftf/ActionGroup/SaveAndContinueEditCmsPageActionGroup.xml @@ -10,7 +10,7 @@ xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> <actionGroup name="SaveAndContinueEditCmsPageActionGroup"> <annotations> - <description>Clicks on the Save and Continue button.</description> + <description>DEPRECATED. Use AdminSaveAndContinueEditCmsPageActionGroup instead. Clicks on the Save and Continue button.</description> </annotations> <waitForElementVisible time="10" selector="{{CmsNewPagePageActionsSection.saveAndContinueEdit}}" stepKey="waitForSaveAndContinueVisibility"/> diff --git a/app/code/Magento/Cms/Test/Mftf/Data/BlockData.xml b/app/code/Magento/Cms/Test/Mftf/Data/BlockData.xml index dea047ec43568..bf9f199634078 100644 --- a/app/code/Magento/Cms/Test/Mftf/Data/BlockData.xml +++ b/app/code/Magento/Cms/Test/Mftf/Data/BlockData.xml @@ -15,4 +15,11 @@ <data key="content">sales25off everything!</data> <data key="is_active">0</data> </entity> + <entity name="ActiveTestBlock" type="block"> + <data key="title" unique="suffix">Test Block</data> + <data key="identifier" unique="suffix">ActiveTestBlock</data> + <data key="store_id">All Store Views</data> + <data key="content">Test Block content</data> + <data key="is_active">1</data> + </entity> </entities> diff --git a/app/code/Magento/Cms/Test/Mftf/Data/WysiwygConfigData.xml b/app/code/Magento/Cms/Test/Mftf/Data/WysiwygConfigData.xml index ea5e90383511c..66f2983140b45 100644 --- a/app/code/Magento/Cms/Test/Mftf/Data/WysiwygConfigData.xml +++ b/app/code/Magento/Cms/Test/Mftf/Data/WysiwygConfigData.xml @@ -16,7 +16,7 @@ <entity name="WysiwygDisabledByDefault"> <data key="path">cms/wysiwyg/enabled</data> <data key="scope_id">0</data> - <data key="value">hidden</data> + <data key="value">disabled</data> </entity> <entity name="WysiwygTinyMCE3Enable" deprecated="Use WysiwygTinyMCE4Enable instead"> <data key="path">cms/wysiwyg/editor</data> diff --git a/app/code/Magento/Cms/Test/Mftf/Section/AdminBlockGridSection.xml b/app/code/Magento/Cms/Test/Mftf/Section/AdminBlockGridSection.xml index ab15570a01f40..f558619fa49ac 100644 --- a/app/code/Magento/Cms/Test/Mftf/Section/AdminBlockGridSection.xml +++ b/app/code/Magento/Cms/Test/Mftf/Section/AdminBlockGridSection.xml @@ -14,5 +14,6 @@ <element name="checkbox" type="checkbox" selector="//label[@class='data-grid-checkbox-cell-inner']//input[@class='admin__control-checkbox']"/> <element name="select" type="select" selector="//tr[@class='data-row']//button[@class='action-select']"/> <element name="editInSelect" type="text" selector="//a[contains(text(), 'Edit')]"/> + <element name="gridDataRow" type="input" selector="//table[@data-role='grid']//tr/td"/> </section> </sections> diff --git a/app/code/Magento/Cms/Test/Mftf/Section/BlockPageActionsSection.xml b/app/code/Magento/Cms/Test/Mftf/Section/BlockPageActionsSection.xml index ac9c66fe82c74..38281d4d6d1d6 100644 --- a/app/code/Magento/Cms/Test/Mftf/Section/BlockPageActionsSection.xml +++ b/app/code/Magento/Cms/Test/Mftf/Section/BlockPageActionsSection.xml @@ -20,5 +20,7 @@ <element name="URLKey" type="input" selector="//div[@class='admin__form-field-control']/input[@name='identifier']"/> <element name="ApplyFiltersBtn" type="button" selector="//span[text()='Apply Filters']"/> <element name="blockGridRowByTitle" type="input" selector="//tbody//tr//td//div[contains(., '{{var1}}')]" parameterized="true" timeout="30"/> + <element name="delete" type="button" selector="//a[@data-action='item-delete']"/> + <element name="deleteConfirm" type="button" selector="//button[@data-role='action']//span[text()='OK']" timeout="60"/> </section> </sections> diff --git a/app/code/Magento/Cms/Test/Mftf/Section/CmsNewBlockBlockActionsSection/BlockContentSection.xml b/app/code/Magento/Cms/Test/Mftf/Section/CmsNewBlockBlockActionsSection/BlockContentSection.xml index 1d5e8541dd497..f4e26938d9008 100644 --- a/app/code/Magento/Cms/Test/Mftf/Section/CmsNewBlockBlockActionsSection/BlockContentSection.xml +++ b/app/code/Magento/Cms/Test/Mftf/Section/CmsNewBlockBlockActionsSection/BlockContentSection.xml @@ -9,7 +9,7 @@ xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> <section name="BlockContentSection"> <element name="TextArea" type="input" selector="#cms_block_form_content"/> - <element name="image" type="file" selector="#tinymce img"/> + <element name="image" type="file" selector=".mce-content-body img"/> <element name="contentIframe" type="iframe" selector="cms_block_form_content_ifr"/> </section> </sections> diff --git a/app/code/Magento/Cms/Test/Mftf/Section/TinyMCESection/MediaGallerySection.xml b/app/code/Magento/Cms/Test/Mftf/Section/TinyMCESection/MediaGallerySection.xml index 725d050554f2d..212035fbc575a 100644 --- a/app/code/Magento/Cms/Test/Mftf/Section/TinyMCESection/MediaGallerySection.xml +++ b/app/code/Magento/Cms/Test/Mftf/Section/TinyMCESection/MediaGallerySection.xml @@ -36,5 +36,6 @@ <element name="checkIfWysiwygArrowExpand" type="button" selector="//li[@id='d3lzaXd5Zw--' and contains(@class,'jstree-closed')]"/> <element name="confirmDelete" type="button" selector=".action-primary.action-accept"/> <element name="imageBlockByName" type="block" selector="//div[@data-row='file'][contains(., '{{imageName}}')]" parameterized="true"/> + <element name="insertEditImageModalWindow" type="block" selector=".mce-floatpanel.mce-window[aria-label='Insert/edit image']"/> </section> </sections> diff --git a/app/code/Magento/Cms/Test/Mftf/Section/TinyMCESection/TinyMCESection.xml b/app/code/Magento/Cms/Test/Mftf/Section/TinyMCESection/TinyMCESection.xml index e3e6ae9cffc02..b7a6618d76596 100644 --- a/app/code/Magento/Cms/Test/Mftf/Section/TinyMCESection/TinyMCESection.xml +++ b/app/code/Magento/Cms/Test/Mftf/Section/TinyMCESection/TinyMCESection.xml @@ -12,7 +12,7 @@ <element name="CheckIfTabExpand" type="button" selector="//div[@data-state-collapsible='closed']//span[text()='Content']"/> <element name="TinyMCE4" type="text" selector=".mce-branding"/> <element name="InsertWidgetBtn" type="button" selector=".action-add-widget"/> - <element name="InsertWidgetIcon" type="button" selector="div[aria-label='Insert Widget']"/> + <element name="InsertWidgetIcon" type="button" selector="div[aria-label='Insert Widget']" timeout="30"/> <element name="InsertVariableBtn" type="button" selector=".scalable.add-variable.plugin"/> <element name="InsertVariableIcon" type="button" selector="div[aria-label='Insert Variable']"/> <element name="InsertImageBtn" type="button" selector=".scalable.action-add-image.plugin"/> diff --git a/app/code/Magento/Cms/Test/Mftf/Test/AdminCmsPageMassActionTest.xml b/app/code/Magento/Cms/Test/Mftf/Test/AdminCmsPageMassActionTest.xml index 9a02104d8d6ef..53bb2619075a7 100644 --- a/app/code/Magento/Cms/Test/Mftf/Test/AdminCmsPageMassActionTest.xml +++ b/app/code/Magento/Cms/Test/Mftf/Test/AdminCmsPageMassActionTest.xml @@ -28,6 +28,8 @@ <after> <deleteData createDataKey="firstCMSPage" stepKey="deleteFirstCMSPage" /> <deleteData createDataKey="secondCMSPage" stepKey="deleteSecondCMSPage" /> + <actionGroup ref="AdminOpenCMSPagesGridActionGroup" stepKey="navigateToCMSPageGrid"/> + <actionGroup ref="AdminGridFilterResetActionGroup" stepKey="clearPossibleGridFilters"/> <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> </after> diff --git a/app/code/Magento/Cms/Test/Mftf/Test/AdminCreateCmsPageTest.xml b/app/code/Magento/Cms/Test/Mftf/Test/AdminCreateCmsPageTest.xml index 90da152e7a7b1..f82ae3eed5de6 100644 --- a/app/code/Magento/Cms/Test/Mftf/Test/AdminCreateCmsPageTest.xml +++ b/app/code/Magento/Cms/Test/Mftf/Test/AdminCreateCmsPageTest.xml @@ -25,7 +25,7 @@ <after> <actionGroup ref="AdminLogoutActionGroup" stepKey="adminLogout"/> </after> - <actionGroup ref="AdminNavigateToPageGridActionGroup" stepKey="navigateToCmsPageGrid" /> + <actionGroup ref="AdminOpenCMSPagesGridActionGroup" stepKey="navigateToCmsPageGrid" /> <actionGroup ref="CreateNewPageWithBasicValues" stepKey="createNewPageWithBasicValues" /> <actionGroup ref="SaveCmsPageActionGroup" stepKey="clickSaveCmsPageButton" /> <actionGroup ref="VerifyCreatedCmsPage" stepKey="verifyCmsPage" /> diff --git a/app/code/Magento/Cms/Test/Mftf/Test/AdminDeleteCmsBlockTest.xml b/app/code/Magento/Cms/Test/Mftf/Test/AdminDeleteCmsBlockTest.xml new file mode 100644 index 0000000000000..9e83b02d9184e --- /dev/null +++ b/app/code/Magento/Cms/Test/Mftf/Test/AdminDeleteCmsBlockTest.xml @@ -0,0 +1,47 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminDeleteCmsBlockTest"> + <annotations> + <features value="Cms"/> + <stories value="CMS Blocks Deleting"/> + <title value="Admin should be able to delete CMS block from grid"/> + <description value="Admin should be able to delete CMS block from grid"/> + <group value="Cms"/> + <severity value="MINOR"/> + </annotations> + <before> + <createData entity="_defaultBlock" stepKey="createCMSBlock"/> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + </before> + <after> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> + </after> + + <actionGroup ref="AdminOpenCmsBlocksGridActionGroup" stepKey="navigateToCmsBlocksGrid"/> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearGridSearchFilters"/> + <actionGroup ref="AdminSearchCMSBlockInGridByIdentifierActionGroup" stepKey="findCreatedCmsBlock"> + <argument name="identifier" value="$$createCMSBlock.identifier$$"/> + </actionGroup> + <actionGroup ref="AdminDeleteCMSBlockFromGridActionGroup" stepKey="deleteCmsBlockFromGrid"> + <argument name="identifier" value="$$createCMSBlock.identifier$$"/> + </actionGroup> + <actionGroup ref="AssertMessageInAdminPanelActionGroup" stepKey="assertSuccessMessage"> + <argument name="message" value="You deleted the block."/> + </actionGroup> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearGridSearchFiltersAfterBlockDeleting"/> + <actionGroup ref="AdminSearchCMSBlockInGridByIdentifierActionGroup" stepKey="searchDeletedCmsBlock"> + <argument name="identifier" value="$$createCMSBlock.identifier$$"/> + </actionGroup> + <actionGroup ref="AssertAdminCMSBlockIsNotInGridActionGroup" stepKey="assertDeletedCMSBlockIsNotInGrid"> + <argument name="identifier" value="$$createCMSBlock.identifier$$"/> + </actionGroup> + </test> +</tests> diff --git a/app/code/Magento/Cms/Test/Mftf/Test/AdminDeleteCmsPageTest.xml b/app/code/Magento/Cms/Test/Mftf/Test/AdminDeleteCmsPageTest.xml index 3687bb4fe5743..e80f6010b6c69 100644 --- a/app/code/Magento/Cms/Test/Mftf/Test/AdminDeleteCmsPageTest.xml +++ b/app/code/Magento/Cms/Test/Mftf/Test/AdminDeleteCmsPageTest.xml @@ -21,7 +21,7 @@ <before> <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> - <actionGroup ref="AdminNavigateToPageGridActionGroup" stepKey="navigateToCmsPageGrid"/> + <actionGroup ref="AdminOpenCMSPagesGridActionGroup" stepKey="navigateToCmsPageGrid"/> <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearGridSearchFilters"/> <actionGroup ref="CreateNewPageWithBasicValues" stepKey="createNewPageWithBasicValues"/> <actionGroup ref="SaveCmsPageActionGroup" stepKey="clickSaveCmsPageButton"/> diff --git a/app/code/Magento/Cms/Test/Mftf/Test/AdminUseQuickSearchInAdminDataGridsTest.xml b/app/code/Magento/Cms/Test/Mftf/Test/AdminUseQuickSearchInAdminDataGridsTest.xml new file mode 100644 index 0000000000000..245b1486058b8 --- /dev/null +++ b/app/code/Magento/Cms/Test/Mftf/Test/AdminUseQuickSearchInAdminDataGridsTest.xml @@ -0,0 +1,109 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminUseQuickSearchInAdminDataGridsTest"> + <annotations> + <features value="Cms"/> + <stories value="Create CMS Page"/> + <title value="[CMS Grids] Use quick search in Admin data grids"/> + <description value="Verify that Merchant can use quick search in order to simplify the data grid filtering in Admin"/> + <testCaseId value="MC-27559" /> + <severity value="MAJOR"/> + <group value="cms"/> + <group value="ui"/> + </annotations> + <before> + <createData entity="simpleCmsPage" stepKey="createFirstCMSPage" /> + <createData entity="_newDefaultCmsPage" stepKey="createSecondCMSPage" /> + <createData entity="_emptyCmsPage" stepKey="createThirdCMSPage" /> + <createData entity="Sales25offBlock" stepKey="createFirstCmsBlock"/> + <createData entity="ActiveTestBlock" stepKey="createSecondCmsBlock"/> + <createData entity="_emptyCmsBlock" stepKey="createThirdCmsBlock"/> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + </before> + <after> + <deleteData createDataKey="createFirstCMSPage" stepKey="deleteFirstCMSPage" /> + <deleteData createDataKey="createSecondCMSPage" stepKey="deleteSecondCMSPage" /> + <deleteData createDataKey="createThirdCMSPage" stepKey="deleteThirdCMSPage" /> + <deleteData createDataKey="createFirstCmsBlock" stepKey="deleteFirstCmsBlock" /> + <deleteData createDataKey="createSecondCmsBlock" stepKey="deleteSecondCmsBlock" /> + <deleteData createDataKey="createThirdCmsBlock" stepKey="deleteThirdCmsBlock" /> + <actionGroup ref="AdminOpenCMSPagesGridActionGroup" stepKey="navigateToCMSPageGrid"/> + <actionGroup ref="AdminGridFilterResetActionGroup" stepKey="clearCmsPagesGridFilters"/> + <actionGroup ref="AdminOpenCmsBlocksGridActionGroup" stepKey="navigateToCmsBlockGrid"/> + <actionGroup ref="AdminGridFilterResetActionGroup" stepKey="clearCmsBlockGridFilters"/> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logoutFromAdmin"/> + </after> + <!--Go to "Cms Pages Grid" page and filter by title--> + <actionGroup ref="AdminOpenCMSPagesGridActionGroup" stepKey="navigateToCmsPageGrid"/> + <actionGroup ref="SearchAdminDataGridByKeywordActionGroup" stepKey="searchFirstCmsPage"> + <argument name="keyword" value="$createFirstCMSPage.title$"/> + </actionGroup> + <see userInput="$createFirstCMSPage.title$" selector="{{AdminGridRow.rowOne}}" stepKey="seeFirstCmsPageAfterFiltering"/> + <actionGroup ref="AdminAssertNumberOfRecordsInUiGridActionGroup" stepKey="assertNumberOfRecordsInCmsPageGrid"/> + <actionGroup ref="SearchAdminDataGridByKeywordActionGroup" stepKey="searchSecondCmsPage"> + <argument name="keyword" value="$createSecondCMSPage.title$"/> + </actionGroup> + <see userInput="$createSecondCMSPage.title$" selector="{{AdminGridRow.rowOne}}" stepKey="seeSecondCmsPageAfterFiltering"/> + <actionGroup ref="AdminAssertNumberOfRecordsInUiGridActionGroup" stepKey="assertNumberOfRecordsAfterFilteringSecondCmsPage"/> + <actionGroup ref="AdminGridFilterResetActionGroup" stepKey="clearGridFilters"/> + <grabTextFrom selector="{{AdminGridHeaders.totalRecords}}" stepKey="grabTotalRecordsCmsPagesBeforeClickSearchButton"/> + <click selector="{{AdminDataGridHeaderSection.submitSearch}}" stepKey="clickSearchMagnifierButton"/> + <grabTextFrom selector="{{AdminGridHeaders.totalRecords}}" stepKey="grabTotalRecordsCmsPagesAfterClickSearchButton"/> + <assertEquals stepKey="assertTotalRecordsCmsPages"> + <expectedResult type="string">$grabTotalRecordsCmsPagesBeforeClickSearchButton</expectedResult> + <actualResult type="string">$grabTotalRecordsCmsPagesAfterClickSearchButton</actualResult> + </assertEquals> + <actionGroup ref="SearchAdminDataGridByKeywordActionGroup" stepKey="enterNonExistentEntityInQuickSearch"> + <argument name="keyword" value="TestQueryNonExistentEntity"/> + </actionGroup> + <dontSeeElement selector="{{AdminDataGridTableSection.rows}}" stepKey="dontSeeResultRows"/> + <actionGroup ref="AdminAssertNumberOfRecordsInUiGridActionGroup" stepKey="assertNumberOfRecordsAfterFilteringNonExistentCmsPage"> + <argument name="number" value="0"/> + </actionGroup> + <actionGroup ref="SearchAdminDataGridByKeywordActionGroup" stepKey="searchThirdCmsPage"> + <argument name="keyword" value="$createThirdCMSPage.title$"/> + </actionGroup> + <see userInput="$createThirdCMSPage.title$" selector="{{AdminGridRow.rowOne}}" stepKey="seeThirdCmsPageAfterFiltering"/> + <actionGroup ref="AdminAssertNumberOfRecordsInUiGridActionGroup" stepKey="assertNumberOfRecordsAfterFilteringThirdCmsPage"/> + + <!--Go to "Cms Blocks Grid" page and filter by title--> + <actionGroup ref="AdminOpenCmsBlocksGridActionGroup" stepKey="navigateToCmsBlockGrid"/> + <actionGroup ref="SearchAdminDataGridByKeywordActionGroup" stepKey="searchFirstCmsBlock"> + <argument name="keyword" value="$createFirstCmsBlock.title$"/> + </actionGroup> + <see userInput="$createFirstCmsBlock.title$" selector="{{AdminGridRow.rowOne}}" stepKey="seeFirstCmsBlockAfterFiltering"/> + <actionGroup ref="AdminAssertNumberOfRecordsInUiGridActionGroup" stepKey="assertNumberOfRecordsInBlockGrid"/> + <actionGroup ref="SearchAdminDataGridByKeywordActionGroup" stepKey="searchSecondCmsBlock"> + <argument name="keyword" value="$createSecondCmsBlock.title$"/> + </actionGroup> + <see userInput="$createSecondCmsBlock.title$" selector="{{AdminGridRow.rowOne}}" stepKey="seeSecondCmsBlockAfterFiltering"/> + <actionGroup ref="AdminAssertNumberOfRecordsInUiGridActionGroup" stepKey="assertNumberOfRecordsAfterFilteringSecondBlock"/> + <actionGroup ref="AdminGridFilterResetActionGroup" stepKey="clearGridFiltersOnBlocksGridPage"/> + <grabTextFrom selector="{{AdminGridHeaders.totalRecords}}" stepKey="grabTotalRecordsBlocksBeforeClickSearchButton"/> + <click selector="{{AdminDataGridHeaderSection.submitSearch}}" stepKey="clickSearchMagnifierButtonOnBlocksGridPage"/> + <grabTextFrom selector="{{AdminGridHeaders.totalRecords}}" stepKey="grabTotalRecordsBlocksAfterClickSearchButton"/> + <assertEquals stepKey="assertTotalRecordsBlocks"> + <expectedResult type="string">$grabTotalRecordsBlocksBeforeClickSearchButton</expectedResult> + <actualResult type="string">$grabTotalRecordsBlocksAfterClickSearchButton</actualResult> + </assertEquals> + <actionGroup ref="SearchAdminDataGridByKeywordActionGroup" stepKey="enterNonExistentEntityInQuickSearchOnBlocksGridPage"> + <argument name="keyword" value="TestQueryNonExistentEntity"/> + </actionGroup> + <dontSeeElement selector="{{AdminDataGridTableSection.rows}}" stepKey="dontSeeResultRowsOnBlocksGrid"/> + <actionGroup ref="AdminAssertNumberOfRecordsInUiGridActionGroup" stepKey="assertNumberOfRecordsAfterFilteringNonExistentCmsBlock"> + <argument name="number" value="0"/> + </actionGroup> + <actionGroup ref="SearchAdminDataGridByKeywordActionGroup" stepKey="searchThirdCmsBlock"> + <argument name="keyword" value="$createThirdCmsBlock.title$"/> + </actionGroup> + <see userInput="$createThirdCmsBlock.title$" selector="{{AdminGridRow.rowOne}}" stepKey="seeThirdCmsBlockAfterFiltering"/> + <actionGroup ref="AdminAssertNumberOfRecordsInUiGridActionGroup" stepKey="assertNumberOfRecordsAfterFilteringThirdBlock"/> + </test> +</tests> diff --git a/app/code/Magento/Cms/Test/Mftf/Test/CheckOrderOfProdsInWidgetOnCMSPageTest.xml b/app/code/Magento/Cms/Test/Mftf/Test/CheckOrderOfProdsInWidgetOnCMSPageTest.xml index c69cd620b1d72..484dc16faa3b9 100644 --- a/app/code/Magento/Cms/Test/Mftf/Test/CheckOrderOfProdsInWidgetOnCMSPageTest.xml +++ b/app/code/Magento/Cms/Test/Mftf/Test/CheckOrderOfProdsInWidgetOnCMSPageTest.xml @@ -102,6 +102,7 @@ <actionGroup ref="NavigateToCreatedCMSPageActionGroup" stepKey="navigateToCreatedCMSPage2"> <argument name="CMSPage" value="$$createCMSPage$$"/> </actionGroup> + <scrollTo stepKey="scrollToContent" selector="{{CmsNewPagePageContentSection.header}}" x="0" y="-80"/> <conditionalClick selector="{{CmsNewPagePageContentSection.header}}" dependentSelector="{{CmsNewPagePageContentSection.header}}._show" visible="false" stepKey="clickContentTab2"/> diff --git a/app/code/Magento/Cms/Test/Mftf/Test/CheckStaticBlocksTest.xml b/app/code/Magento/Cms/Test/Mftf/Test/CheckStaticBlocksTest.xml index eba7812e29a0c..490e11d3cc751 100644 --- a/app/code/Magento/Cms/Test/Mftf/Test/CheckStaticBlocksTest.xml +++ b/app/code/Magento/Cms/Test/Mftf/Test/CheckStaticBlocksTest.xml @@ -21,8 +21,7 @@ </annotations> <before> - <actionGroup ref="AdminLoginActionGroup" stepKey="LoginAsAdmin"/> - + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> <actionGroup ref="AdminCreateWebsiteActionGroup" stepKey="createAdditionalWebsite"> <argument name="newWebsiteName" value="{{customWebsite.name}}"/> <argument name="websiteCode" value="{{customWebsite.code}}"/> @@ -39,49 +38,60 @@ </before> <after> - <actionGroup ref="AdminDeleteWebsiteActionGroup" stepKey="DeleteWebsite"> + <actionGroup ref="AdminDeleteWebsiteActionGroup" stepKey="deleteWebsite"> <argument name="websiteName" value="{{customWebsite.name}}"/> </actionGroup> - <actionGroup ref="DeleteCMSBlockActionGroup" stepKey="DeleteCMSBlockActionGroup"/> + <actionGroup ref="DeleteCMSBlockActionGroup" stepKey="deleteCMSBlock"/> + <actionGroup ref="DeleteCMSBlockActionGroup" stepKey="deleteSecondCMSBlock"/> <actionGroup ref="AdminClearFiltersActionGroup" stepKey="clearFilters"/> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> </after> <!--Go to Cms blocks page--> - <amOnPage url="{{CmsBlocksPage.url}}" stepKey="navigateToCMSPagesGrid"/> - <waitForPageLoad stepKey="waitForPageLoad1"/> - <seeInCurrentUrl url="cms/block/" stepKey="VerifyPageIsOpened"/> + <actionGroup ref="AdminOpenCmsBlocksGridActionGroup" stepKey="navigateToCMSBlocksGrid"/> + <actionGroup ref="AssertAdminProperUrlIsShownActionGroup" stepKey="verifyPageIsOpened"> + <argument name="target_path" value="cms/block/"/> + </actionGroup> + <!--Click to create new block--> - <click selector="{{BlockPageActionsSection.addNewBlock}}" stepKey="ClickToAddNewBlock"/> - <waitForPageLoad stepKey="waitForPageLoad2"/> - <seeInCurrentUrl url="cms/block/new" stepKey="VerifyNewBlockPageIsOpened"/> + <actionGroup ref="AdminPressAddNewCmsBlockButtonActionGroup" stepKey="clickOnAddNewBlockButton"/> + <actionGroup ref="AssertAdminProperUrlIsShownActionGroup" stepKey="verifyNewCmsBlockPageIsOpened"> + <argument name="target_path" value="cms/block/new"/> + </actionGroup> <actionGroup ref="FillOutBlockContent" stepKey="FillOutBlockContent"/> - <click selector="{{BlockNewPagePageActionsSection.saveBlock}}" stepKey="ClickToSaveBlock"/> - <waitForPageLoad stepKey="waitForPageLoad3"/> - <see userInput="You saved the block." stepKey="VerifyBlockIsSaved"/> - <!--Click to go back and add new block--> - <click selector="{{BlockNewPagePageActionsSection.back}}" stepKey="ClickToGoBack"/> - <waitForPageLoad stepKey="waitForPageLoad4"/> - <click selector="{{BlockPageActionsSection.addNewBlock}}" stepKey="ClickToAddNewBlock1"/> - <waitForPageLoad stepKey="waitForPageLoad5"/> - <seeInCurrentUrl url="cms/block/new" stepKey="VerifyNewBlockPageIsOpened1"/> + <actionGroup ref="AdminPressSaveCmsBlockButtonActionGroup" stepKey="saveCmsBlock"/> + <actionGroup ref="AssertMessageInAdminPanelActionGroup" stepKey="assertSuccessMessage"> + <argument name="message" value="You saved the block."/> + </actionGroup> + <!--Add new BLock with the same data--> + <actionGroup ref="AdminOpenCmsBlocksGridActionGroup" stepKey="openCmsBlocksGrid"/> + <actionGroup ref="AdminPressAddNewCmsBlockButtonActionGroup" stepKey="pressAddNewBlockButton"/> + <actionGroup ref="AssertAdminProperUrlIsShownActionGroup" stepKey="assertNewCmsBlockPageIsOpened"> + <argument name="target_path" value="cms/block/new"/> + </actionGroup> <actionGroup ref="FillOutBlockContent" stepKey="FillOutBlockContent1"/> - <click selector="{{BlockNewPagePageActionsSection.saveBlock}}" stepKey="ClickToSaveBlock1"/> - <waitForPageLoad stepKey="waitForPageLoad6"/> - <!--Verify that corresponding message is displayed--> - <see userInput="A block identifier with the same properties already exists in the selected store." stepKey="VerifyBlockIsSaved1"/> - <!--Click to go back and add new block--> - <click selector="{{BlockNewPagePageActionsSection.back}}" stepKey="ClickToGoBack1"/> - <waitForPageLoad stepKey="waitForPageLoad7"/> - <click selector="{{BlockPageActionsSection.addNewBlock}}" stepKey="ClickToAddNewBlock2"/> - <waitForPageLoad stepKey="waitForPageLoad8"/> - <seeInCurrentUrl url="cms/block/new" stepKey="VerifyNewBlockPageIsOpened2"/> + <actionGroup ref="AdminPressSaveCmsBlockButtonActionGroup" stepKey="clickOnSaveButton"/> + <actionGroup ref="AssertMessageInAdminPanelActionGroup" stepKey="assertErrorMessage"> + <argument name="messageType" value="error"/> + <argument name="message" value="A block identifier with the same properties already exists in the selected store."/> + </actionGroup> + <!--Add new BLock with the same data for another store view--> + <actionGroup ref="AdminOpenCmsBlocksGridActionGroup" stepKey="goToCmsBlocksGrid"/> + <actionGroup ref="AdminPressAddNewCmsBlockButtonActionGroup" stepKey="clickToAddNewButton"/> + <actionGroup ref="AssertAdminProperUrlIsShownActionGroup" stepKey="confirmNewCmsBlockPageIsOpened"> + <argument name="target_path" value="cms/block/new"/> + </actionGroup> <actionGroup ref="FillOutBlockContent" stepKey="FillOutBlockContent2"/> - <selectOption selector="{{BlockNewPageBasicFieldsSection.storeView}}" userInput="Default Store View" stepKey="selectDefaultStoreView" /> - <selectOption selector="{{BlockNewPageBasicFieldsSection.storeView}}" userInput="{{customStore.name}}" stepKey="selectSecondStoreView1" /> - <click selector="{{BlockNewPagePageActionsSection.saveBlock}}" stepKey="ClickToSaveBlock2"/> - <waitForPageLoad stepKey="waitForPageLoad9"/> - <see userInput="You saved the block." stepKey="VerifyBlockIsSaved2"/> + + <actionGroup ref="AdminSelectCMSBlockStoreViewActionGroup" stepKey="selectCustomStoreView"> + <argument name="storeViewName" value="{{customStore.name}}"/> + </actionGroup> + + <actionGroup ref="AdminPressSaveCmsBlockButtonActionGroup" stepKey="saveNewCmsBlock"/> + <actionGroup ref="AssertMessageInAdminPanelActionGroup" stepKey="verifyBlockIsSaved"> + <argument name="message" value="You saved the block."/> + </actionGroup> </test> </tests> diff --git a/app/code/Magento/Cms/Test/Unit/Block/BlockByIdentifierTest.php b/app/code/Magento/Cms/Test/Unit/Block/BlockByIdentifierTest.php new file mode 100644 index 0000000000000..44b94b059cb6d --- /dev/null +++ b/app/code/Magento/Cms/Test/Unit/Block/BlockByIdentifierTest.php @@ -0,0 +1,225 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Cms\Test\Unit\Block; + +use Magento\Cms\Api\Data\BlockInterface; +use Magento\Cms\Api\GetBlockByIdentifierInterface; +use Magento\Cms\Block\BlockByIdentifier; +use Magento\Cms\Model\Block; +use Magento\Cms\Model\Template\FilterProvider; +use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Framework\Event\ManagerInterface; +use Magento\Framework\Exception\NoSuchEntityException; +use Magento\Framework\Filter\Template; +use Magento\Framework\View\Element\Context; +use Magento\Store\Api\Data\StoreInterface; +use Magento\Store\Model\StoreManagerInterface; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ +class BlockByIdentifierTest extends TestCase +{ + private const STUB_MODULE_OUTPUT_DISABLED = false; + private const STUB_EXISTING_IDENTIFIER = 'existingOne'; + private const STUB_UNAVAILABLE_IDENTIFIER = 'notExists'; + private const STUB_DEFAULT_STORE = 1; + private const STUB_CMS_BLOCK_ID = 1; + private const STUB_CONTENT = 'Content'; + + private const ASSERT_EMPTY_BLOCK_HTML = ''; + private const ASSERT_CONTENT_HTML = self::STUB_CONTENT; + private const ASSERT_UNAVAILABLE_IDENTIFIER_BASED_IDENTITIES = [ + BlockByIdentifier::CACHE_KEY_PREFIX . '_' . self::STUB_UNAVAILABLE_IDENTIFIER, + BlockByIdentifier::CACHE_KEY_PREFIX . '_' . self::STUB_UNAVAILABLE_IDENTIFIER . '_' . self::STUB_DEFAULT_STORE + ]; + private const STUB_CMS_BLOCK_IDENTITY_BY_ID = 'CMS_BLOCK_' . self::STUB_CMS_BLOCK_ID; + private const STUB_CMS_BLOCK_IDENTITY_BY_IDENTIFIER = 'CMS_BLOCK_' . self::STUB_EXISTING_IDENTIFIER; + + /** @var MockObject|GetBlockByIdentifierInterface */ + private $getBlockByIdentifierMock; + + /** @var MockObject|StoreManagerInterface */ + private $storeManagerMock; + + /** @var MockObject|FilterProvider */ + private $filterProviderMock; + + /** @var MockObject|StoreInterface */ + private $storeMock; + + protected function setUp(): void + { + $this->storeMock = $this->createMock(StoreInterface::class); + $this->storeManagerMock = $this->createMock(StoreManagerInterface::class); + $this->storeManagerMock->method('getStore')->willReturn($this->storeMock); + + $this->getBlockByIdentifierMock = $this->createMock(GetBlockByIdentifierInterface::class); + + $this->filterProviderMock = $this->createMock(FilterProvider::class); + $this->filterProviderMock->method('getBlockFilter')->willReturn($this->getPassthroughFilterMock()); + } + + public function testBlockThrowsInvalidArgumentExceptionWhenNoIdentifierProvided(): void + { + // Given + $missingIdentifierBlock = $this->getTestedBlockUsingIdentifier(null); + $this->storeMock->method('getId')->willReturn(self::STUB_DEFAULT_STORE); + + // Expect + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Expected value of `identifier` was not provided'); + + // When + $missingIdentifierBlock->toHtml(); + } + + public function testBlockReturnsEmptyStringWhenIdentifierProvidedNotFound(): void + { + // Given + $this->getBlockByIdentifierMock->method('execute')->willThrowException( + new NoSuchEntityException(__('NoSuchEntityException')) + ); + $missingIdentifierBlock = $this->getTestedBlockUsingIdentifier(self::STUB_UNAVAILABLE_IDENTIFIER); + $this->storeMock->method('getId')->willReturn(self::STUB_DEFAULT_STORE); + + // Expect + $this->assertSame(self::ASSERT_EMPTY_BLOCK_HTML, $missingIdentifierBlock->toHtml()); + $this->assertSame( + self::ASSERT_UNAVAILABLE_IDENTIFIER_BASED_IDENTITIES, + $missingIdentifierBlock->getIdentities() + ); + } + + public function testBlockReturnsCmsContentsWhenIdentifierFound(): void + { + // Given + $cmsBlockMock = $this->getCmsBlockMock( + self::STUB_CMS_BLOCK_ID, + self::STUB_EXISTING_IDENTIFIER, + self::STUB_CONTENT + ); + $this->storeMock->method('getId')->willReturn(self::STUB_DEFAULT_STORE); + $this->getBlockByIdentifierMock->method('execute') + ->with(self::STUB_EXISTING_IDENTIFIER, self::STUB_DEFAULT_STORE) + ->willReturn($cmsBlockMock); + $block = $this->getTestedBlockUsingIdentifier(self::STUB_EXISTING_IDENTIFIER); + + // Expect + $this->assertSame(self::ASSERT_CONTENT_HTML, $block->toHtml()); + } + + public function testBlockCacheIdentitiesContainCmsBlockIdentities(): void + { + // Given + $cmsBlockMock = $this->createMock(Block::class); + $cmsBlockMock->method('getId')->willReturn(self::STUB_CMS_BLOCK_ID); + $cmsBlockMock->method('isActive')->willReturn(true); + $cmsBlockMock->method('getIdentifier')->willReturn(self::STUB_EXISTING_IDENTIFIER); + $cmsBlockMock->method('getIdentities')->willReturn( + [ + self::STUB_CMS_BLOCK_IDENTITY_BY_ID, + self::STUB_CMS_BLOCK_IDENTITY_BY_IDENTIFIER + ] + ); + + $this->storeMock->method('getId')->willReturn(self::STUB_DEFAULT_STORE); + $this->getBlockByIdentifierMock->method('execute') + ->with(self::STUB_EXISTING_IDENTIFIER, self::STUB_DEFAULT_STORE) + ->willReturn($cmsBlockMock); + $block = $this->getTestedBlockUsingIdentifier(self::STUB_EXISTING_IDENTIFIER); + + // When + $identities = $block->getIdentities(); + + // Then + $this->assertContains($this->getIdentityStubById(self::STUB_CMS_BLOCK_ID), $identities); + $this->assertContains(self::STUB_CMS_BLOCK_IDENTITY_BY_ID, $identities); + $this->assertContains(self::STUB_CMS_BLOCK_IDENTITY_BY_IDENTIFIER, $identities); + } + + /** + * Initializes the tested block with injecting the references required by parent classes. + * + * @param string|null $identifier + * @return BlockByIdentifier + */ + private function getTestedBlockUsingIdentifier(?string $identifier): BlockByIdentifier + { + $eventManagerMock = $this->createMock(ManagerInterface::class); + $scopeConfigMock = $this->createMock(ScopeConfigInterface::class); + $scopeConfigMock->method('getValue')->willReturn(self::STUB_MODULE_OUTPUT_DISABLED); + + $contextMock = $this->createMock(Context::class); + $contextMock->method('getEventManager')->willReturn($eventManagerMock); + $contextMock->method('getScopeConfig')->willReturn($scopeConfigMock); + + return new BlockByIdentifier( + $this->getBlockByIdentifierMock, + $this->storeManagerMock, + $this->filterProviderMock, + $contextMock, + ['identifier' => $identifier] + ); + } + + /** + * Mocks the CMS Block object for further play + * + * @param int $entityId + * @param string $identifier + * @param string $content + * @param bool $isActive + * @return MockObject|BlockInterface + */ + private function getCmsBlockMock( + int $entityId, + string $identifier, + string $content, + bool $isActive = true + ): BlockInterface { + $cmsBlock = $this->createMock(BlockInterface::class); + + $cmsBlock->method('getId')->willReturn($entityId); + $cmsBlock->method('getIdentifier')->willReturn($identifier); + $cmsBlock->method('getContent')->willReturn($content); + $cmsBlock->method('isActive')->willReturn($isActive); + + return $cmsBlock; + } + + /** + * Creates mock of the Filter that actually is doing nothing + * + * @return MockObject|Template + */ + private function getPassthroughFilterMock(): Template + { + $filterMock = $this->getMockBuilder(Template::class) + ->disableOriginalConstructor() + ->setMethods(['setStoreId', 'filter']) + ->getMock(); + $filterMock->method('setStoreId')->willReturnSelf(); + $filterMock->method('filter')->willReturnArgument(0); + + return $filterMock; + } + + /** + * Returns stub of Identity based on `$cmsBlockId` + * + * @param int $cmsBlockId + * @return string + */ + private function getIdentityStubById(int $cmsBlockId): string + { + return BlockByIdentifier::CACHE_KEY_PREFIX . '_' . $cmsBlockId; + } +} diff --git a/app/code/Magento/Cms/Test/Unit/Controller/Adminhtml/Wysiwyg/DirectiveTest.php b/app/code/Magento/Cms/Test/Unit/Controller/Adminhtml/Wysiwyg/DirectiveTest.php index 5791ecea4e4e3..70dd95521f040 100644 --- a/app/code/Magento/Cms/Test/Unit/Controller/Adminhtml/Wysiwyg/DirectiveTest.php +++ b/app/code/Magento/Cms/Test/Unit/Controller/Adminhtml/Wysiwyg/DirectiveTest.php @@ -15,7 +15,10 @@ use Magento\Framework\App\ResponseInterface; use Magento\Framework\Controller\Result\Raw; use Magento\Framework\Controller\Result\RawFactory; +use Magento\Framework\Filesystem; +use Magento\Framework\Filesystem\Directory\WriteInterface; use Magento\Framework\Filesystem\Driver\File; +use Magento\Framework\Filesystem\DriverInterface; use Magento\Framework\Image\Adapter\AdapterInterface; use Magento\Framework\Image\AdapterFactory; use Magento\Framework\ObjectManagerInterface; @@ -78,11 +81,6 @@ class DirectiveTest extends TestCase */ protected $responseMock; - /** - * @var File|MockObject - */ - protected $fileMock; - /** * @var Config|MockObject */ @@ -103,6 +101,11 @@ class DirectiveTest extends TestCase */ protected $rawMock; + /** + * @var DriverInterface|MockObject + */ + private $driverMock; + protected function setUp(): void { $this->actionContextMock = $this->getMockBuilder(Context::class) @@ -146,10 +149,6 @@ protected function setUp(): void ->disableOriginalConstructor() ->setMethods(['setHeader', 'setBody', 'sendResponse']) ->getMockForAbstractClass(); - $this->fileMock = $this->getMockBuilder(File::class) - ->disableOriginalConstructor() - ->setMethods(['fileGetContents']) - ->getMock(); $this->wysiwygConfigMock = $this->getMockBuilder(Config::class) ->disableOriginalConstructor() ->getMock(); @@ -173,6 +172,17 @@ protected function setUp(): void $this->actionContextMock->expects($this->any()) ->method('getObjectManager') ->willReturn($this->objectManagerMock); + $this->driverMock = $this->getMockBuilder(DriverInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $directoryWrite = $this->getMockBuilder(WriteInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $directoryWrite->expects($this->any())->method('getDriver')->willReturn($this->driverMock); + $filesystemMock = $this->getMockBuilder(Filesystem::class) + ->disableOriginalConstructor() + ->getMock(); + $filesystemMock->expects($this->any())->method('getDirectoryWrite')->willReturn($directoryWrite); $objectManager = new ObjectManager($this); $this->wysiwygDirective = $objectManager->getObject( @@ -185,7 +195,7 @@ protected function setUp(): void 'logger' => $this->loggerMock, 'config' => $this->wysiwygConfigMock, 'filter' => $this->templateFilterMock, - 'file' => $this->fileMock, + 'filesystem' => $filesystemMock ] ); } @@ -216,7 +226,7 @@ public function testExecute() $this->imageAdapterMock->expects($this->once()) ->method('getImage') ->willReturn($imageBody); - $this->fileMock->expects($this->once()) + $this->driverMock->expects($this->once()) ->method('fileGetContents') ->willReturn($imageBody); $this->rawFactoryMock->expects($this->any()) @@ -267,7 +277,7 @@ public function testExecuteException() $this->imageAdapterMock->expects($this->any()) ->method('getImage') ->willReturn($imageBody); - $this->fileMock->expects($this->once()) + $this->driverMock->expects($this->once()) ->method('fileGetContents') ->willReturn($imageBody); $this->loggerMock->expects($this->once()) diff --git a/app/code/Magento/Cms/Test/Unit/Model/Wysiwyg/ConfigTest.php b/app/code/Magento/Cms/Test/Unit/Model/Wysiwyg/ConfigTest.php index 33bf352adf6c5..0ba3fada2a072 100644 --- a/app/code/Magento/Cms/Test/Unit/Model/Wysiwyg/ConfigTest.php +++ b/app/code/Magento/Cms/Test/Unit/Model/Wysiwyg/ConfigTest.php @@ -192,12 +192,12 @@ public function testGetConfig($data, $isAuthorizationAllowed, $expectedResults) ->willReturn('localhost/index.php/'); $this->filesystemMock->expects($this->once()) ->method('getUri') - ->willReturn('pub/static'); + ->willReturn('static'); /** @var ContextInterface|MockObject $contextMock */ $contextMock = $this->getMockForAbstractClass(ContextInterface::class); $contextMock->expects($this->once()) ->method('getBaseUrl') - ->willReturn('localhost/pub/static/'); + ->willReturn('localhost/static/'); $this->assetRepoMock->expects($this->once()) ->method('getStaticViewFileContext') ->willReturn($contextMock); @@ -217,8 +217,8 @@ public function testGetConfig($data, $isAuthorizationAllowed, $expectedResults) $config = $this->wysiwygConfig->getConfig($data); $this->assertInstanceOf(DataObject::class, $config); $this->assertEquals($expectedResults[0], $config->getData('someData')); - $this->assertEquals('localhost/pub/static/', $config->getData('baseStaticUrl')); - $this->assertEquals('localhost/pub/static/', $config->getData('baseStaticDefaultUrl')); + $this->assertEquals('localhost/static/', $config->getData('baseStaticUrl')); + $this->assertEquals('localhost/static/', $config->getData('baseStaticDefaultUrl')); } /** 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 c2c748dcc7633..b03dbb8f0c888 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 @@ -494,7 +494,7 @@ public function testUploadFile() $targetPath = self::STORAGE_ROOT_DIR . $path; $fileName = 'image.gif'; $realPath = $targetPath . '/' . $fileName; - $thumbnailTargetPath = self::STORAGE_ROOT_DIR . '/.thumbs' . $path; + $thumbnailTargetPath = self::STORAGE_ROOT_DIR . '.thumbs' . $path; $thumbnailDestination = $thumbnailTargetPath . '/' . $fileName; $type = 'image'; $result = [ diff --git a/app/code/Magento/Cms/Test/Unit/Model/Wysiwyg/ValidatorTest.php b/app/code/Magento/Cms/Test/Unit/Model/Wysiwyg/ValidatorTest.php new file mode 100644 index 0000000000000..8e2fa44a24545 --- /dev/null +++ b/app/code/Magento/Cms/Test/Unit/Model/Wysiwyg/ValidatorTest.php @@ -0,0 +1,87 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +declare(strict_types=1); + +namespace Magento\Cms\Test\Unit\Model\Wysiwyg; + +use Magento\Cms\Model\Wysiwyg\Validator; +use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Framework\Message\ManagerInterface; +use Magento\Framework\Message\MessageInterface; +use Magento\Framework\Validation\ValidationException; +use Magento\Framework\Validator\HTML\WYSIWYGValidatorInterface; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use Magento\Framework\Message\Factory as MessageFactory; + +class ValidatorTest extends TestCase +{ + /** + * Validation cases. + * + * @return array + */ + public function getValidationCases(): array + { + return [ + 'invalid-exception' => [true, new ValidationException(__('Invalid html')), true, false], + 'invalid-warning' => [false, new \RuntimeException('Invalid html'), false, true], + 'valid' => [false, null, false, false] + ]; + } + + /** + * Test validation. + * + * @param bool $isFlagSet + * @param \Throwable|null $thrown + * @param bool $exceptionThrown + * @param bool $warned + * @dataProvider getValidationCases + */ + public function testValidate(bool $isFlagSet, ?\Throwable $thrown, bool $exceptionThrown, bool $warned): void + { + $actuallyWarned = false; + + $messageFactoryMock = $this->createMock(MessageFactory::class); + $messageFactoryMock->method('create') + ->willReturnCallback( + function () { + return $this->getMockForAbstractClass(MessageInterface::class); + } + ); + $configMock = $this->getMockForAbstractClass(ScopeConfigInterface::class); + $configMock->method('isSetFlag') + ->with(Validator::CONFIG_PATH_THROW_EXCEPTION) + ->willReturn($isFlagSet); + + $backendMock = $this->getMockForAbstractClass(WYSIWYGValidatorInterface::class); + if ($thrown) { + $backendMock->method('validate')->willThrowException($thrown); + } + + $messagesMock = $this->getMockForAbstractClass(ManagerInterface::class); + $messagesMock->method('addUniqueMessages') + ->willReturnCallback( + function () use (&$actuallyWarned): void { + $actuallyWarned = true; + } + ); + + $loggerMock = $this->getMockForAbstractClass(LoggerInterface::class); + + $validator = new Validator($backendMock, $messagesMock, $configMock, $loggerMock, $messageFactoryMock); + try { + $validator->validate('content'); + $actuallyThrown = false; + } catch (\Throwable $exception) { + $actuallyThrown = true; + } + $this->assertEquals($exceptionThrown, $actuallyThrown); + $this->assertEquals($warned, $actuallyWarned); + } +} diff --git a/app/code/Magento/Cms/etc/config.xml b/app/code/Magento/Cms/etc/config.xml index 7090bb7a1fd25..c1b3717386454 100644 --- a/app/code/Magento/Cms/etc/config.xml +++ b/app/code/Magento/Cms/etc/config.xml @@ -24,12 +24,14 @@ <wysiwyg> <enabled>enabled</enabled> <editor>mage/adminhtml/wysiwyg/tiny_mce/tinymce4Adapter</editor> + <force_valid>0</force_valid> </wysiwyg> </cms> <system> <media_storage_configuration> <allowed_resources> <wysiwyg_image_folder>wysiwyg</wysiwyg_image_folder> + <preview_folder>.thumbs</preview_folder> </allowed_resources> </media_storage_configuration> </system> diff --git a/app/code/Magento/Cms/etc/di.xml b/app/code/Magento/Cms/etc/di.xml index 7fc8268eea5e0..61cf33f88abd6 100644 --- a/app/code/Magento/Cms/etc/di.xml +++ b/app/code/Magento/Cms/etc/di.xml @@ -57,35 +57,35 @@ <item name="exclude" xsi:type="array"> <item name="captcha" xsi:type="array"> <item name="regexp" xsi:type="boolean">true</item> - <item name="name" xsi:type="string">pub[/\\]+media[/\\]+captcha[/\\]*$</item> + <item name="name" xsi:type="string">media[/\\]+captcha[/\\]*$</item> </item> <item name="catalog/product" xsi:type="array"> <item name="regexp" xsi:type="boolean">true</item> - <item name="name" xsi:type="string">pub[/\\]+media[/\\]+catalog[/\\]+product[/\\]*$</item> + <item name="name" xsi:type="string">media[/\\]+catalog[/\\]+product[/\\]*$</item> </item> <item name="customer" xsi:type="array"> <item name="regexp" xsi:type="boolean">true</item> - <item name="name" xsi:type="string">pub[/\\]+media[/\\]+customer[/\\]*$</item> + <item name="name" xsi:type="string">media[/\\]+customer[/\\]*$</item> </item> <item name="downloadable" xsi:type="array"> <item name="regexp" xsi:type="boolean">true</item> - <item name="name" xsi:type="string">pub[/\\]+media[/\\]+downloadable[/\\]*$</item> + <item name="name" xsi:type="string">media[/\\]+downloadable[/\\]*$</item> </item> <item name="import" xsi:type="array"> <item name="regexp" xsi:type="boolean">true</item> - <item name="name" xsi:type="string">pub[/\\]+media[/\\]+import[/\\]*$</item> + <item name="name" xsi:type="string">media[/\\]+import[/\\]*$</item> </item> <item name="theme" xsi:type="array"> <item name="regexp" xsi:type="boolean">true</item> - <item name="name" xsi:type="string">pub[/\\]+media[/\\]+theme[/\\]*$</item> + <item name="name" xsi:type="string">media[/\\]+theme[/\\]*$</item> </item> <item name="theme_customization" xsi:type="array"> <item name="regexp" xsi:type="boolean">true</item> - <item name="name" xsi:type="string">pub[/\\]+media[/\\]+theme_customization[/\\]*$</item> + <item name="name" xsi:type="string">media[/\\]+theme_customization[/\\]*$</item> </item> <item name="tmp" xsi:type="array"> <item name="regexp" xsi:type="boolean">true</item> - <item name="name" xsi:type="string">pub[/\\]+media[/\\]+tmp[/\\]*$</item> + <item name="name" xsi:type="string">media[/\\]+tmp[/\\]*$</item> </item> </item> <item name="include" xsi:type="array"/> @@ -215,6 +215,7 @@ <type name="Magento\Cms\Model\BlockRepository"> <arguments> <argument name="collectionProcessor" xsi:type="object">Magento\Cms\Model\Api\SearchCriteria\BlockCollectionProcessor</argument> + <argument name="hydrator" xsi:type="object">Magento\Framework\EntityManager\AbstractModelHydrator</argument> </arguments> </type> @@ -234,6 +235,7 @@ <argument name="validators" xsi:type="array"> <item name="layout_update" xsi:type="object">Magento\Cms\Model\PageRepository\Validator\LayoutUpdateValidator</item> </argument> + <argument name="hydrator" xsi:type="object">Magento\Framework\EntityManager\AbstractModelHydrator</argument> </arguments> </type> <preference for="Magento\Cms\Model\Page\CustomLayoutManagerInterface" type="Magento\Cms\Model\Page\CustomLayout\CustomLayoutManager" /> @@ -243,4 +245,73 @@ </arguments> </type> <preference for="Magento\Cms\Model\Page\CustomLayoutRepositoryInterface" type="Magento\Cms\Model\Page\CustomLayout\CustomLayoutRepository" /> + <type name="Magento\Cms\Model\Wysiwyg\Validator"> + <arguments> + <argument name="validator" xsi:type="object">DefaultWYSIWYGValidator</argument> + </arguments> + </type> + <preference for="Magento\Framework\Validator\HTML\WYSIWYGValidatorInterface" type="Magento\Cms\Model\Wysiwyg\Validator" /> + <type name="Magento\Framework\Console\CommandListInterface"> + <arguments> + <argument name="commands" xsi:type="array"> + <item name="cms_wysiwyg_restrict" xsi:type="object">Magento\Cms\Command\WysiwygRestrictCommand</item> + </argument> + </arguments> + </type> + <virtualType name="DefaultWYSIWYGValidator" type="Magento\Framework\Validator\HTML\ConfigurableWYSIWYGValidator"> + <arguments> + <argument name="allowedTags" xsi:type="array"> + <item name="div" xsi:type="string">div</item> + <item name="a" xsi:type="string">a</item> + <item name="p" xsi:type="string">p</item> + <item name="span" xsi:type="string">span</item> + <item name="em" xsi:type="string">em</item> + <item name="strong" xsi:type="string">strong</item> + <item name="ul" xsi:type="string">ul</item> + <item name="li" xsi:type="string">li</item> + <item name="ol" xsi:type="string">ol</item> + <item name="h5" xsi:type="string">h5</item> + <item name="h4" xsi:type="string">h4</item> + <item name="h3" xsi:type="string">h3</item> + <item name="h2" xsi:type="string">h2</item> + <item name="h1" xsi:type="string">h1</item> + <item name="table" xsi:type="string">table</item> + <item name="tbody" xsi:type="string">tbody</item> + <item name="tr" xsi:type="string">tr</item> + <item name="td" xsi:type="string">td</item> + <item name="th" xsi:type="string">th</item> + <item name="tfoot" xsi:type="string">tfoot</item> + <item name="img" xsi:type="string">img</item> + <item name="hr" xsi:type="string">hr</item> + <item name="figure" xsi:type="string">figure</item> + <item name="button" xsi:type="string">button</item> + <item name="i" xsi:type="string">i</item> + <item name="u" xsi:type="string">u</item> + </argument> + <argument name="allowedAttributes" xsi:type="array"> + <item name="class" xsi:type="string">class</item> + <item name="width" xsi:type="string">width</item> + <item name="height" xsi:type="string">height</item> + <item name="style" xsi:type="string">style</item> + <item name="alt" xsi:type="string">alt</item> + <item name="title" xsi:type="string">title</item> + <item name="border" xsi:type="string">border</item> + <item name="id" xsi:type="string">id</item> + </argument> + <argument name="attributesAllowedByTags" xsi:type="array"> + <item name="a" xsi:type="array"> + <item name="href" xsi:type="string">href</item> + </item> + <item name="img" xsi:type="array"> + <item name="src" xsi:type="string">src</item> + </item> + <item name="button" xsi:type="array"> + <item name="type" xsi:type="string">type</item> + </item> + </argument> + <argument name="attributeValidators" xsi:type="array"> + <item name="style" xsi:type="object">Magento\Framework\Validator\HTML\StyleAttributeValidator</item> + </argument> + </arguments> + </virtualType> </config> diff --git a/app/code/Magento/Cms/etc/webapi.xml b/app/code/Magento/Cms/etc/webapi.xml index 5b66d0e3ed879..464f5146e6358 100644 --- a/app/code/Magento/Cms/etc/webapi.xml +++ b/app/code/Magento/Cms/etc/webapi.xml @@ -23,19 +23,19 @@ <route url="/V1/cmsPage" method="POST"> <service class="Magento\Cms\Api\PageRepositoryInterface" method="save"/> <resources> - <resource ref="Magento_Cms::page"/> + <resource ref="Magento_Cms::save"/> </resources> </route> <route url="/V1/cmsPage/:id" method="PUT"> <service class="Magento\Cms\Api\PageRepositoryInterface" method="save"/> <resources> - <resource ref="Magento_Cms::page"/> + <resource ref="Magento_Cms::save"/> </resources> </route> <route url="/V1/cmsPage/:pageId" method="DELETE"> <service class="Magento\Cms\Api\PageRepositoryInterface" method="deleteById"/> <resources> - <resource ref="Magento_Cms::page"/> + <resource ref="Magento_Cms::page_delete"/> </resources> </route> <!-- Cms Block --> diff --git a/app/code/Magento/Cms/view/adminhtml/templates/browser/content/uploader.phtml b/app/code/Magento/Cms/view/adminhtml/templates/browser/content/uploader.phtml index d1c204c01ad1c..154e76bd93e41 100644 --- a/app/code/Magento/Cms/view/adminhtml/templates/browser/content/uploader.phtml +++ b/app/code/Magento/Cms/view/adminhtml/templates/browser/content/uploader.phtml @@ -11,14 +11,14 @@ $filters = $block->getConfig()->getFilters() ?? []; $allowedExtensions = []; $blockHtmlId = $block->getHtmlId(); -$listExtensions = [[]]; +$listExtensions = []; foreach ($filters as $media_type) { $listExtensions[] = array_map(function ($fileExt) { return ltrim($fileExt, '.*'); }, $media_type['files']); } -$allowedExtensions = array_merge(...$listExtensions); +$allowedExtensions = array_merge([], ...$listExtensions); $resizeConfig = $block->getImageUploadConfigData()->getIsResizeEnabled() ? "{action: 'resize', maxWidth: " diff --git a/app/code/Magento/Cms/view/adminhtml/ui_component/cms_block_listing.xml b/app/code/Magento/Cms/view/adminhtml/ui_component/cms_block_listing.xml index af54df24b64f5..332c316396122 100644 --- a/app/code/Magento/Cms/view/adminhtml/ui_component/cms_block_listing.xml +++ b/app/code/Magento/Cms/view/adminhtml/ui_component/cms_block_listing.xml @@ -64,7 +64,7 @@ <label translate="true">Store View</label> <dataScope>store_id</dataScope> <imports> - <link name="visible">componentType = column, index = ${ $.index }:visible</link> + <link name="visible">ns = ${ $.ns }, index = ${ $.index }:visible</link> </imports> </settings> </filterSelect> diff --git a/app/code/Magento/Cms/view/adminhtml/ui_component/cms_page_listing.xml b/app/code/Magento/Cms/view/adminhtml/ui_component/cms_page_listing.xml index 846356adf9429..12c3e8287ecd8 100644 --- a/app/code/Magento/Cms/view/adminhtml/ui_component/cms_page_listing.xml +++ b/app/code/Magento/Cms/view/adminhtml/ui_component/cms_page_listing.xml @@ -69,7 +69,7 @@ <label translate="true">Store View</label> <dataScope>store_id</dataScope> <imports> - <link name="visible">componentType = column, index = ${ $.index }:visible</link> + <link name="visible">ns = ${ $.ns }, index = ${ $.index }:visible</link> </imports> </settings> </filterSelect> diff --git a/app/code/Magento/Config/App/Config/Source/ConfigStructureSource.php b/app/code/Magento/Config/App/Config/Source/ConfigStructureSource.php new file mode 100644 index 0000000000000..f176e0d518230 --- /dev/null +++ b/app/code/Magento/Config/App/Config/Source/ConfigStructureSource.php @@ -0,0 +1,68 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Config\App\Config\Source; + +use Magento\Config\Model\Config\Structure; +use Magento\Framework\App\Config\ConfigSourceInterface; +use Magento\Framework\DataObject; + +/** + * Class for retrieving configuration structure + */ +class ConfigStructureSource implements ConfigSourceInterface +{ + /** + * @var Structure + */ + private $structure; + + /** + * @param Structure $structure + */ + public function __construct(Structure $structure) + { + $this->structure = $structure; + } + + /** + * @inheritdoc + */ + public function get($path = '') + { + $fieldPaths = array_keys($this->structure->getFieldPaths()); + $defaultConfig = []; + foreach ($fieldPaths as $fieldPath) { + $defaultConfig = $this->addPathToConfig($defaultConfig, $fieldPath); + } + $data = new DataObject(['default' => $defaultConfig]); + + return $data->getData($path); + } + + /** + * Add config path to config structure + * + * @param array $config + * @param string $path + * @return array + */ + private function addPathToConfig(array $config, string $path): array + { + if (strpos($path, '/') !== false) { + list ($key, $subPath) = explode('/', $path, 2); + $config[$key] = $this->addPathToConfig( + $config[$key] ?? [], + $subPath + ); + } else { + $config[$path] = null; + } + + return $config; + } +} diff --git a/app/code/Magento/Config/Model/Config.php b/app/code/Magento/Config/Model/Config.php index f61e99529c3cc..eda02612ded1a 100644 --- a/app/code/Magento/Config/Model/Config.php +++ b/app/code/Magento/Config/Model/Config.php @@ -207,10 +207,9 @@ public function save() $deleteTransaction ); - $groupChangedPaths = $this->getChangedPaths($sectionId, $groupId, $groupData, $oldConfig, $extraOldGroups); - // phpcs:ignore Magento2.Performance.ForeachArrayMerge - $changedPaths = \array_merge($changedPaths, $groupChangedPaths); + $changedPaths[] = $this->getChangedPaths($sectionId, $groupId, $groupData, $oldConfig, $extraOldGroups); } + $changedPaths = array_merge([], ...$changedPaths); try { $deleteTransaction->delete(); @@ -356,7 +355,7 @@ private function getChangedPaths( $field = $this->getField($sectionId, $groupId, $fieldId); $path = $this->getFieldPath($field, $fieldId, $oldConfig, $extraOldGroups); if ($this->isValueChanged($oldConfig, $path, $fieldData)) { - $changedPaths[] = $path; + $changedPaths[] = [$path]; } } } @@ -371,12 +370,11 @@ private function getChangedPaths( $oldConfig, $extraOldGroups ); - // phpcs:ignore Magento2.Performance.ForeachArrayMerge - $changedPaths = \array_merge($changedPaths, $subGroupChangedPaths); + $changedPaths[] = $subGroupChangedPaths; } } - return $changedPaths; + return \array_merge([], ...$changedPaths); } /** diff --git a/app/code/Magento/Config/Model/Config/Backend/Admin/Robots.php b/app/code/Magento/Config/Model/Config/Backend/Admin/Robots.php index e6acd431be3d5..1763a6d1800a1 100644 --- a/app/code/Magento/Config/Model/Config/Backend/Admin/Robots.php +++ b/app/code/Magento/Config/Model/Config/Backend/Admin/Robots.php @@ -10,6 +10,7 @@ namespace Magento\Config\Model\Config\Backend\Admin; use Magento\Config\Model\Config\Reader\Source\Deployed\DocumentRoot; +use Magento\Framework\App\Filesystem\DirectoryList; use Magento\Framework\App\ObjectManager; /** @@ -36,7 +37,6 @@ class Robots extends \Magento\Framework\App\Config\Value * @param \Magento\Framework\Model\ResourceModel\AbstractResource $resource * @param \Magento\Framework\Data\Collection\AbstractDb $resourceCollection * @param array $data - * @param DocumentRoot $documentRoot */ public function __construct( \Magento\Framework\Model\Context $context, @@ -46,13 +46,11 @@ public function __construct( \Magento\Framework\Filesystem $filesystem, \Magento\Framework\Model\ResourceModel\AbstractResource $resource = null, \Magento\Framework\Data\Collection\AbstractDb $resourceCollection = null, - array $data = [], - \Magento\Config\Model\Config\Reader\Source\Deployed\DocumentRoot $documentRoot = null + array $data = [] ) { parent::__construct($context, $registry, $config, $cacheTypeList, $resource, $resourceCollection, $data); - $documentRoot = $documentRoot ?: ObjectManager::getInstance()->get(DocumentRoot::class); - $this->_directory = $filesystem->getDirectoryWrite($documentRoot->getPath()); + $this->_directory = $filesystem->getDirectoryWrite(DirectoryList::PUB); $this->_file = 'robots.txt'; } diff --git a/app/code/Magento/Config/Model/Config/Backend/Serialized.php b/app/code/Magento/Config/Model/Config/Backend/Serialized.php index 6e0b6275db836..88fdae8908797 100644 --- a/app/code/Magento/Config/Model/Config/Backend/Serialized.php +++ b/app/code/Magento/Config/Model/Config/Backend/Serialized.php @@ -5,6 +5,7 @@ */ namespace Magento\Config\Model\Config\Backend; +use Magento\Framework\App\Config\ScopeConfigInterface; use Magento\Framework\App\ObjectManager; use Magento\Framework\Serialize\Serializer\Json; @@ -84,4 +85,26 @@ public function beforeSave() parent::beforeSave(); return $this; } + + /** + * Get old value from existing config + * + * @return string + */ + public function getOldValue() + { + // If the value is retrieved from defaults defined in config.xml + // it may be returned as an array. + $value = $this->_config->getValue( + $this->getPath(), + $this->getScope() ?: ScopeConfigInterface::SCOPE_TYPE_DEFAULT, + $this->getScopeCode() + ); + + if (is_array($value)) { + return $this->serializer->serialize($value); + } + + return (string)$value; + } } diff --git a/app/code/Magento/Config/Model/Config/Reader/Source/Deployed/DocumentRoot.php b/app/code/Magento/Config/Model/Config/Reader/Source/Deployed/DocumentRoot.php index bf59c729790a7..fb78de35569ac 100644 --- a/app/code/Magento/Config/Model/Config/Reader/Source/Deployed/DocumentRoot.php +++ b/app/code/Magento/Config/Model/Config/Reader/Source/Deployed/DocumentRoot.php @@ -3,57 +3,50 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ - namespace Magento\Config\Model\Config\Reader\Source\Deployed; -use Magento\Framework\Config\ConfigOptionsListConstants; -use Magento\Framework\App\Filesystem\DirectoryList; use Magento\Framework\App\DeploymentConfig; +use Magento\Framework\App\Filesystem\DirectoryList; /** - * Class DocumentRoot - * @package Magento\Config\Model\Config\Reader\Source\Deployed + * Document root detector. + * * @api * @since 101.0.0 + * + * @deprecated Magento always uses the pub directory + * @see DirectoryList::PUB */ class DocumentRoot { /** - * @var DeploymentConfig - */ - private $config; - - /** - * DocumentRoot constructor. * @param DeploymentConfig $config + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ public function __construct(DeploymentConfig $config) { - $this->config = $config; } /** - * A shortcut to load the document root path from the DirectoryList based on the - * deployment configuration. + * A shortcut to load the document root path from the DirectoryList. * * @return string * @since 101.0.0 */ public function getPath() { - return $this->isPub() ? DirectoryList::PUB : DirectoryList::ROOT; + return DirectoryList::PUB; } /** - * Returns whether the deployment configuration specifies that the document root is - * in the pub/ folder. This affects ares such as sitemaps and robots.txt (and will - * likely be extended to control other areas). + * Checks if root folder is /pub. * * @return bool * @since 101.0.0 */ public function isPub() { - return (bool)$this->config->get(ConfigOptionsListConstants::CONFIG_PATH_DOCUMENT_ROOT_IS_PUB); + return true; } } diff --git a/app/code/Magento/Config/Model/Config/Structure.php b/app/code/Magento/Config/Model/Config/Structure.php index 437aca04ec577..156867f34318a 100644 --- a/app/code/Magento/Config/Model/Config/Structure.php +++ b/app/code/Magento/Config/Model/Config/Structure.php @@ -292,20 +292,16 @@ public function getFieldPathsByAttribute($attributeName, $attributeValue) foreach ($section['children'] as $group) { if (isset($group['children'])) { $path = $section['id'] . '/' . $group['id']; - // phpcs:ignore Magento2.Performance.ForeachArrayMerge.ForeachArrayMerge - $result = array_merge( - $result, - $this->_getGroupFieldPathsByAttribute( - $group['children'], - $path, - $attributeName, - $attributeValue - ) + $result[] = $this->_getGroupFieldPathsByAttribute( + $group['children'], + $path, + $attributeName, + $attributeValue ); } } } - return $result; + return array_merge([], ...$result); } /** diff --git a/app/code/Magento/Config/Test/Mftf/Suite/AppConfigDumpSuite.xml b/app/code/Magento/Config/Test/Mftf/Suite/AppConfigDumpSuite.xml index 762d17bdf87f1..127677ce05e0d 100644 --- a/app/code/Magento/Config/Test/Mftf/Suite/AppConfigDumpSuite.xml +++ b/app/code/Magento/Config/Test/Mftf/Suite/AppConfigDumpSuite.xml @@ -8,6 +8,7 @@ <suites xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Suite/etc/suiteSchema.xsd"> <suite name="AppConfigDumpSuite"> <before> + <!-- Command app:config:dump is not reversible and magento instance stays configuration read only after this test. You need to restore etc/env.php manually to make magento configuration writable again.--> <magentoCLI command="app:config:dump" stepKey="configDump"/> </before> <after> diff --git a/app/code/Magento/Config/Test/Unit/App/Config/Source/ConfigStructureSourceTest.php b/app/code/Magento/Config/Test/Unit/App/Config/Source/ConfigStructureSourceTest.php new file mode 100644 index 0000000000000..8fc7b04a13c64 --- /dev/null +++ b/app/code/Magento/Config/Test/Unit/App/Config/Source/ConfigStructureSourceTest.php @@ -0,0 +1,72 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Config\Test\Unit\App\Config\Source; + +use Magento\Config\App\Config\Source\ConfigStructureSource; +use Magento\Config\Model\Config\Structure; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +class ConfigStructureSourceTest extends TestCase +{ + /** + * @var Structure|MockObject + */ + private $structure; + + /** + * @var ConfigStructureSource + */ + private $source; + + protected function setUp(): void + { + $this->structure = $this->createMock(Structure::class); + $this->source = new ConfigStructureSource($this->structure); + } + + /** + * @dataProvider getDataProvider + * @param array $fieldPaths + * @param array $expectedConfig + */ + public function testGet(array $fieldPaths, array $expectedConfig) + { + $this->structure->expects($this->once()) + ->method('getFieldPaths') + ->willReturn($fieldPaths); + $this->assertEquals($expectedConfig, $this->source->get('default')); + } + + /** + * @return array + */ + public function getDataProvider(): array + { + return [ + [ + [ + 'general/single_store_mode/enabled' => [], + 'general/locale/timezone' => [], + 'general/locale/code' => [], + ], + [ + 'general' => [ + 'single_store_mode' => [ + 'enabled' => null, + ], + 'locale' => [ + 'timezone' => null, + 'code' => null, + ], + ], + ], + ], + ]; + } +} diff --git a/app/code/Magento/Config/Test/Unit/Model/Config/Backend/SerializedTest.php b/app/code/Magento/Config/Test/Unit/Model/Config/Backend/SerializedTest.php index c509b515b3112..90a381cff714b 100644 --- a/app/code/Magento/Config/Test/Unit/Model/Config/Backend/SerializedTest.php +++ b/app/code/Magento/Config/Test/Unit/Model/Config/Backend/SerializedTest.php @@ -8,6 +8,7 @@ namespace Magento\Config\Test\Unit\Model\Config\Backend; use Magento\Config\Model\Config\Backend\Serialized; +use Magento\Framework\App\Config\ScopeConfigInterface; use Magento\Framework\Event\ManagerInterface; use Magento\Framework\Model\Context; use Magento\Framework\Serialize\Serializer\Json; @@ -27,11 +28,14 @@ class SerializedTest extends TestCase /** @var LoggerInterface|MockObject */ private $loggerMock; + private $scopeConfigMock; + protected function setUp(): void { $objectManager = new ObjectManager($this); $this->serializerMock = $this->createMock(Json::class); $this->loggerMock = $this->getMockForAbstractClass(LoggerInterface::class); + $this->scopeConfigMock = $this->createMock(ScopeConfigInterface::class); $contextMock = $this->createMock(Context::class); $eventManagerMock = $this->getMockForAbstractClass(ManagerInterface::class); $contextMock->method('getEventDispatcher') @@ -43,6 +47,7 @@ protected function setUp(): void [ 'serializer' => $this->serializerMock, 'context' => $contextMock, + 'config' => $this->scopeConfigMock, ] ); } @@ -135,4 +140,29 @@ public function beforeSaveDataProvider() ] ]; } + + /** + * If a config value is not available in core_confid_data the defaults are + * loaded from the config.xml file. Those defaults may be arrays. + * The Serialized backend model has to override its parent + * getOldValue function, to prevent an array to string conversion error + * and serialize those values. + */ + public function testGetOldValueWithNonScalarDefaultValue(): void + { + $value = [ + ['foo' => '1', 'bar' => '2'], + ]; + $serializedValue = \json_encode($value); + + $this->scopeConfigMock->method('getValue')->willReturn($value); + $this->serializerMock->method('serialize')->willReturn($serializedValue); + + $this->serializedConfig->setData('value', $serializedValue); + + $oldValue = $this->serializedConfig->getOldValue(); + + $this->assertIsString($oldValue, 'Default value from the config is not serialized.'); + $this->assertSame($serializedValue, $oldValue); + } } diff --git a/app/code/Magento/Config/Test/Unit/Model/Config/Reader/Source/Deployed/DocumentRootTest.php b/app/code/Magento/Config/Test/Unit/Model/Config/Reader/Source/Deployed/DocumentRootTest.php deleted file mode 100644 index 6f1758f3d2b92..0000000000000 --- a/app/code/Magento/Config/Test/Unit/Model/Config/Reader/Source/Deployed/DocumentRootTest.php +++ /dev/null @@ -1,75 +0,0 @@ -<?php -/** - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. - */ -declare(strict_types=1); - -namespace Magento\Config\Test\Unit\Model\Config\Reader\Source\Deployed; - -use Magento\Config\Model\Config\Reader\Source\Deployed\DocumentRoot; -use Magento\Framework\App\Config; -use Magento\Framework\App\DeploymentConfig; -use Magento\Framework\App\Filesystem\DirectoryList; -use Magento\Framework\Config\ConfigOptionsListConstants; -use PHPUnit\Framework\MockObject\MockObject; -use PHPUnit\Framework\TestCase; - -/** - * Test class for checking settings that defined in config file - */ -class DocumentRootTest extends TestCase -{ - /** - * @var Config|MockObject - */ - private $configMock; - - /** - * @var DocumentRoot - */ - private $documentRoot; - - protected function setUp(): void - { - $this->configMock = $this->getMockBuilder(DeploymentConfig::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->documentRoot = new DocumentRoot($this->configMock); - } - - /** - * Ensures that the path returned matches the pub/ path. - */ - public function testGetPath() - { - $this->configMockSetForDocumentRootIsPub(); - - $this->assertSame(DirectoryList::PUB, $this->documentRoot->getPath()); - } - - /** - * Ensures that the deployment configuration returns the mocked value for - * the pub/ folder. - */ - public function testIsPub() - { - $this->configMockSetForDocumentRootIsPub(); - - $this->assertTrue($this->documentRoot->isPub()); - } - - private function configMockSetForDocumentRootIsPub() - { - $this->configMock->expects($this->any()) - ->method('get') - ->willReturnMap([ - [ - ConfigOptionsListConstants::CONFIG_PATH_DOCUMENT_ROOT_IS_PUB, - null, - true - ], - ]); - } -} diff --git a/app/code/Magento/Config/etc/di.xml b/app/code/Magento/Config/etc/di.xml index 85304e9c20f27..4277ca0a6de26 100644 --- a/app/code/Magento/Config/etc/di.xml +++ b/app/code/Magento/Config/etc/di.xml @@ -207,6 +207,10 @@ <virtualType name="appDumpSystemSource" type="Magento\Config\App\Config\Source\DumpConfigSourceAggregated"> <arguments> <argument name="sources" xsi:type="array"> + <item name="structure" xsi:type="array"> + <item name="source" xsi:type="object">Magento\Config\App\Config\Source\ConfigStructureSource</item> + <item name="sortOrder" xsi:type="string">1</item> + </item> <item name="modular" xsi:type="array"> <item name="source" xsi:type="object">Magento\Config\App\Config\Source\ModularConfigSource</item> <item name="sortOrder" xsi:type="string">10</item> @@ -346,4 +350,19 @@ <argument name="excludeList" xsi:type="object">Magento\Config\Model\Config\Export\ExcludeList</argument> </arguments> </type> + <virtualType name="adminhtmlConfigStructureData" type="\Magento\Config\Model\Config\Structure\Data"> + <arguments> + <argument name="configScope" xsi:type="object">adminhtmlConfigScope</argument> + </arguments> + </virtualType> + <virtualType name="adminhtmlConfigStructure" type="Magento\Config\Model\Config\Structure"> + <arguments> + <argument name="structureData" xsi:type="object">adminhtmlConfigStructureData</argument> + </arguments> + </virtualType> + <type name="Magento\Config\App\Config\Source\ConfigStructureSource"> + <arguments> + <argument name="structure" xsi:type="object">adminhtmlConfigStructure</argument> + </arguments> + </type> </config> 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 6a9e84e345985..26923fc9df837 100644 --- a/app/code/Magento/ConfigurableProduct/Block/Product/View/Type/Configurable.php +++ b/app/code/Magento/ConfigurableProduct/Block/Product/View/Type/Configurable.php @@ -7,6 +7,7 @@ */ namespace Magento\ConfigurableProduct\Block\Product\View\Type; +use Magento\Catalog\Api\Data\ProductInterface; use Magento\Catalog\Model\Product\Attribute\Source\Status; use Magento\ConfigurableProduct\Model\ConfigurableAttributeData; use Magento\Customer\Helper\Session\CurrentCustomer; @@ -287,53 +288,70 @@ protected function getOptionPrices() { $prices = []; foreach ($this->getAllowProducts() as $product) { - $tierPrices = []; $priceInfo = $product->getPriceInfo(); - $tierPriceModel = $priceInfo->getPrice('tier_price'); - $tierPricesList = $tierPriceModel->getTierPriceList(); - foreach ($tierPricesList as $tierPrice) { - $tierPrices[] = [ - 'qty' => $this->localeFormat->getNumber($tierPrice['price_qty']), - 'price' => $this->localeFormat->getNumber($tierPrice['price']->getValue()), - 'percentage' => $this->localeFormat->getNumber( - $tierPriceModel->getSavePercent($tierPrice['price']) - ), - ]; - } - $prices[$product->getId()] = - [ - 'baseOldPrice' => [ - 'amount' => $this->localeFormat->getNumber( - $priceInfo->getPrice('regular_price')->getAmount()->getBaseAmount() - ), - ], - 'oldPrice' => [ - 'amount' => $this->localeFormat->getNumber( - $priceInfo->getPrice('regular_price')->getAmount()->getValue() - ), - ], - 'basePrice' => [ - 'amount' => $this->localeFormat->getNumber( - $priceInfo->getPrice('final_price')->getAmount()->getBaseAmount() - ), - ], - 'finalPrice' => [ - 'amount' => $this->localeFormat->getNumber( - $priceInfo->getPrice('final_price')->getAmount()->getValue() - ), - ], - 'tierPrices' => $tierPrices, - 'msrpPrice' => [ - 'amount' => $this->localeFormat->getNumber( - $this->priceCurrency->convertAndRound($product->getMsrp()) - ), - ], - ]; + $prices[$product->getId()] = [ + 'baseOldPrice' => [ + 'amount' => $this->localeFormat->getNumber( + $priceInfo->getPrice('regular_price')->getAmount()->getBaseAmount() + ), + ], + 'oldPrice' => [ + 'amount' => $this->localeFormat->getNumber( + $priceInfo->getPrice('regular_price')->getAmount()->getValue() + ), + ], + 'basePrice' => [ + 'amount' => $this->localeFormat->getNumber( + $priceInfo->getPrice('final_price')->getAmount()->getBaseAmount() + ), + ], + 'finalPrice' => [ + 'amount' => $this->localeFormat->getNumber( + $priceInfo->getPrice('final_price')->getAmount()->getValue() + ), + ], + 'tierPrices' => $this->getTierPricesByProduct($product), + 'msrpPrice' => [ + 'amount' => $this->localeFormat->getNumber( + $this->priceCurrency->convertAndRound($product->getMsrp()) + ), + ], + ]; } + return $prices; } + /** + * Returns product's tier prices list + * + * @param ProductInterface $product + * @return array + */ + private function getTierPricesByProduct(ProductInterface $product): array + { + $tierPrices = []; + $tierPriceModel = $product->getPriceInfo()->getPrice('tier_price'); + foreach ($tierPriceModel->getTierPriceList() as $tierPrice) { + $tierPriceData = [ + 'qty' => $this->localeFormat->getNumber($tierPrice['price_qty']), + 'price' => $this->localeFormat->getNumber($tierPrice['price']->getValue()), + 'percentage' => $this->localeFormat->getNumber( + $tierPriceModel->getSavePercent($tierPrice['price']) + ), + ]; + + if (isset($tierPrice['excl_tax_price'])) { + $excludingTax = $tierPrice['excl_tax_price']; + $tierPriceData['excl_tax_price'] = $this->localeFormat->getNumber($excludingTax->getBaseAmount()); + } + $tierPrices[] = $tierPriceData; + } + + return $tierPrices; + } + /** * Replace ',' on '.' for js * diff --git a/app/code/Magento/ConfigurableProduct/Helper/Data.php b/app/code/Magento/ConfigurableProduct/Helper/Data.php index a5fdcd62c7aa1..54b95bcd7bd90 100644 --- a/app/code/Magento/ConfigurableProduct/Helper/Data.php +++ b/app/code/Magento/ConfigurableProduct/Helper/Data.php @@ -7,9 +7,11 @@ namespace Magento\ConfigurableProduct\Helper; use Magento\Catalog\Model\Product\Image\UrlBuilder; +use Magento\ConfigurableProduct\Model\Product\Type\Configurable; use Magento\Framework\App\ObjectManager; use Magento\Catalog\Helper\Image as ImageHelper; use Magento\Catalog\Api\Data\ProductInterface; +use Magento\Catalog\Model\Product; use Magento\Catalog\Model\Product\Image; /** @@ -73,7 +75,7 @@ public function getGalleryImages(ProductInterface $product) /** * Get Options for Configurable Product Options * - * @param \Magento\Catalog\Model\Product $currentProduct + * @param Product $currentProduct * @param array $allowedProducts * @return array */ @@ -100,11 +102,13 @@ public function getOptions($currentProduct, $allowedProducts) /** * Get allowed attributes * - * @param \Magento\Catalog\Model\Product $product + * @param Product $product * @return array */ public function getAllowAttributes($product) { - return $product->getTypeInstance()->getConfigurableAttributes($product); + return ($product->getTypeId() == Configurable::TYPE_CODE) + ? $product->getTypeInstance()->getConfigurableAttributes($product) + : []; } } diff --git a/app/code/Magento/ConfigurableProduct/Model/Plugin/Frontend/UsedProductsWebsiteFilter.php b/app/code/Magento/ConfigurableProduct/Model/Plugin/Frontend/UsedProductsWebsiteFilter.php new file mode 100644 index 0000000000000..9e1f3482d3c0f --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Model/Plugin/Frontend/UsedProductsWebsiteFilter.php @@ -0,0 +1,34 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\ConfigurableProduct\Model\Plugin\Frontend; + +use Magento\Catalog\Api\Data\ProductInterface; +use Magento\ConfigurableProduct\Model\Product\Type\Configurable; + +/** + * Filter configurable options by current store plugin. + */ +class UsedProductsWebsiteFilter +{ + /** + * Filter configurable options not assigned to current website. + * + * @param Configurable $subject + * @param ProductInterface $product + * @param array|null $requiredAttributeIds + * @return void + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function beforeGetUsedProducts( + Configurable $subject, + ProductInterface $product, + array $requiredAttributeIds = null + ): void { + $subject->setStoreFilter($product->getStore(), $product); + } +} diff --git a/app/code/Magento/ConfigurableProduct/Model/Product/Type/Configurable.php b/app/code/Magento/ConfigurableProduct/Model/Product/Type/Configurable.php index c2ae381b345c6..79f6d1e47f1a2 100644 --- a/app/code/Magento/ConfigurableProduct/Model/Product/Type/Configurable.php +++ b/app/code/Magento/ConfigurableProduct/Model/Product/Type/Configurable.php @@ -17,6 +17,7 @@ use Magento\Framework\App\ObjectManager; use Magento\Framework\EntityManager\MetadataPool; use Magento\Framework\Api\SearchCriteriaBuilder; +use Magento\Framework\File\UploaderFactory; /** * Configurable product type implementation @@ -235,11 +236,12 @@ class Configurable extends \Magento\Catalog\Model\Product\Type\AbstractType * @param \Magento\Framework\Api\ExtensionAttribute\JoinProcessorInterface $extensionAttributesJoinProcessor * @param \Magento\Framework\Cache\FrontendInterface|null $cache * @param \Magento\Customer\Model\Session|null $customerSession - * @param \Magento\Framework\Serialize\Serializer\Json $serializer - * @param ProductInterfaceFactory $productFactory - * @param SalableProcessor $salableProcessor + * @param \Magento\Framework\Serialize\Serializer\Json|null $serializer + * @param ProductInterfaceFactory|null $productFactory + * @param SalableProcessor|null $salableProcessor * @param ProductAttributeRepositoryInterface|null $productAttributeRepository * @param SearchCriteriaBuilder|null $searchCriteriaBuilder + * @param UploaderFactory|null $uploaderFactory * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -266,7 +268,8 @@ public function __construct( ProductInterfaceFactory $productFactory = null, SalableProcessor $salableProcessor = null, ProductAttributeRepositoryInterface $productAttributeRepository = null, - SearchCriteriaBuilder $searchCriteriaBuilder = null + SearchCriteriaBuilder $searchCriteriaBuilder = null, + UploaderFactory $uploaderFactory = null ) { $this->typeConfigurableFactory = $typeConfigurableFactory; $this->_eavAttributeFactory = $eavAttributeFactory; @@ -295,7 +298,8 @@ public function __construct( $coreRegistry, $logger, $productRepository, - $serializer + $serializer, + $uploaderFactory ); } diff --git a/app/code/Magento/ConfigurableProduct/Model/Quote/Item/CartItemProcessor.php b/app/code/Magento/ConfigurableProduct/Model/Quote/Item/CartItemProcessor.php index 56993ecec1fbf..75592efc52dca 100644 --- a/app/code/Magento/ConfigurableProduct/Model/Quote/Item/CartItemProcessor.php +++ b/app/code/Magento/ConfigurableProduct/Model/Quote/Item/CartItemProcessor.php @@ -5,6 +5,8 @@ */ namespace Magento\ConfigurableProduct\Model\Quote\Item; +use Magento\ConfigurableProduct\Api\Data\ConfigurableItemOptionValueInterface; +use Magento\Quote\Api\Data\ProductOptionExtensionInterface; use Magento\Quote\Model\Quote\Item\CartItemProcessorInterface; use Magento\Quote\Api\Data\CartItemInterface; use Magento\Framework\Serialize\Serializer\Json; @@ -64,7 +66,7 @@ public function __construct( public function convertToBuyRequest(CartItemInterface $cartItem) { if ($cartItem->getProductOption() && $cartItem->getProductOption()->getExtensionAttributes()) { - /** @var \Magento\ConfigurableProduct\Api\Data\ConfigurableItemOptionValueInterface $options */ + /** @var ConfigurableItemOptionValueInterface $options */ $options = $cartItem->getProductOption()->getExtensionAttributes()->getConfigurableItemOptions(); if (is_array($options)) { $requestData = []; @@ -82,13 +84,17 @@ public function convertToBuyRequest(CartItemInterface $cartItem) */ public function processOptions(CartItemInterface $cartItem) { - $attributesOption = $cartItem->getProduct()->getCustomOption('attributes'); + $attributesOption = $cartItem->getProduct() + ->getCustomOption('attributes'); + if (!$attributesOption) { + return $cartItem; + } $selectedConfigurableOptions = $this->serializer->unserialize($attributesOption->getValue()); if (is_array($selectedConfigurableOptions)) { $configurableOptions = []; foreach ($selectedConfigurableOptions as $optionId => $optionValue) { - /** @var \Magento\ConfigurableProduct\Api\Data\ConfigurableItemOptionValueInterface $option */ + /** @var ConfigurableItemOptionValueInterface $option */ $option = $this->itemOptionValueFactory->create(); $option->setOptionId($optionId); $option->setOptionValue($optionValue); @@ -99,8 +105,8 @@ public function processOptions(CartItemInterface $cartItem) ? $cartItem->getProductOption() : $this->productOptionFactory->create(); - /** @var \Magento\Quote\Api\Data\ProductOptionExtensionInterface $extensibleAttribute */ - $extensibleAttribute = $productOption->getExtensionAttributes() + /** @var ProductOptionExtensionInterface $extensibleAttribute */ + $extensibleAttribute = $productOption->getExtensionAttributes() ? $productOption->getExtensionAttributes() : $this->extensionFactory->create(); @@ -108,6 +114,7 @@ public function processOptions(CartItemInterface $cartItem) $productOption->setExtensionAttributes($extensibleAttribute); $cartItem->setProductOption($productOption); } + return $cartItem; } } diff --git a/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/BaseStockStatusSelectProcessor.php b/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/BaseStockStatusSelectProcessor.php new file mode 100644 index 0000000000000..cbeaf2cea90e0 --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/BaseStockStatusSelectProcessor.php @@ -0,0 +1,68 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\ConfigurableProduct\Model\ResourceModel\Product\Indexer\Price; + +use Magento\CatalogInventory\Model\Stock; +use Magento\Framework\App\ResourceConnection; +use Magento\Framework\DB\Select; +use Magento\Catalog\Model\ResourceModel\Product\BaseSelectProcessorInterface; +use Magento\CatalogInventory\Api\StockConfigurationInterface; + +/** + * A Select object processor. + * + * Adds stock status limitations to a given Select object. + */ +class BaseStockStatusSelectProcessor implements BaseSelectProcessorInterface +{ + /** + * @var ResourceConnection + */ + private $resource; + + /** + * @var StockConfigurationInterface + */ + private $stockConfig; + + /** + * @param ResourceConnection $resource + * @param StockConfigurationInterface $stockConfig + */ + public function __construct( + ResourceConnection $resource, + StockConfigurationInterface $stockConfig + ) { + $this->resource = $resource; + $this->stockConfig = $stockConfig; + } + + /** + * @inheritdoc + */ + public function process(Select $select) + { + // Does not make sense to extend query if out of stock products won't appear in tables for indexing + if ($this->stockConfig->isShowOutOfStock()) { + $select->join( + ['si' => $this->resource->getTableName('cataloginventory_stock_item')], + 'si.product_id = l.product_id', + [] + ); + $select->join( + ['si_parent' => $this->resource->getTableName('cataloginventory_stock_item')], + 'si_parent.product_id = l.parent_id', + [] + ); + $select->where('si.is_in_stock = ?', Stock::STOCK_IN_STOCK); + $select->orWhere('si_parent.is_in_stock = ?', Stock::STOCK_OUT_OF_STOCK); + } + + return $select; + } +} 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 6031ab6f8f8ae..d00e5c72a4622 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 @@ -3,9 +3,13 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); + namespace Magento\ConfigurableProduct\Model\ResourceModel\Product\Indexer\Price; +use Magento\Catalog\Model\ResourceModel\Product\BaseSelectProcessorInterface; use Magento\Catalog\Model\ResourceModel\Product\Indexer\Price\BasePriceModifier; +use Magento\Framework\DB\Select; use Magento\Framework\Indexer\DimensionalIndexerInterface; use Magento\Framework\EntityManager\MetadataPool; use Magento\Catalog\Model\Indexer\Product\Price\TableMaintainer; @@ -13,10 +17,8 @@ use Magento\Catalog\Model\ResourceModel\Product\Indexer\Price\IndexTableStructureFactory; use Magento\Catalog\Model\ResourceModel\Product\Indexer\Price\IndexTableStructure; use Magento\Framework\App\Config\ScopeConfigInterface; -use Magento\Store\Model\ScopeInterface; use Magento\Framework\App\ObjectManager; use Magento\CatalogInventory\Model\Stock; -use Magento\CatalogInventory\Model\Configuration; /** * Configurable Products Price Indexer Resource model @@ -75,6 +77,11 @@ class Configurable implements DimensionalIndexerInterface */ private $scopeConfig; + /** + * @var BaseSelectProcessorInterface + */ + private $baseSelectProcessor; + /** * @param BaseFinalPrice $baseFinalPrice * @param IndexTableStructureFactory $indexTableStructureFactory @@ -85,6 +92,9 @@ class Configurable implements DimensionalIndexerInterface * @param bool $fullReindexAction * @param string $connectionName * @param ScopeConfigInterface $scopeConfig + * @param BaseSelectProcessorInterface|null $baseSelectProcessor + * + * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( BaseFinalPrice $baseFinalPrice, @@ -95,7 +105,8 @@ public function __construct( BasePriceModifier $basePriceModifier, $fullReindexAction = false, $connectionName = 'indexer', - ScopeConfigInterface $scopeConfig = null + ScopeConfigInterface $scopeConfig = null, + ?BaseSelectProcessorInterface $baseSelectProcessor = null ) { $this->baseFinalPrice = $baseFinalPrice; $this->indexTableStructureFactory = $indexTableStructureFactory; @@ -106,6 +117,8 @@ public function __construct( $this->fullReindexAction = $fullReindexAction; $this->basePriceModifier = $basePriceModifier; $this->scopeConfig = $scopeConfig ?: ObjectManager::getInstance()->get(ScopeConfigInterface::class); + $this->baseSelectProcessor = $baseSelectProcessor ?: + ObjectManager::getInstance()->get(BaseSelectProcessorInterface::class); } /** @@ -198,15 +211,7 @@ private function fillTemporaryOptionsTable(string $temporaryOptionsTableName, ar [] ); - // Does not make sense to extend query if out of stock products won't appear in tables for indexing - if ($this->isConfigShowOutOfStock()) { - $select->join( - ['si' => $this->getTable('cataloginventory_stock_item')], - 'si.product_id = l.product_id', - [] - ); - $select->where('si.is_in_stock = ?', Stock::STOCK_IN_STOCK); - } + $this->baseSelectProcessor->process($select); $select->columns( [ @@ -295,17 +300,4 @@ private function getTable($tableName) { return $this->resource->getTableName($tableName, $this->connectionName); } - - /** - * Is flag Show Out Of Stock setted - * - * @return bool - */ - private function isConfigShowOutOfStock(): bool - { - return $this->scopeConfig->isSetFlag( - Configuration::XML_PATH_SHOW_OUT_OF_STOCK, - ScopeInterface::SCOPE_STORE - ); - } } diff --git a/app/code/Magento/ConfigurableProduct/Plugin/Model/Order/Invoice/UpdateConfigurableProductTotalQty.php b/app/code/Magento/ConfigurableProduct/Plugin/Model/Order/Invoice/UpdateConfigurableProductTotalQty.php new file mode 100644 index 0000000000000..28237ca71b07a --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Plugin/Model/Order/Invoice/UpdateConfigurableProductTotalQty.php @@ -0,0 +1,43 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\ConfigurableProduct\Plugin\Model\Order\Invoice; + +use Magento\ConfigurableProduct\Model\Product\Type\Configurable; +use Magento\Sales\Model\Order\Invoice; + +/** + * Update total quantity for configurable product invoice + */ +class UpdateConfigurableProductTotalQty +{ + /** + * Set total quantity for configurable product invoice + * + * @param Invoice $invoice + * @param float $totalQty + * @return float + */ + public function beforeSetTotalQty( + Invoice $invoice, + float $totalQty + ): float { + $order = $invoice->getOrder(); + $productTotalQty = 0; + $hasConfigurableProduct = false; + foreach ($order->getAllItems() as $orderItem) { + if ($orderItem->getParentItemId() === null && + $orderItem->getProductType() == Configurable::TYPE_CODE + ) { + $hasConfigurableProduct = true; + continue; + } + $productTotalQty += (float) $orderItem->getQtyOrdered(); + } + return $hasConfigurableProduct ? $productTotalQty : $totalQty; + } +} diff --git a/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/StorefrontAssertExcludingTierPriceActionGroup.xml b/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/StorefrontAssertExcludingTierPriceActionGroup.xml new file mode 100644 index 0000000000000..d609fb0346900 --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/StorefrontAssertExcludingTierPriceActionGroup.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="StorefrontAssertExcludingTierPriceActionGroup"> + <annotations> + <description>Assert product item tier price excluding price.</description> + </annotations> + <arguments> + <argument name="excludingPrice" type="string"/> + </arguments> + + <grabTextFrom selector="{{StorefrontProductInfoMainSection.tierPriceExcludingPrice}}" stepKey="tierPriceExcluding"/> + <assertStringContainsString stepKey="assertTierPriceTextOnProductPage"> + <expectedResult type="string">{{excludingPrice}}</expectedResult> + <actualResult type="variable">tierPriceExcluding</actualResult> + </assertStringContainsString> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/ConfigurableProduct/Test/Mftf/Section/StorefrontProductInfoMainSection.xml b/app/code/Magento/ConfigurableProduct/Test/Mftf/Section/StorefrontProductInfoMainSection.xml index 37c129dc3bbde..460040431bb97 100644 --- a/app/code/Magento/ConfigurableProduct/Test/Mftf/Section/StorefrontProductInfoMainSection.xml +++ b/app/code/Magento/ConfigurableProduct/Test/Mftf/Section/StorefrontProductInfoMainSection.xml @@ -23,5 +23,6 @@ <element name="stockIndication" type="block" selector=".stock" /> <element name="attributeSelectByAttributeID" type="select" selector="//div[@class='fieldset']//div[//span[text()='{{attribute_code}}']]//select" parameterized="true"/> <element name="attributeOptionByAttributeID" type="select" selector="//div[@class='fieldset']//div[//span[text()='{{attribute_code}}']]//option[text()='{{optionName}}']" parameterized="true"/> + <element name="tierPriceExcludingPrice" type="text" selector=".item [data-label='Excl. Tax']"/> </section> </sections> diff --git a/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/AdminConfigurableProductWithTierPriceWithTaxTest.xml b/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/AdminConfigurableProductWithTierPriceWithTaxTest.xml new file mode 100644 index 0000000000000..bbd72bbaa02da --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/AdminConfigurableProductWithTierPriceWithTaxTest.xml @@ -0,0 +1,126 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminConfigurableProductWithTierPriceWithTaxTest"> + <annotations> + <features value="ConfigurableProduct"/> + <stories value="Create configurable product"/> + <title value="Create configurable product with tier price and check excluding tax item price"/> + <description value="Create configurable product with tier price and check excluding tax item price"/> + <severity value="MAJOR"/> + <testCaseId value="MC-37863"/> + <group value="ConfigurableProduct"/> + </annotations> + <before> + <createData entity="productAttributeWithTwoOptionsNotVisible" stepKey="createConfigProductAttribute"/> + <createData entity="productAttributeOption1" stepKey="createConfigProductAttributeOptionOne"> + <requiredEntity createDataKey="createConfigProductAttribute"/> + </createData> + <createData entity="productAttributeOption2" stepKey="createConfigProductAttributeOptionTwo"> + <requiredEntity createDataKey="createConfigProductAttribute"/> + </createData> + <createData entity="AddToDefaultSet" stepKey="createConfigAddToAttributeSet"> + <requiredEntity createDataKey="createConfigProductAttribute"/> + </createData> + <getData entity="ProductAttributeOptionGetter" index="1" stepKey="getConfigAttributeOptionOne"> + <requiredEntity createDataKey="createConfigProductAttribute"/> + </getData> + <getData entity="ProductAttributeOptionGetter" index="2" stepKey="getConfigAttributeOptionTwo"> + <requiredEntity createDataKey="createConfigProductAttribute"/> + </getData> + + <createData entity="ApiSimpleOne" stepKey="createFirstSimpleProduct"> + <requiredEntity createDataKey="createConfigProductAttribute"/> + <requiredEntity createDataKey="getConfigAttributeOptionOne"/> + </createData> + <createData entity="ApiSimpleTwo" stepKey="createSecondSimpleProduct"> + <requiredEntity createDataKey="createConfigProductAttribute"/> + <requiredEntity createDataKey="getConfigAttributeOptionTwo"/> + </createData> + + <createData entity="tierProductPrice" stepKey="addTierPrice"> + <requiredEntity createDataKey="createFirstSimpleProduct" /> + </createData> + + <createData entity="CustomerEntityOne" stepKey="createCustomer"/> + <createData entity="SimpleTaxRule" stepKey="createTaxRule"/> + + <magentoCLI command="config:set tax/display/type 3" stepKey="enableShowIncludingExcludingTax"/> + + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + </before> + <after> + <actionGroup ref="StorefrontCustomerLogoutActionGroup" stepKey="customerLogOut"/> + <actionGroup ref="DeleteProductUsingProductGridActionGroup" stepKey="deleteProduct"> + <argument name="product" value="ApiConfigurableProduct"/> + </actionGroup> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> + + <deleteData createDataKey="createFirstSimpleProduct" stepKey="deleteFirstSimpleProduct"/> + <deleteData createDataKey="createSecondSimpleProduct" stepKey="deleteSecondSimpleProduct"/> + <deleteData createDataKey="createConfigProductAttribute" stepKey="deleteConfigProductAttribute"/> + <deleteData createDataKey="createCustomer" stepKey="deleteCustomer"/> + <deleteData createDataKey="createTaxRule" stepKey="deleteTaxRule"/> + + <magentoCLI command="config:set tax/display/type 0" stepKey="disableShowIncludingExcludingTax"/> + <magentoCron groups="index" stepKey="reindexInvalidatedIndices"/> + </after> + + <!-- Create configurable product --> + <actionGroup ref="AdminOpenProductIndexPageActionGroup" stepKey="amOnProductGridPage"/> + <actionGroup ref="GoToCreateProductPageActionGroup" stepKey="createConfigurableProduct"> + <argument name="product" value="ApiConfigurableProduct"/> + </actionGroup> + + <!-- Fill configurable product values --> + <actionGroup ref="FillMainProductFormActionGroup" stepKey="fillConfigurableProductValues"> + <argument name="product" value="ApiConfigurableProduct"/> + </actionGroup> + + <!-- Create product configurations and add attribute and select all options --> + <actionGroup ref="GenerateConfigurationsByAttributeCodeActionGroup" stepKey="generateConfigurationsByAttributeCode"> + <argument name="attributeCode" value="$$createConfigProductAttribute.attribute_code$$"/> + </actionGroup> + + <!-- Add associated products to configurations grid --> + <actionGroup ref="AddProductToConfigurationsGridActionGroup" stepKey="addFirstSimpleProduct"> + <argument name="sku" value="$$createFirstSimpleProduct.sku$$"/> + <argument name="name" value="$$createConfigProductAttributeOptionOne.option[store_labels][1][label]$$"/> + </actionGroup> + <actionGroup ref="AddProductToConfigurationsGridActionGroup" stepKey="addSecondSimpleProduct"> + <argument name="sku" value="$$createSecondSimpleProduct.sku$$"/> + <argument name="name" value="$$createConfigProductAttributeOptionTwo.option[store_labels][1][label]$$"/> + </actionGroup> + + <!-- Save configurable product --> + <actionGroup ref="SaveProductFormActionGroup" stepKey="saveConfigurableProduct"/> + + <!--Login customer on storefront--> + <actionGroup ref="LoginToStorefrontActionGroup" stepKey="loginCustomer"> + <argument name="Customer" value="$$createCustomer$$" /> + </actionGroup> + + <!-- Assert product tier price on product page --> + <amOnPage url="{{ApiConfigurableProduct.urlKey}}.html" stepKey="amOnProductPage"/> + <waitForPageLoad stepKey="waitForProductPageLoad"/> + <selectOption userInput="$$createConfigProductAttributeOptionOne.option[store_labels][1][label]$$" + selector="{{StorefrontProductInfoMainSection.productAttributeOptionsSelectButton}}" + stepKey="selectOption"/> + + <!-- Assert tier price excluding including price item --> + <actionGroup ref="AssertStorefrontProductDetailPageTierPriceActionGroup" stepKey="assertProductTierPriceExcludingIncludingTax"> + <argument name="tierProductPriceDiscountQuantity" value="2"/> + <argument name="productPriceWithAppliedTierPriceDiscount" value="97.43 ${{tierProductPrice.price}}"/> + <argument name="productSavedPricePercent" value="27"/> + </actionGroup> + <actionGroup ref="StorefrontAssertExcludingTierPriceActionGroup" stepKey="assertTierPriceExcludingPrice"> + <argument name="excludingPrice" value="${{tierProductPrice.price}}" /> + </actionGroup> + </test> +</tests> diff --git a/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/AdminCreateAndSwitchProductType/AdminCreateDownloadableProductSwitchToConfigurableTest.xml b/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/AdminCreateAndSwitchProductType/AdminCreateDownloadableProductSwitchToConfigurableTest.xml index dc3608ec827df..17c7426dc547f 100644 --- a/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/AdminCreateAndSwitchProductType/AdminCreateDownloadableProductSwitchToConfigurableTest.xml +++ b/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/AdminCreateAndSwitchProductType/AdminCreateDownloadableProductSwitchToConfigurableTest.xml @@ -27,7 +27,7 @@ </createData> </before> <after> - <actionGroup ref="GoToProductCatalogPageActionGroup" stepKey="goToProductCatalogPage"/> + <actionGroup ref="AdminOpenCatalogProductPageActionGroup" stepKey="goToProductCatalogPage"/> <actionGroup ref="DeleteProductUsingProductGridActionGroup" stepKey="deleteConfigurableProduct"> <argument name="product" value="_defaultProduct"/> </actionGroup> @@ -62,7 +62,7 @@ <actionGroup ref="SaveConfiguredProductActionGroup" stepKey="saveProductForm"/> <!-- Check that product was added with implicit type change --> <comment stepKey="beforeVerify" userInput="Verify Product Type Assigned Correctly"/> - <actionGroup ref="GoToProductCatalogPageActionGroup" stepKey="goToProductCatalogPage"/> + <actionGroup ref="AdminOpenCatalogProductPageActionGroup" stepKey="goToProductCatalogPage"/> <actionGroup ref="ResetProductGridToDefaultViewActionGroup" stepKey="resetSearch"/> <actionGroup ref="FilterProductGridByNameActionGroup" stepKey="searchForProduct"> <argument name="product" value="_defaultProduct"/> diff --git a/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/AdminProductTypeSwitchingOnEditingTest/AdminConfigurableProductTypeSwitchingToVirtualProductTest.xml b/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/AdminProductTypeSwitchingOnEditingTest/AdminConfigurableProductTypeSwitchingToVirtualProductTest.xml index dd176455a03ba..f287aca332b48 100644 --- a/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/AdminProductTypeSwitchingOnEditingTest/AdminConfigurableProductTypeSwitchingToVirtualProductTest.xml +++ b/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/AdminProductTypeSwitchingOnEditingTest/AdminConfigurableProductTypeSwitchingToVirtualProductTest.xml @@ -36,7 +36,7 @@ <actionGroup ref="SaveProductFormActionGroup" stepKey="saveVirtualProductForm"/> <!--Assert virtual product on Admin product page grid--> <comment userInput="Assert virtual product on Admin product page grid" stepKey="commentAssertVirtualProductOnAdmin"/> - <amOnPage url="{{AdminCatalogProductPage.url}}" stepKey="goToCatalogProductPageForVirtual"/> + <actionGroup ref="AdminOpenCatalogProductPageActionGroup" stepKey="goToCatalogProductPageForVirtual"/> <actionGroup ref="FilterProductGridBySku2ActionGroup" stepKey="filterProductGridBySkuForVirtual"> <argument name="sku" value="$createProduct.sku$"/> </actionGroup> diff --git a/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/AdminProductTypeSwitchingOnEditingTest/AdminSimpleProductTypeSwitchingToConfigurableProductTest.xml b/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/AdminProductTypeSwitchingOnEditingTest/AdminSimpleProductTypeSwitchingToConfigurableProductTest.xml index 14979f93ca423..f3b79765f746d 100644 --- a/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/AdminProductTypeSwitchingOnEditingTest/AdminSimpleProductTypeSwitchingToConfigurableProductTest.xml +++ b/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/AdminProductTypeSwitchingOnEditingTest/AdminSimpleProductTypeSwitchingToConfigurableProductTest.xml @@ -61,7 +61,7 @@ <actionGroup ref="SaveConfiguredProductActionGroup" stepKey="saveConfigProductForm"/> <!--Assert configurable product on Admin product page grid--> <comment userInput="Assert configurable product in Admin product page grid" stepKey="commentAssertConfigProductOnAdmin"/> - <amOnPage url="{{AdminCatalogProductPage.url}}" stepKey="goToCatalogProductPage"/> + <actionGroup ref="AdminOpenCatalogProductPageActionGroup" stepKey="goToCatalogProductPage"/> <actionGroup ref="FilterProductGridBySku2ActionGroup" stepKey="filterProductGridBySku"> <argument name="sku" value="$createProduct.sku$"/> </actionGroup> diff --git a/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/AdminProductTypeSwitchingOnEditingTest/AdminVirtualProductTypeSwitchingToConfigurableProductTest.xml b/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/AdminProductTypeSwitchingOnEditingTest/AdminVirtualProductTypeSwitchingToConfigurableProductTest.xml index e986ea38f0fe1..bb5baf33d95fb 100644 --- a/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/AdminProductTypeSwitchingOnEditingTest/AdminVirtualProductTypeSwitchingToConfigurableProductTest.xml +++ b/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/AdminProductTypeSwitchingOnEditingTest/AdminVirtualProductTypeSwitchingToConfigurableProductTest.xml @@ -60,7 +60,7 @@ <actionGroup ref="SaveConfiguredProductActionGroup" stepKey="saveNewConfigurableProductForm"/> <!--Assert configurable product on Admin product page grid--> <comment userInput="Assert configurable product in Admin product page grid" stepKey="commentAssertConfigurableProductOnAdmin"/> - <amOnPage url="{{AdminCatalogProductPage.url}}" stepKey="goToCatalogProductPageForConfigurable"/> + <actionGroup ref="AdminOpenCatalogProductPageActionGroup" stepKey="goToCatalogProductPageForConfigurable"/> <actionGroup ref="FilterProductGridBySku2ActionGroup" stepKey="filterProductGridBySkuForConfigurable"> <argument name="sku" value="$$createProduct.sku$$"/> </actionGroup> diff --git a/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/NoOptionAvailableToConfigureDisabledProductTest.xml b/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/NoOptionAvailableToConfigureDisabledProductTest.xml index b87ddf612be19..aa2c19ebc17f4 100644 --- a/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/NoOptionAvailableToConfigureDisabledProductTest.xml +++ b/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/NoOptionAvailableToConfigureDisabledProductTest.xml @@ -135,7 +135,8 @@ <click selector="{{AdminOrderFormConfigureProductSection.configure($$createConfigProduct.id$$)}}" stepKey="clickOnConfigure"/> <!-- Click on attribute drop-down and check no option 1 is available --> <comment userInput="Click on attribute drop-down and check no option 1 is available" stepKey="commentNoOptionIsAvailable"/> - <waitForElement selector="{{AdminOrderFormConfigureProductSection.selectOption}}" stepKey="waitForShippingSectionLoaded"/> + <waitForPageLoad stepKey="waitForConfigure"/> + <waitForElementVisible selector="{{AdminOrderFormConfigureProductSection.selectOption}}" stepKey="waitForShippingSectionLoaded"/> <click selector="{{AdminOrderFormConfigureProductSection.selectOption}}" stepKey="clickToSelectOption"/> <dontSee userInput="$$createConfigProductAttributeOption1.option[store_labels][1][label]$$" stepKey="dontSeeOption1"/> <!-- Go to created customer page again --> diff --git a/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/ProductsQtyReturnAfterOrderCancelTest.xml b/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/ProductsQtyReturnAfterOrderCancelTest.xml index 898e277cff55c..0b7bca201ec32 100644 --- a/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/ProductsQtyReturnAfterOrderCancelTest.xml +++ b/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/ProductsQtyReturnAfterOrderCancelTest.xml @@ -35,7 +35,7 @@ <actionGroup ref="AdminLogoutActionGroup" stepKey="amOnLogoutPage"/> </after> - <amOnPage url="{{AdminCatalogProductPage.url}}" stepKey="GoToCatalogProductPage1"/> + <actionGroup ref="AdminOpenCatalogProductPageActionGroup" stepKey="goToCatalogProductPage1"/> <conditionalClick selector="{{AdminProductGridFilterSection.clearFilters}}" dependentSelector="{{AdminProductGridFilterSection.clearFilters}}" visible="true" stepKey="clickClearFiltersInitial"/> <actionGroup ref="OpenEditProductOnBackendActionGroup" stepKey="openEditProduct"> @@ -70,8 +70,7 @@ </actionGroup> <actionGroup ref="AdminOrderGridClickFirstRowActionGroup" stepKey="clickOrderRow"/> - <click selector="{{AdminOrderDetailsMainActionsSection.invoice}}" stepKey="clickInvoiceButton"/> - <waitForPageLoad stepKey="waitForNewInvoicePageLoad"/> + <actionGroup ref="AdminClickInvoiceButtonOrderViewActionGroup" stepKey="clickInvoiceButton"/> <fillField selector="{{AdminInvoiceItemsSection.qtyToInvoiceColumn}}" userInput="1" stepKey="ChangeQtyToInvoice"/> <click selector="{{AdminInvoiceItemsSection.updateQty}}" stepKey="updateQuantity"/> <waitForPageLoad stepKey="waitPageToBeLoaded"/> @@ -87,7 +86,7 @@ <see selector="{{AdminOrderItemsOrderedSection.itemQty('1')}}" userInput="Canceled 3" stepKey="seeCanceledQuantity"/> - <amOnPage url="{{AdminCatalogProductPage.url}}" stepKey="GoToCatalogProductPage"/> + <actionGroup ref="AdminOpenCatalogProductPageActionGroup" stepKey="goToCatalogProductPage"/> <actionGroup ref="FilterProductGridBySku2ActionGroup" stepKey="filterProductGridBySku"> <argument name="sku" value="$$createConfigProduct.sku$$"/> diff --git a/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/StorefrontVisibilityOfDuplicateProductTest.xml b/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/StorefrontVisibilityOfDuplicateProductTest.xml index 976be77122547..79705e679fb78 100644 --- a/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/StorefrontVisibilityOfDuplicateProductTest.xml +++ b/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/StorefrontVisibilityOfDuplicateProductTest.xml @@ -62,8 +62,9 @@ <argument name="attributeType" value="{{colorProductAttribute.input_type}}"/> <argument name="scope" value="Global"/> </actionGroup> - <reloadPage stepKey="reloadPage"/> - <waitForPageLoad stepKey="waitForProductPageReload"/> + <actionGroup ref="ReloadPageActionGroup" stepKey="reloadPage"/> + <comment userInput="Replacing reload action and preserve Backward Compatibility" stepKey="waitForProductPageReload"/> + <click selector="{{AdminProductFormConfigurationsSection.createConfigurations}}" stepKey="clickOnCreateConfigurations"/> <waitForPageLoad stepKey="waitForFilters"/> <actionGroup ref="CreateOptionsForAttributeActionGroup" stepKey="createOptions"> @@ -142,8 +143,9 @@ <argument name="attributeType" value="{{productAttributeColor.input_type}}"/> <argument name="scope" value="Global"/> </actionGroup> - <reloadPage stepKey="reloadDuplicatedProductPage"/> - <waitForPageLoad stepKey="waitForDuplicatedProductReload"/> + <actionGroup ref="ReloadPageActionGroup" stepKey="reloadDuplicatedProductPage"/> + <comment userInput="Replacing reload action and preserve Backward Compatibility" stepKey="waitForDuplicatedProductReload"/> + <click selector="{{AdminProductFormConfigurationsSection.createConfigurations}}" stepKey="createConfigurationsDuplicatedProduct"/> <waitForElementVisible selector="{{AdminGridSelectRows.multicheckDropdown}}" stepKey="waitForCreateConfigurationsPageLoad"/> <click selector="{{AdminGridSelectRows.multicheckDropdown}}" stepKey="openMulticheckDropdown"/> diff --git a/app/code/Magento/ConfigurableProduct/Test/Unit/Helper/DataTest.php b/app/code/Magento/ConfigurableProduct/Test/Unit/Helper/DataTest.php index db72e1ca6ab4c..d1cd57e59ffe6 100644 --- a/app/code/Magento/ConfigurableProduct/Test/Unit/Helper/DataTest.php +++ b/app/code/Magento/ConfigurableProduct/Test/Unit/Helper/DataTest.php @@ -49,7 +49,7 @@ protected function setUp(): void ->getMock(); $this->_imageHelperMock = $this->createMock(Image::class); $this->_productMock = $this->createMock(Product::class); - + $this->_productMock->setTypeId(Configurable::TYPE_CODE); $this->_model = $objectManager->getObject( Data::class, [ @@ -66,6 +66,10 @@ public function testGetAllowAttributes() ->method('getConfigurableAttributes') ->with($this->_productMock); + $this->_productMock->expects($this->once()) + ->method('getTypeId') + ->willReturn(Configurable::TYPE_CODE); + $this->_productMock->expects($this->once()) ->method('getTypeInstance') ->willReturn($typeInstanceMock); @@ -114,12 +118,16 @@ public function testGetOptions(array $expected, array $data) /** * @return array + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ - public function getOptionsDataProvider() + public function getOptionsDataProvider(): array { $currentProductMock = $this->createPartialMock( Product::class, - ['getTypeInstance'] + [ + 'getTypeInstance', + 'getTypeId' + ] ); $provider = []; $provider[] = [ @@ -156,6 +164,9 @@ public function getOptionsDataProvider() $typeInstanceMock->expects($this->any()) ->method('getConfigurableAttributes') ->willReturn($attributes); + $currentProductMock->expects($this->any()) + ->method('getTypeId') + ->willReturn(Configurable::TYPE_CODE); $currentProductMock->expects($this->any()) ->method('getTypeInstance') ->willReturn($typeInstanceMock); @@ -215,7 +226,7 @@ public function getOptionsDataProvider() * @param string $key * @return string */ - public function getDataCallback($key) + public function getDataCallback($key): string { $map = []; for ($k = 1; $k < 3; $k++) { @@ -279,7 +290,7 @@ public function testGetGalleryImages() /** * @return Collection */ - private function getImagesCollection() + private function getImagesCollection(): MockObject { $collectionMock = $this->getMockBuilder(Collection::class) ->disableOriginalConstructor() diff --git a/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Quote/Item/CartItemProcessorTest.php b/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Quote/Item/CartItemProcessorTest.php index 10f5b1cbb344a..cd68e1dcfce24 100644 --- a/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Quote/Item/CartItemProcessorTest.php +++ b/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Quote/Item/CartItemProcessorTest.php @@ -59,7 +59,7 @@ class CartItemProcessorTest extends TestCase */ private $productOptionExtensionAttributes; - /** @var \PHPUnit\Framework\MockObject\MockObject */ + /** @var MockObject */ private $serializer; protected function setUp(): void @@ -263,4 +263,25 @@ public function testProcessProductOptionsIfOptionsExist() $this->assertEquals($cartItemMock, $this->model->processOptions($cartItemMock)); } + + /** + * Checks processOptions method with the empty custom option + * + * @return void + */ + public function testProcessProductWithEmptyOption(): void + { + $customOption = $this->createMock(Option::class); + $productMock = $this->createMock(Product::class); + $productMock->method('getCustomOption') + ->with('attributes') + ->willReturn(null); + $customOption->expects($this->never()) + ->method('getValue'); + $cartItemMock = $this->createPartialMock(Item::class, ['getProduct']); + $cartItemMock->expects($this->once()) + ->method('getProduct') + ->willReturn($productMock); + $this->assertEquals($cartItemMock, $this->model->processOptions($cartItemMock)); + } } diff --git a/app/code/Magento/ConfigurableProduct/Test/Unit/Plugin/Model/Order/Invoice/UpdateConfigurableProductTotalQtyTest.php b/app/code/Magento/ConfigurableProduct/Test/Unit/Plugin/Model/Order/Invoice/UpdateConfigurableProductTotalQtyTest.php new file mode 100644 index 0000000000000..bff629fd94ac2 --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Test/Unit/Plugin/Model/Order/Invoice/UpdateConfigurableProductTotalQtyTest.php @@ -0,0 +1,182 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\ConfigurableProduct\Test\Unit\Plugin\Model\Order\Invoice; + +use Magento\Bundle\Model\Product\Type as Bundle; +use Magento\ConfigurableProduct\Model\Product\Type\Configurable; +use Magento\ConfigurableProduct\Plugin\Model\Order\Invoice\UpdateConfigurableProductTotalQty; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager as ObjectManagerHelper; +use Magento\Sales\Model\Order\Invoice; +use Magento\Sales\Model\Order; +use Magento\Sales\Model\Order\Item; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * Test for class UpdateConfigurableProductTotalQty. + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ +class UpdateConfigurableProductTotalQtyTest extends TestCase +{ + /** + * @var UpdateConfigurableProductTotalQty + */ + private $model; + + /** + * @var ObjectManagerHelper|null + */ + private $objectManagerHelper; + + /** + * @var Invoice|MockObject + */ + private $invoiceMock; + + /** + * @var Order|MockObject + */ + private $orderMock; + + /** + * @var Item[]|MockObject + */ + private $orderItemsMock; + + protected function setUp(): void + { + $this->invoiceMock = $this->createMock(Invoice::class); + $this->orderMock = $this->createMock(Order::class); + $this->orderItemsMock = $this->getMockBuilder(Item::class) + ->disableOriginalConstructor() + ->getMock(); + $this->objectManagerHelper = new ObjectManagerHelper($this); + $this->model = $this->objectManagerHelper->getObject( + UpdateConfigurableProductTotalQty::class, + [] + ); + } + + /** + * Test Set total quantity for configurable product invoice + * + * @param array $orderItems + * @param float $totalQty + * @param float $productTotalQty + * @dataProvider getOrdersForConfigurableProducts + */ + public function testBeforeSetTotalQty( + array $orderItems, + float $totalQty, + float $productTotalQty + ): void { + $this->invoiceMock->expects($this->any()) + ->method('getOrder') + ->willReturn($this->orderMock); + $this->orderMock->expects($this->any()) + ->method('getAllItems') + ->willReturn($orderItems); + $expectedQty= $this->model->beforeSetTotalQty($this->invoiceMock, $totalQty); + $this->assertEquals($expectedQty, $productTotalQty); + } + + /** + * DataProvider for beforeSetTotalQty. + * + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + * @return array + */ + public function getOrdersForConfigurableProducts(): array + { + + return [ + 'verify productQty for simple products' => [ + 'orderItems' => $this->getOrderItems( + [ + [ + 'parent_item_id' => null, + 'product_type' => 'simple', + 'qty_ordered' => 10 + ] + ] + ), + 'totalQty' => 10.00, + 'productTotalQty' => 10.00 + ], + 'verify productQty for configurable products' => [ + 'orderItems' => $this->getOrderItems( + [ + [ + 'parent_item_id' => '2', + 'product_type' => Configurable::TYPE_CODE, + 'qty_ordered' => 10 + ] + ] + ), + 'totalQty' => 10.00, + 'productTotalQty' => 10.00 + ], + 'verify productQty for simple configurable products' => [ + 'orderItems' => $this->getOrderItems( + [ + [ + 'parent_item_id' => null, + 'product_type' => 'simple', + 'qty_ordered' => 10 + ], + [ + 'parent_item_id' => '2', + 'product_type' => Configurable::TYPE_CODE, + 'qty_ordered' => 10 + ], + [ + 'parent_item_id' => '2', + 'product_type' => Bundle::TYPE_CODE, + 'qty_ordered' => 10 + ] + ] + ), + 'totalQty' => 30.00, + 'productTotalQty' => 30.00 + ] + ]; + } + + /** + * Get Order Items. + * + * @param array $orderItems + * @return array + */ + public function getOrderItems(array $orderItems): array + { + $orderItemsMock = []; + foreach ($orderItems as $key => $orderItem) { + $orderItemsMock[$key] = $this->getMockBuilder(Item::class) + ->disableOriginalConstructor() + ->getMock(); + $orderItemsMock[$key]->expects($this->any()) + ->method('getParentItemId') + ->willReturn($orderItem['parent_item_id']); + $orderItemsMock[$key]->expects($this->any()) + ->method('getProductType') + ->willReturn($orderItem['product_type']); + $orderItemsMock[$key]->expects($this->any()) + ->method('getQtyOrdered') + ->willReturn($orderItem['qty_ordered']); + } + return $orderItemsMock; + } + + protected function tearDown(): void + { + unset($this->invoiceMock); + unset($this->orderMock); + unset($this->orderItemsMock); + } +} diff --git a/app/code/Magento/ConfigurableProduct/etc/adminhtml/di.xml b/app/code/Magento/ConfigurableProduct/etc/adminhtml/di.xml index de6765138fce6..60ad9e03fc17e 100644 --- a/app/code/Magento/ConfigurableProduct/etc/adminhtml/di.xml +++ b/app/code/Magento/ConfigurableProduct/etc/adminhtml/di.xml @@ -78,4 +78,7 @@ </argument> </arguments> </virtualType> + <type name="Magento\Sales\Model\Order\Invoice"> + <plugin name="update_configurable_product_total_qty" type="Magento\ConfigurableProduct\Plugin\Model\Order\Invoice\UpdateConfigurableProductTotalQty"/> + </type> </config> diff --git a/app/code/Magento/ConfigurableProduct/etc/di.xml b/app/code/Magento/ConfigurableProduct/etc/di.xml index c8a278df92dc6..c7f67a69d669f 100644 --- a/app/code/Magento/ConfigurableProduct/etc/di.xml +++ b/app/code/Magento/ConfigurableProduct/etc/di.xml @@ -198,6 +198,7 @@ <arguments> <argument name="tableStrategy" xsi:type="object">Magento\Catalog\Model\ResourceModel\Product\Indexer\TemporaryTableStrategy</argument> <argument name="connectionName" xsi:type="string">indexer</argument> + <argument name="baseSelectProcessor" xsi:type="object">Magento\ConfigurableProduct\Model\ResourceModel\Product\Indexer\Price\BaseStockStatusSelectProcessor</argument> </arguments> </type> <type name="Magento\ConfigurableProduct\Plugin\Model\ResourceModel\Product"> diff --git a/app/code/Magento/ConfigurableProduct/etc/frontend/di.xml b/app/code/Magento/ConfigurableProduct/etc/frontend/di.xml index f60234453dc60..3942ec52cbb8b 100644 --- a/app/code/Magento/ConfigurableProduct/etc/frontend/di.xml +++ b/app/code/Magento/ConfigurableProduct/etc/frontend/di.xml @@ -12,5 +12,6 @@ </type> <type name="Magento\ConfigurableProduct\Model\Product\Type\Configurable"> <plugin name="used_products_cache" type="Magento\ConfigurableProduct\Model\Plugin\Frontend\UsedProductsCache" /> + <plugin name="used_products_website_filter" type="Magento\ConfigurableProduct\Model\Plugin\Frontend\UsedProductsWebsiteFilter" /> </type> </config> diff --git a/app/code/Magento/ConfigurableProduct/view/adminhtml/ui_component/configurable_associated_product_listing.xml b/app/code/Magento/ConfigurableProduct/view/adminhtml/ui_component/configurable_associated_product_listing.xml index 2a40caaabae04..c23141d44a2ec 100644 --- a/app/code/Magento/ConfigurableProduct/view/adminhtml/ui_component/configurable_associated_product_listing.xml +++ b/app/code/Magento/ConfigurableProduct/view/adminhtml/ui_component/configurable_associated_product_listing.xml @@ -58,7 +58,7 @@ <label translate="true">Status</label> <dataScope>status</dataScope> <imports> - <link name="visible">componentType = column, index = ${ $.index }:visible</link> + <link name="visible">ns = ${ $.ns }, index = ${ $.index }:visible</link> </imports> </settings> </filterSelect> diff --git a/app/code/Magento/ConfigurableProduct/view/base/templates/product/price/tier_price.phtml b/app/code/Magento/ConfigurableProduct/view/base/templates/product/price/tier_price.phtml index c68419b955e6d..7ce1fd2ccb451 100644 --- a/app/code/Magento/ConfigurableProduct/view/base/templates/product/price/tier_price.phtml +++ b/app/code/Magento/ConfigurableProduct/view/base/templates/product/price/tier_price.phtml @@ -6,22 +6,34 @@ ?> <script type="text/x-magento-template" id="tier-prices-template"> <ul class="prices-tier items"> + <% var exclPrice = ' <span class="price-wrapper price-excluding-tax"' + + 'data-label="<?= $block->escapeHtml(__('Excl. Tax')) ?>">' + + '<span class="price"> %1</span>' + + '</span>' + %> + <% _.each(tierPrices, function(item, key) { %> - <% var priceStr = '<span class="price-container price-tier_price">' - + '<span data-price-amount="' + priceUtils.formatPrice(item.price, currencyFormat) + '"' - + ' data-price-type=""' + ' class="price-wrapper ">' - + '<span class="price">' + priceUtils.formatPrice(item.price, currencyFormat) + '</span>' - + '</span>' - + '</span>'; %> - <li class="item"> - <%= '<?= $block->escapeHtml(__('Buy %1 for %2 each and', '%1', '%2')) ?>' - .replace('%1', item.qty) - .replace('%2', priceStr) %> - <strong class="benefit"> - <?= $block->escapeHtml(__('save')) ?><span + <% var itemExclPrice = item.hasOwnProperty('excl_tax_price') + ? exclPrice.replace('%1', priceUtils.formatPrice(item['excl_tax_price'], currencyFormat)) + : '' + %> + + <% var priceStr = '<span class="price-container price-tier_price">' + + '<span data-price-amount="' + priceUtils.formatPrice(item.price, currencyFormat) + '"' + + ' data-price-type=""' + ' class="price-wrapper price-including-tax">' + + '<span class="price">' + priceUtils.formatPrice(item.price, currencyFormat) + '</span>' + + '</span>' + itemExclPrice + '</span>'; + %> + <li class="item"> + <%= '<?= $block->escapeHtml(__('Buy %1 for %2 each and', '%1', '%2')) ?>' + .replace('%1', item.qty) + .replace('%2', priceStr) + %> + <strong class="benefit"> + <?= $block->escapeHtml(__('save')) ?><span class="percent tier-<%= key %>"> <%= item.percentage %></span>% - </strong> - </li> + </strong> + </li> <% }); %> </ul> </script> diff --git a/app/code/Magento/ConfigurableProductGraphQl/Model/Cart/BuyRequest/SuperAttributeDataProvider.php b/app/code/Magento/ConfigurableProductGraphQl/Model/Cart/BuyRequest/SuperAttributeDataProvider.php index 4a613254ddf84..0fa4b8da50817 100644 --- a/app/code/Magento/ConfigurableProductGraphQl/Model/Cart/BuyRequest/SuperAttributeDataProvider.php +++ b/app/code/Magento/ConfigurableProductGraphQl/Model/Cart/BuyRequest/SuperAttributeDataProvider.php @@ -8,14 +8,17 @@ namespace Magento\ConfigurableProductGraphQl\Model\Cart\BuyRequest; use Magento\Catalog\Api\Data\ProductInterface; +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\CatalogInventory\Api\StockStateInterface; +use Magento\ConfigurableProductGraphQl\Model\Options\Collection as OptionCollection; +use Magento\Framework\EntityManager\MetadataPool; +use Magento\Framework\Exception\LocalizedException; use Magento\Framework\Exception\NoSuchEntityException; use Magento\Framework\GraphQl\Exception\GraphQlInputException; use Magento\Framework\GraphQl\Exception\GraphQlNoSuchEntityException; use Magento\Framework\Stdlib\ArrayManager; +use Magento\Quote\Model\Quote; use Magento\QuoteGraphQl\Model\Cart\BuyRequest\BuyRequestDataProviderInterface; -use Magento\Catalog\Api\ProductRepositoryInterface; -use Magento\ConfigurableProductGraphQl\Model\Options\Collection as OptionCollection; -use Magento\Framework\EntityManager\MetadataPool; /** * DataProvider for building super attribute options in buy requests @@ -42,22 +45,30 @@ class SuperAttributeDataProvider implements BuyRequestDataProviderInterface */ private $metadataPool; + /** + * @var StockStateInterface + */ + private $stockState; + /** * @param ArrayManager $arrayManager * @param ProductRepositoryInterface $productRepository * @param OptionCollection $optionCollection * @param MetadataPool $metadataPool + * @param StockStateInterface $stockState */ public function __construct( ArrayManager $arrayManager, ProductRepositoryInterface $productRepository, OptionCollection $optionCollection, - MetadataPool $metadataPool + MetadataPool $metadataPool, + StockStateInterface $stockState ) { $this->arrayManager = $arrayManager; $this->productRepository = $productRepository; $this->optionCollection = $optionCollection; $this->metadataPool = $metadataPool; + $this->stockState = $stockState; } /** @@ -70,13 +81,20 @@ public function execute(array $cartItemData): array return []; } $sku = $this->arrayManager->get('data/sku', $cartItemData); - + $qty = $this->arrayManager->get('data/quantity', $cartItemData); + $cart = $this->arrayManager->get('model', $cartItemData); + if (!$cart instanceof Quote) { + throw new LocalizedException(__('"model" value should be specified')); + } try { $parentProduct = $this->productRepository->get($parentSku); $product = $this->productRepository->get($sku); } catch (NoSuchEntityException $e) { throw new GraphQlNoSuchEntityException(__('Could not find specified product.')); } + + $this->checkProductStock($sku, (float) $qty, (int) $cart->getStoreId()); + $configurableProductLinks = $parentProduct->getExtensionAttributes()->getConfigurableProductLinks(); if (!in_array($product->getId(), $configurableProductLinks)) { throw new GraphQlInputException(__('Could not find specified product.')); @@ -95,6 +113,47 @@ public function execute(array $cartItemData): array } } } + $this->checkSuperAttributeData($parentSku, $superAttributesData); + return ['super_attribute' => $superAttributesData]; } + + /** + * Stock check for a product + * + * @param string $sku + * @param float $qty + * @param int $scopeId + */ + private function checkProductStock(string $sku, float $qty, int $scopeId): void + { + // Child stock check has to be performed a catalog by default would not show/check it + $childProduct = $this->productRepository->get($sku, false, null, true); + + $result = $this->stockState->checkQuoteItemQty($childProduct->getId(), $qty, $qty, $qty, $scopeId); + + if ($result->getHasError()) { + throw new LocalizedException( + __($result->getMessage()) + ); + } + } + + /** + * Check super attribute data. + * + * Some options might be disabled and/or available when parent and child sku are provided. + * + * @param string $parentSku + * @param array $superAttributesData + * @throws LocalizedException + */ + private function checkSuperAttributeData(string $parentSku, array $superAttributesData): void + { + if (empty($superAttributesData)) { + throw new LocalizedException( + __('The product with SKU %sku is out of stock.', ['sku' => $parentSku]) + ); + } + } } diff --git a/app/code/Magento/ConfigurableProductGraphQl/Model/Options/DataProvider/Variant.php b/app/code/Magento/ConfigurableProductGraphQl/Model/Options/DataProvider/Variant.php new file mode 100644 index 0000000000000..80fbdc76bacb3 --- /dev/null +++ b/app/code/Magento/ConfigurableProductGraphQl/Model/Options/DataProvider/Variant.php @@ -0,0 +1,67 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\ConfigurableProductGraphQl\Model\Options\DataProvider; + +use Magento\Catalog\Api\Data\ProductInterface; +use Magento\CatalogInventory\Model\ResourceModel\Stock\StatusFactory; +use Magento\ConfigurableProduct\Model\Product\Type\Configurable; + +/** + * Retrieve child products + */ +class Variant +{ + /** + * @var Configurable + */ + private $configurableType; + + /** + * @var StatusFactory + */ + private $stockStatusFactory; + + /** + * @param Configurable $configurableType + * @param StatusFactory $stockStatusFactory + */ + public function __construct( + Configurable $configurableType, + StatusFactory $stockStatusFactory + ) { + $this->configurableType = $configurableType; + $this->stockStatusFactory = $stockStatusFactory; + } + + /** + * Load available child products by parent + * + * @param ProductInterface $product + * @return ProductInterface[] + * @throws \Magento\Framework\Exception\LocalizedException + */ + public function getSalableVariantsByParent(ProductInterface $product) + { + $collection = $this->configurableType->getUsedProductCollection($product); + $collection + ->addAttributeToSelect('*') + ->addFilterByRequiredOptions(); + $collection->addMediaGalleryData(); + $collection->addTierPriceData(); + + $stockFlag = 'has_stock_status_filter'; + if (!$collection->hasFlag($stockFlag)) { + $stockStatusResource = $this->stockStatusFactory->create(); + $stockStatusResource->addStockDataToCollection($collection, true); + $collection->setFlag($stockFlag, true); + } + $collection->clear(); + + return $collection->getItems(); + } +} diff --git a/app/code/Magento/ConfigurableProductGraphQl/Model/Options/Metadata.php b/app/code/Magento/ConfigurableProductGraphQl/Model/Options/Metadata.php new file mode 100644 index 0000000000000..9fa6e4f23fa56 --- /dev/null +++ b/app/code/Magento/ConfigurableProductGraphQl/Model/Options/Metadata.php @@ -0,0 +1,173 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\ConfigurableProductGraphQl\Model\Options; + +use Magento\Catalog\Api\Data\ProductInterface; +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\ConfigurableProduct\Helper\Data; +use Magento\ConfigurableProductGraphQl\Model\Options\DataProvider\Variant; +use Magento\Framework\Exception\NoSuchEntityException; + +/** + * Retrieve metadata for configurable option selection. + */ +class Metadata +{ + /** + * @var Data + */ + private $configurableProductHelper; + + /** + * @var SelectionUidFormatter + */ + private $selectionUidFormatter; + + /** + * @var ProductRepositoryInterface + */ + private $productRepository; + + /** + * @var Variant + */ + private $variant; + + /** + * @param Data $configurableProductHelper + * @param SelectionUidFormatter $selectionUidFormatter + * @param ProductRepositoryInterface $productRepository + * @param Variant $variant + */ + public function __construct( + Data $configurableProductHelper, + SelectionUidFormatter $selectionUidFormatter, + ProductRepositoryInterface $productRepository, + Variant $variant + ) { + $this->configurableProductHelper = $configurableProductHelper; + $this->selectionUidFormatter = $selectionUidFormatter; + $this->productRepository = $productRepository; + $this->variant = $variant; + } + + /** + * Load available selections from configurable options. + * + * @param ProductInterface $product + * @param array $selectedOptionsUid + * @return array + * @throws NoSuchEntityException + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + */ + public function getAvailableSelections( + ProductInterface $product, + array $selectedOptionsUid + ): array { + $options = $this->configurableProductHelper->getOptions($product, $this->getAllowProducts($product)); + $selectedOptions = $this->selectionUidFormatter->extract($selectedOptionsUid); + $attributeCodes = $this->getAttributeCodes($product); + $availableSelections = $availableProducts = $variantData = []; + + if (isset($options['index']) && $options['index']) { + foreach ($options['index'] as $productId => $productOptions) { + if (!empty($selectedOptions) && !$this->hasProductRequiredOptions($selectedOptions, $productOptions)) { + continue; + } + + $availableProducts[] = $productId; + foreach ($productOptions as $attributeId => $optionIndex) { + $uid = $this->selectionUidFormatter->encode($attributeId, (int)$optionIndex); + + if (isset($availableSelections[$attributeId]['option_value_uids']) + && in_array($uid, $availableSelections[$attributeId]['option_value_uids']) + ) { + continue; + } + $availableSelections[$attributeId]['option_value_uids'][] = $uid; + $availableSelections[$attributeId]['attribute_code'] = $attributeCodes[$attributeId]; + } + + if ($this->hasSelectionProduct($selectedOptions, $productOptions)) { + $variantProduct = $this->productRepository->getById($productId); + $variantData = $variantProduct->getData(); + $variantData['model'] = $variantProduct; + } + } + } + + return [ + 'options_available_for_selection' => $availableSelections, + 'variant' => $variantData, + 'availableSelectionProducts' => array_unique($availableProducts), + 'product' => $product + ]; + } + + /** + * Get allowed products. + * + * @param ProductInterface $product + * @return ProductInterface[] + */ + public function getAllowProducts(ProductInterface $product): array + { + return $this->variant->getSalableVariantsByParent($product) ?? []; + } + + /** + * Check if a product has the selected options. + * + * @param array $requiredOptions + * @param array $productOptions + * @return bool + */ + private function hasProductRequiredOptions($requiredOptions, $productOptions): bool + { + $result = true; + foreach ($requiredOptions as $attributeId => $optionIndex) { + if (!isset($productOptions[$attributeId]) || !$productOptions[$attributeId] + || $optionIndex != $productOptions[$attributeId] + ) { + $result = false; + break; + } + } + + return $result; + } + + /** + * Check if selected options match a product. + * + * @param array $requiredOptions + * @param array $productOptions + * @return bool + */ + private function hasSelectionProduct($requiredOptions, $productOptions): bool + { + return $this->hasProductRequiredOptions($productOptions, $requiredOptions); + } + + /** + * Retrieve attribute codes + * + * @param ProductInterface $product + * @return string[] + */ + private function getAttributeCodes(ProductInterface $product): array + { + $allowedAttributes = $this->configurableProductHelper->getAllowAttributes($product); + $attributeCodes = []; + foreach ($allowedAttributes as $attribute) { + $attributeCodes[$attribute->getAttributeId()] = $attribute->getProductAttribute()->getAttributeCode(); + } + + return $attributeCodes; + } +} diff --git a/app/code/Magento/ConfigurableProductGraphQl/Model/Options/SelectionUidFormatter.php b/app/code/Magento/ConfigurableProductGraphQl/Model/Options/SelectionUidFormatter.php new file mode 100644 index 0000000000000..1d13ad75489a1 --- /dev/null +++ b/app/code/Magento/ConfigurableProductGraphQl/Model/Options/SelectionUidFormatter.php @@ -0,0 +1,60 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +namespace Magento\ConfigurableProductGraphQl\Model\Options; + +/** + * Handle option selection uid. + */ +class SelectionUidFormatter +{ + /** + * Prefix of uid for encoding + */ + private const UID_PREFIX = 'configurable'; + + /** + * Separator of uid for encoding + */ + private const UID_SEPARATOR = '/'; + + /** + * Create uid and encode. + * + * @param int $attributeId + * @param int $indexId + * @return string + */ + public function encode(int $attributeId, int $indexId): string + { + // phpcs:ignore Magento2.Functions.DiscouragedFunction + return base64_encode(implode(self::UID_SEPARATOR, [ + self::UID_PREFIX, + $attributeId, + $indexId + ])); + } + + /** + * Retrieve attribute and option index from uid. Array key is the id of attribute and value is the index of option + * + * @param string $selectionUids + * @return array + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + */ + public function extract(array $selectionUids): array + { + $attributeOption = []; + foreach ($selectionUids as $uid) { + // phpcs:ignore Magento2.Functions.DiscouragedFunction + $optionData = explode(self::UID_SEPARATOR, base64_decode($uid)); + if (count($optionData) == 3) { + $attributeOption[(int)$optionData[1]] = (int)$optionData[2]; + } + } + + return $attributeOption; + } +} diff --git a/app/code/Magento/ConfigurableProductGraphQl/Model/Resolver/ConfigurableCartItemOptions.php b/app/code/Magento/ConfigurableProductGraphQl/Model/Resolver/ConfigurableCartItemOptions.php index fed05208e0d55..6624a2624f1c3 100644 --- a/app/code/Magento/ConfigurableProductGraphQl/Model/Resolver/ConfigurableCartItemOptions.php +++ b/app/code/Magento/ConfigurableProductGraphQl/Model/Resolver/ConfigurableCartItemOptions.php @@ -55,6 +55,10 @@ public function resolve(Field $field, $context, ResolveInfo $info, array $value $result = []; foreach ($this->configurationHelper->getOptions($cartItem) as $option) { + if (isset($option['option_type'])) { + //Don't return customizable options in this resolver + continue; + } $result[] = [ 'id' => $option['option_id'], 'option_label' => $option['label'], diff --git a/app/code/Magento/ConfigurableProductGraphQl/Model/Resolver/OptionsSelectionMetadata.php b/app/code/Magento/ConfigurableProductGraphQl/Model/Resolver/OptionsSelectionMetadata.php new file mode 100644 index 0000000000000..f7d5a96ad2aba --- /dev/null +++ b/app/code/Magento/ConfigurableProductGraphQl/Model/Resolver/OptionsSelectionMetadata.php @@ -0,0 +1,49 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +namespace Magento\ConfigurableProductGraphQl\Model\Resolver; + +use Magento\Catalog\Api\Data\ProductInterface; +use Magento\ConfigurableProductGraphQl\Model\Options\Metadata; +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\GraphQl\Config\Element\Field; +use Magento\Framework\GraphQl\Query\ResolverInterface; +use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; + +/** + * Resolver class for option selection metadata. + */ +class OptionsSelectionMetadata implements ResolverInterface +{ + /** + * @var Metadata + */ + private $configurableSelectionMetadata; + + /** + * @param Metadata $configurableSelectionMetadata + */ + public function __construct( + Metadata $configurableSelectionMetadata + ) { + $this->configurableSelectionMetadata = $configurableSelectionMetadata; + } + + /** + * @inheritDoc + */ + public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null) + { + if (!isset($value['model'])) { + throw new LocalizedException(__('"model" value should be specified')); + } + + $selectedOptions = $args['selectedConfigurableOptionValues'] ?? []; + /** @var ProductInterface $product */ + $product = $value['model']; + + return $this->configurableSelectionMetadata->getAvailableSelections($product, $selectedOptions); + } +} diff --git a/app/code/Magento/ConfigurableProductGraphQl/Model/Resolver/SelectionMediaGallery.php b/app/code/Magento/ConfigurableProductGraphQl/Model/Resolver/SelectionMediaGallery.php new file mode 100644 index 0000000000000..7b3ddc4ac1417 --- /dev/null +++ b/app/code/Magento/ConfigurableProductGraphQl/Model/Resolver/SelectionMediaGallery.php @@ -0,0 +1,45 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +namespace Magento\ConfigurableProductGraphQl\Model\Resolver; + +use Magento\Framework\GraphQl\Config\Element\Field; +use Magento\Framework\GraphQl\Query\ResolverInterface; +use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; + +/** + * Resolver class for media gallery of child products. + */ +class SelectionMediaGallery implements ResolverInterface +{ + /** + * @inheritDoc + */ + public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null) + { + if (!isset($value['product']) || !$value['product']) { + return null; + } + + $product = $value['product']; + $availableSelectionProducts = $value['availableSelectionProducts']; + $mediaGalleryEntries = []; + $usedProducts = $product->getTypeInstance()->getUsedProducts($product, null); + foreach ($usedProducts as $usedProduct) { + if (in_array($usedProduct->getId(), $availableSelectionProducts)) { + foreach ($usedProduct->getMediaGalleryEntries() ?? [] as $key => $entry) { + $index = $usedProduct->getId() . '_' . $key; + $mediaGalleryEntries[$index] = $entry->getData(); + $mediaGalleryEntries[$index]['model'] = $usedProduct; + if ($entry->getExtensionAttributes() && $entry->getExtensionAttributes()->getVideoContent()) { + $mediaGalleryEntries[$index]['video_content'] + = $entry->getExtensionAttributes()->getVideoContent()->getData(); + } + } + } + } + return $mediaGalleryEntries; + } +} diff --git a/app/code/Magento/ConfigurableProductGraphQl/Model/Resolver/Variant/Variant.php b/app/code/Magento/ConfigurableProductGraphQl/Model/Resolver/Variant/Variant.php new file mode 100644 index 0000000000000..625c31a2680c8 --- /dev/null +++ b/app/code/Magento/ConfigurableProductGraphQl/Model/Resolver/Variant/Variant.php @@ -0,0 +1,34 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +namespace Magento\ConfigurableProductGraphQl\Model\Resolver\Variant; + +use Magento\Framework\GraphQl\Config\Element\Field; +use Magento\Framework\GraphQl\Query\ResolverInterface; +use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; + +/** + * Resolver class for product variant. + */ +class Variant implements ResolverInterface +{ + /** + * @inheritDoc + */ + public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null) + { + if (isset($value['variant']['model']) && $value['variant']['model']) { + return + array_merge( + $value['variant']['model']->getData(), + [ + 'model' => $value['variant']['model'] + ] + ); + } else { + return null; + } + } +} diff --git a/app/code/Magento/ConfigurableProductGraphQl/Model/Variant/Collection.php b/app/code/Magento/ConfigurableProductGraphQl/Model/Variant/Collection.php index b60a660251f4d..cd6d78e5c3ffb 100644 --- a/app/code/Magento/ConfigurableProductGraphQl/Model/Variant/Collection.php +++ b/app/code/Magento/ConfigurableProductGraphQl/Model/Variant/Collection.php @@ -9,6 +9,7 @@ use Magento\Catalog\Api\Data\ProductInterface; use Magento\Catalog\Model\Product; +use Magento\ConfigurableProduct\Model\Product\Type\Configurable; use Magento\ConfigurableProduct\Model\ResourceModel\Product\Type\Configurable\Product\Collection as ChildCollection; use Magento\ConfigurableProduct\Model\ResourceModel\Product\Type\Configurable\Product\CollectionFactory; use Magento\Framework\EntityManager\MetadataPool; @@ -175,19 +176,21 @@ private function fetch(ContextInterface $context = null) : array } /** - * Get attributes code + * Get attributes codes for given product * - * @param \Magento\Catalog\Model\Product $currentProduct + * @param Product $currentProduct * @return array */ private function getAttributesCodes(Product $currentProduct): array { $attributeCodes = $this->attributeCodes; - $allowAttributes = $currentProduct->getTypeInstance()->getConfigurableAttributes($currentProduct); - foreach ($allowAttributes as $attribute) { - $productAttribute = $attribute->getProductAttribute(); - if (!\in_array($productAttribute->getAttributeCode(), $attributeCodes)) { - $attributeCodes[] = $productAttribute->getAttributeCode(); + if ($currentProduct->getTypeId() == Configurable::TYPE_CODE) { + $allowAttributes = $currentProduct->getTypeInstance()->getConfigurableAttributes($currentProduct); + foreach ($allowAttributes as $attribute) { + $productAttribute = $attribute->getProductAttribute(); + if (!\in_array($productAttribute->getAttributeCode(), $attributeCodes)) { + $attributeCodes[] = $productAttribute->getAttributeCode(); + } } } diff --git a/app/code/Magento/ConfigurableProductGraphQl/composer.json b/app/code/Magento/ConfigurableProductGraphQl/composer.json index 295efb65b1978..a6e1d1c822435 100644 --- a/app/code/Magento/ConfigurableProductGraphQl/composer.json +++ b/app/code/Magento/ConfigurableProductGraphQl/composer.json @@ -10,6 +10,7 @@ "magento/module-catalog-graph-ql": "*", "magento/module-quote": "*", "magento/module-quote-graph-ql": "*", + "magento/module-catalog-inventory": "*", "magento/framework": "*" }, "license": [ diff --git a/app/code/Magento/ConfigurableProductGraphQl/etc/graphql/di.xml b/app/code/Magento/ConfigurableProductGraphQl/etc/graphql/di.xml index 808ca62f7e149..dc672b02e2f96 100644 --- a/app/code/Magento/ConfigurableProductGraphQl/etc/graphql/di.xml +++ b/app/code/Magento/ConfigurableProductGraphQl/etc/graphql/di.xml @@ -43,4 +43,12 @@ </argument> </arguments> </type> + + <type name="Magento\ConfigurableProduct\Model\Product\Type\Configurable"> + <plugin name="used_products_cache_graphql" type="Magento\ConfigurableProduct\Model\Plugin\Frontend\UsedProductsCache" /> + </type> + + <type name="Magento\ConfigurableProduct\Model\ResourceModel\Attribute\OptionSelectBuilderInterface"> + <plugin name="Magento_ConfigurableProduct_Plugin_Model_ResourceModel_Attribute_InStockOptionSelectBuilder_GraphQl" type="Magento\ConfigurableProduct\Plugin\Model\ResourceModel\Attribute\InStockOptionSelectBuilder"/> + </type> </config> diff --git a/app/code/Magento/ConfigurableProductGraphQl/etc/module.xml b/app/code/Magento/ConfigurableProductGraphQl/etc/module.xml index f249a417f1046..3aa1658c9388d 100644 --- a/app/code/Magento/ConfigurableProductGraphQl/etc/module.xml +++ b/app/code/Magento/ConfigurableProductGraphQl/etc/module.xml @@ -12,6 +12,7 @@ <module name="Magento_ConfigurableProduct"/> <module name="Magento_GraphQl"/> <module name="Magento_CatalogGraphQl"/> + <module name="Magento_CatalogInventory"/> <module name="Magento_QuoteGraphQl"/> </sequence> </module> diff --git a/app/code/Magento/ConfigurableProductGraphQl/etc/schema.graphqls b/app/code/Magento/ConfigurableProductGraphQl/etc/schema.graphqls index 257bca11fb5b7..6fd3132aa6645 100644 --- a/app/code/Magento/ConfigurableProductGraphQl/etc/schema.graphqls +++ b/app/code/Magento/ConfigurableProductGraphQl/etc/schema.graphqls @@ -7,6 +7,7 @@ type Mutation { type ConfigurableProduct implements ProductInterface, PhysicalProductInterface, CustomizableProductInterface @doc(description: "ConfigurableProduct defines basic features of a configurable product and its simple product variants") { variants: [ConfigurableVariant] @doc(description: "An array of variants of products") @resolver(class: "Magento\\ConfigurableProductGraphQl\\Model\\Resolver\\ConfigurableVariant") configurable_options: [ConfigurableProductOptions] @doc(description: "An array of linked simple product items") @resolver(class: "Magento\\ConfigurableProductGraphQl\\Model\\Resolver\\Options") + configurable_options_selection_metadata(selectedConfigurableOptionValues: [ID!]): ConfigurableOptionsSelectionMetadata @doc(description: "Metadata for the specified configurable options selection") @resolver(class: "Magento\\ConfigurableProductGraphQl\\Model\\Resolver\\OptionsSelectionMetadata") } type ConfigurableVariant @doc(description: "An array containing all the simple product variants of a configurable product") { @@ -58,7 +59,7 @@ input ConfigurableProductCartItemInput { } type ConfigurableCartItem implements CartItemInterface { - customizable_options: [SelectedCustomizableOption]! + customizable_options: [SelectedCustomizableOption] @resolver(class: "Magento\\QuoteGraphQl\\Model\\Resolver\\CustomizableOptions") configurable_options: [SelectedConfigurableOption!]! @resolver(class: "Magento\\ConfigurableProductGraphQl\\Model\\Resolver\\ConfigurableCartItemOptions") } @@ -73,3 +74,14 @@ type ConfigurableWishlistItem implements WishlistItemInterface @doc(description: child_sku: String! @doc(description: "The SKU of the simple product corresponding to a set of selected configurable options") @resolver(class: "\\Magento\\ConfigurableProductGraphQl\\Model\\Wishlist\\ChildSku") configurable_options: [SelectedConfigurableOption!] @resolver(class: "\\Magento\\ConfigurableProductGraphQl\\Model\\Wishlist\\ConfigurableOptions") @doc (description: "An array of selected configurable options") } + +type ConfigurableOptionsSelectionMetadata @doc(description: "Metadata corresponding to the configurable options selection.") { + options_available_for_selection: [ConfigurableOptionAvailableForSelection!] @doc(description: "Configurable options available for further selection based on current selection.") + media_gallery: [MediaGalleryInterface!] @resolver(class: "Magento\\ConfigurableProductGraphQl\\Model\\Resolver\\SelectionMediaGallery") @doc(description: "Product images and videos corresponding to the specified configurable options selection.") + variant: SimpleProduct @resolver(class: "Magento\\ConfigurableProductGraphQl\\Model\\Resolver\\Variant\\Variant") @doc(description: "Variant represented by the specified configurable options selection. It is expected to be null, until selections are made for each configurable option.") +} + +type ConfigurableOptionAvailableForSelection @doc(description: "Configurable option available for further selection based on current selection.") { + option_value_uids: [ID!]! @doc(description: "Configurable option values available for further selection.") + attribute_code: String! @doc(description: "Attribute code that uniquely identifies configurable option.") +} diff --git a/app/code/Magento/Cron/Test/Unit/Observer/ProcessCronQueueObserverTest.php b/app/code/Magento/Cron/Test/Unit/Observer/ProcessCronQueueObserverTest.php index e1e28ff6f06a3..a48a3dd76b884 100644 --- a/app/code/Magento/Cron/Test/Unit/Observer/ProcessCronQueueObserverTest.php +++ b/app/code/Magento/Cron/Test/Unit/Observer/ProcessCronQueueObserverTest.php @@ -679,6 +679,8 @@ public function dispatchExceptionInCallbackDataProvider() /** * Test case, successfully run job + * + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ public function testDispatchRunJob() { @@ -764,7 +766,7 @@ function ($callback) { ->method('getCollection')->willReturn($this->scheduleCollectionMock); $scheduleMock->expects($this->any()) ->method('getResource')->willReturn($this->scheduleResourceMock); - $this->scheduleFactoryMock->expects($this->once(2)) + $this->scheduleFactoryMock->expects($this->once()) ->method('create')->willReturn($scheduleMock); $testCronJob = $this->getMockBuilder('CronJob') diff --git a/app/code/Magento/Csp/Helper/InlineUtil.php b/app/code/Magento/Csp/Helper/InlineUtil.php index f9dd9aafa459e..648ba51e34f7d 100644 --- a/app/code/Magento/Csp/Helper/InlineUtil.php +++ b/app/code/Magento/Csp/Helper/InlineUtil.php @@ -110,14 +110,14 @@ private function extractHost(string $url): ?string */ private function extractRemoteFonts(string $styleContent): array { - $urlsFound = [[]]; + $urlsFound = []; preg_match_all('/\@font\-face\s*?\{([^\}]*)[^\}]*?\}/im', $styleContent, $fontFaces); foreach ($fontFaces[1] as $fontFaceContent) { preg_match_all('/url\([\'\"]?(http(s)?\:[^\)]+)[\'\"]?\)/i', $fontFaceContent, $urls); $urlsFound[] = $urls[1]; } - return array_map([$this, 'extractHost'], array_merge(...$urlsFound)); + return array_map([$this, 'extractHost'], array_merge([], ...$urlsFound)); } /** diff --git a/app/code/Magento/Csp/Model/BlockCache.php b/app/code/Magento/Csp/Model/BlockCache.php index f0469c3251379..fac0beec51c07 100644 --- a/app/code/Magento/Csp/Model/BlockCache.php +++ b/app/code/Magento/Csp/Model/BlockCache.php @@ -111,7 +111,7 @@ public function save($data, $identifier, $tags = [], $lifeTime = null) ]; } } - $data = $this->serializer->serialize(['policies' => $policiesData, 'html' => $data]); + $data = $this->serializer->serialize(['policies' => $policiesData, 'html' => (string)$data]); } return $this->cache->save($data, $identifier, $tags, $lifeTime); diff --git a/app/code/Magento/Csp/Model/Collector/CompositeMerger.php b/app/code/Magento/Csp/Model/Collector/CompositeMerger.php new file mode 100644 index 0000000000000..16430f1ff8aa9 --- /dev/null +++ b/app/code/Magento/Csp/Model/Collector/CompositeMerger.php @@ -0,0 +1,57 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Csp\Model\Collector; + +use Magento\Csp\Api\Data\PolicyInterface; + +/** + * Merges policies using different mergers. + */ +class CompositeMerger implements MergerInterface +{ + /** + * @var MergerInterface[] + */ + private $mergers; + + /** + * @param MergerInterface[] $mergers + */ + public function __construct(array $mergers) + { + $this->mergers = $mergers; + } + + /** + * @inheritDoc + */ + public function merge(PolicyInterface $policy1, PolicyInterface $policy2): PolicyInterface + { + foreach ($this->mergers as $merger) { + if ($merger->canMerge($policy1, $policy2)) { + return $merger->merge($policy1, $policy2); + } + } + + throw new \RuntimeException('Cannot merge 2 policies of ' .get_class($policy1)); + } + + /** + * @inheritDoc + */ + public function canMerge(PolicyInterface $policy1, PolicyInterface $policy2): bool + { + foreach ($this->mergers as $merger) { + if ($merger->canMerge($policy1, $policy2)) { + return true; + } + } + + return false; + } +} diff --git a/app/code/Magento/Csp/Model/Collector/CspWhitelistXml/FileResolver.php b/app/code/Magento/Csp/Model/Collector/CspWhitelistXml/FileResolver.php new file mode 100644 index 0000000000000..acc0dd1600db1 --- /dev/null +++ b/app/code/Magento/Csp/Model/Collector/CspWhitelistXml/FileResolver.php @@ -0,0 +1,97 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +declare(strict_types=1); + +namespace Magento\Csp\Model\Collector\CspWhitelistXml; + +use Magento\Framework\Config\FileResolverInterface; +use Magento\Framework\Filesystem; +use Magento\Framework\View\Design\ThemeInterface; +use Magento\Framework\View\DesignInterface; +use Magento\Framework\View\Design\Theme\CustomizationInterface; +use Magento\Framework\View\Design\Theme\CustomizationInterfaceFactory; +use Magento\Framework\App\Filesystem\DirectoryList; +use Magento\Framework\Filesystem\Directory\ReadInterface as DirectoryRead; +use Magento\Framework\Config\CompositeFileIteratorFactory; + +/** + * Combines configuration files from both modules and current theme. + */ +class FileResolver implements FileResolverInterface +{ + /** + * @var FileResolverInterface + */ + private $moduleFileResolver; + + /** + * @var ThemeInterface + */ + private $theme; + + /** + * @var CustomizationInterfaceFactory + */ + private $themeInfoFactory; + + /** + * @var DirectoryRead + */ + private $rootDir; + + /** + * @var CompositeFileIteratorFactory + */ + private $iteratorFactory; + + /** + * @param FileResolverInterface $moduleFileResolver + * @param DesignInterface $design + * @param CustomizationInterfaceFactory $customizationFactory + * @param Filesystem $filesystem + * @param CompositeFileIteratorFactory $iteratorFactory + */ + public function __construct( + FileResolverInterface $moduleFileResolver, + DesignInterface $design, + CustomizationInterfaceFactory $customizationFactory, + Filesystem $filesystem, + CompositeFileIteratorFactory $iteratorFactory + ) { + $this->moduleFileResolver = $moduleFileResolver; + $this->theme = $design->getDesignTheme(); + $this->themeInfoFactory = $customizationFactory; + $this->rootDir = $filesystem->getDirectoryRead(DirectoryList::ROOT); + $this->iteratorFactory = $iteratorFactory; + } + + /** + * @inheritDoc + */ + public function get($filename, $scope) + { + $configs = $this->moduleFileResolver->get($filename, $scope); + if ($scope === 'global') { + $files = []; + $theme = $this->theme; + while ($theme) { + /** @var CustomizationInterface $info */ + $info = $this->themeInfoFactory->create(['theme' => $theme]); + $file = $info->getThemeFilesPath() .'/etc/' .$filename; + if ($this->rootDir->isExist($file)) { + $files[] = $file; + } + $theme = $theme->getParentTheme(); + } + $configs = $this->iteratorFactory->create( + ['paths' => array_reverse($files), 'existingIterator' => $configs] + ); + } + + return $configs; + } +} diff --git a/app/code/Magento/Csp/Model/Collector/DynamicCollector.php b/app/code/Magento/Csp/Model/Collector/DynamicCollector.php index 6478e9622f910..743f77c93f3d8 100644 --- a/app/code/Magento/Csp/Model/Collector/DynamicCollector.php +++ b/app/code/Magento/Csp/Model/Collector/DynamicCollector.php @@ -20,6 +20,19 @@ class DynamicCollector implements PolicyCollectorInterface */ private $added = []; + /** + * @var MergerInterface + */ + private $merger; + + /** + * @param MergerInterface $merger + */ + public function __construct(MergerInterface $merger) + { + $this->merger = $merger; + } + /** * Add a policy for current page. * @@ -28,7 +41,15 @@ class DynamicCollector implements PolicyCollectorInterface */ public function add(PolicyInterface $policy): void { - $this->added[] = $policy; + if (array_key_exists($policy->getId(), $this->added)) { + if ($this->merger->canMerge($this->added[$policy->getId()], $policy)) { + $this->added[$policy->getId()] = $this->merger->merge($this->added[$policy->getId()], $policy); + } else { + throw new \RuntimeException('Cannot merge a policy of ' .get_class($policy)); + } + } else { + $this->added[$policy->getId()] = $policy; + } } /** @@ -36,6 +57,6 @@ public function add(PolicyInterface $policy): void */ public function collect(array $defaultPolicies = []): array { - return array_merge($defaultPolicies, $this->added); + return array_merge($defaultPolicies, array_values($this->added)); } } diff --git a/app/code/Magento/Csp/etc/di.xml b/app/code/Magento/Csp/etc/di.xml index 7b1129a0e1a41..5e90c4b0c866c 100644 --- a/app/code/Magento/Csp/etc/di.xml +++ b/app/code/Magento/Csp/etc/di.xml @@ -15,6 +15,17 @@ </arguments> </type> <preference for="Magento\Csp\Api\PolicyCollectorInterface" type="Magento\Csp\Model\CompositePolicyCollector" /> + <preference for="Magento\Csp\Model\Collector\MergerInterface" type="Magento\Csp\Model\Collector\CompositeMerger" /> + <type name="Magento\Csp\Model\Collector\CompositeMerger"> + <arguments> + <argument name="mergers" xsi:type="array"> + <item name="fetch" xsi:type="object">Magento\Csp\Model\Collector\FetchPolicyMerger</item> + <item name="flag" xsi:type="object">Magento\Csp\Model\Collector\FlagPolicyMerger</item> + <item name="plugins" xsi:type="object">Magento\Csp\Model\Collector\PluginTypesPolicyMerger</item> + <item name="sandbox" xsi:type="object">Magento\Csp\Model\Collector\SandboxPolicyMerger</item> + </argument> + </arguments> + </type> <type name="Magento\Csp\Model\CompositePolicyCollector"> <arguments> <argument name="collectors" xsi:type="array"> @@ -24,10 +35,7 @@ <item name="dynamic" xsi:type="object" sortOrder="3">Magento\Csp\Model\Collector\DynamicCollector\Proxy</item> </argument> <argument name="mergers" xsi:type="array"> - <item name="fetch" xsi:type="object">Magento\Csp\Model\Collector\FetchPolicyMerger</item> - <item name="flag" xsi:type="object">Magento\Csp\Model\Collector\FlagPolicyMerger</item> - <item name="plugins" xsi:type="object">Magento\Csp\Model\Collector\PluginTypesPolicyMerger</item> - <item name="sandbox" xsi:type="object">Magento\Csp\Model\Collector\SandboxPolicyMerger</item> + <item name="composite" xsi:type="object">Magento\Csp\Model\Collector\MergerInterface</item> </argument> </arguments> </type> @@ -46,6 +54,7 @@ <arguments> <argument name="converter" xsi:type="object">Magento\Csp\Model\Collector\CspWhitelistXml\Converter</argument> <argument name="schemaLocator" xsi:type="object">Magento\Csp\Model\Collector\CspWhitelistXml\SchemaLocator</argument> + <argument name="fileResolver" xsi:type="object">Magento\Csp\Model\Collector\CspWhitelistXml\FileResolver</argument> <argument name="fileName" xsi:type="string">csp_whitelist.xml</argument> </arguments> </type> @@ -93,6 +102,7 @@ <type name="Magento\Csp\Model\BlockCache"> <arguments> <argument name="cache" xsi:type="object">configured_block_cache</argument> + <argument name="serializer" xsi:type="object">Magento\Framework\Serialize\Serializer\Serialize</argument> </arguments> </type> <type name="Magento\Framework\View\Element\Context"> diff --git a/app/code/Magento/CurrencySymbol/Model/System/Currencysymbol.php b/app/code/Magento/CurrencySymbol/Model/System/Currencysymbol.php index d48df02d9de27..400aa56bc68e9 100644 --- a/app/code/Magento/CurrencySymbol/Model/System/Currencysymbol.php +++ b/app/code/Magento/CurrencySymbol/Model/System/Currencysymbol.php @@ -292,7 +292,7 @@ protected function _unserializeStoreConfig($configPath, $storeId = null) */ protected function getAllowedCurrencies() { - $allowedCurrencies = [[]]; + $allowedCurrencies = []; $allowedCurrencies[] = explode( self::ALLOWED_CURRENCIES_CONFIG_SEPARATOR, $this->_scopeConfig->getValue( @@ -330,6 +330,6 @@ protected function getAllowedCurrencies() } } } - return array_unique(array_merge(...$allowedCurrencies)); + return array_unique(array_merge([], ...$allowedCurrencies)); } } diff --git a/app/code/Magento/Customer/Api/SessionCleanerInterface.php b/app/code/Magento/Customer/Api/SessionCleanerInterface.php index eb24712105f96..d8534f8b34e83 100644 --- a/app/code/Magento/Customer/Api/SessionCleanerInterface.php +++ b/app/code/Magento/Customer/Api/SessionCleanerInterface.php @@ -13,7 +13,7 @@ interface SessionCleanerInterface { /** - * Destroy all active customer sessions related to given customer id, including current session. + * Destroy all active customer sessions related to given customer except current session. * * @param int $customerId * @return void diff --git a/app/code/Magento/Customer/Block/Widget/Dob.php b/app/code/Magento/Customer/Block/Widget/Dob.php index 90ce9ba210ed2..ef2d2cca169f5 100644 --- a/app/code/Magento/Customer/Block/Widget/Dob.php +++ b/app/code/Magento/Customer/Block/Widget/Dob.php @@ -7,11 +7,16 @@ use Magento\Customer\Api\CustomerMetadataInterface; use Magento\Framework\Api\ArrayObjectSearch; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\Json\EncoderInterface; +use Magento\Framework\Locale\Bundle\DataBundle; +use Magento\Framework\Locale\ResolverInterface; /** * Customer date of birth attribute block * * @SuppressWarnings(PHPMD.DepthOfInheritance) + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class Dob extends AbstractWidget { @@ -39,6 +44,18 @@ class Dob extends AbstractWidget */ protected $filterFactory; + /** + * JSON Encoder + * + * @var EncoderInterface + */ + private $encoder; + + /** + * @var ResolverInterface + */ + private $localeResolver; + /** * @param \Magento\Framework\View\Element\Template\Context $context * @param \Magento\Customer\Helper\Address $addressHelper @@ -46,6 +63,8 @@ class Dob extends AbstractWidget * @param \Magento\Framework\View\Element\Html\Date $dateElement * @param \Magento\Framework\Data\Form\FilterFactory $filterFactory * @param array $data + * @param EncoderInterface|null $encoder + * @param ResolverInterface|null $localeResolver */ public function __construct( \Magento\Framework\View\Element\Template\Context $context, @@ -53,10 +72,14 @@ public function __construct( CustomerMetadataInterface $customerMetadata, \Magento\Framework\View\Element\Html\Date $dateElement, \Magento\Framework\Data\Form\FilterFactory $filterFactory, - array $data = [] + array $data = [], + ?EncoderInterface $encoder = null, + ?ResolverInterface $localeResolver = null ) { $this->dateElement = $dateElement; $this->filterFactory = $filterFactory; + $this->encoder = $encoder ?? ObjectManager::getInstance()->get(EncoderInterface::class); + $this->localeResolver = $localeResolver ?? ObjectManager::getInstance()->get(ResolverInterface::class); parent::__construct($context, $addressHelper, $customerMetadata, $data); } @@ -281,7 +304,7 @@ public function getHtmlExtraParams() */ public function getDateFormat() { - $dateFormat = $this->_localeDate->getDateFormatWithLongYear(); + $dateFormat = $this->setTwoDayPlaces($this->_localeDate->getDateFormatWithLongYear()); /** Escape RTL characters which are present in some locales and corrupt formatting */ $escapedDateFormat = preg_replace('/[^MmDdYy\/\.\-]/', '', $dateFormat); @@ -377,4 +400,45 @@ public function getFirstDay() \Magento\Store\Model\ScopeInterface::SCOPE_STORE ); } + + /** + * Get translated calendar config json formatted + * + * @return string + */ + public function getTranslatedCalendarConfigJson(): string + { + $localeData = (new DataBundle())->get($this->localeResolver->getLocale()); + $monthsData = $localeData['calendar']['gregorian']['monthNames']; + $daysData = $localeData['calendar']['gregorian']['dayNames']; + + return $this->encoder->encode( + [ + 'closeText' => __('Done'), + 'prevText' => __('Prev'), + 'nextText' => __('Next'), + 'currentText' => __('Today'), + 'monthNames' => array_values(iterator_to_array($monthsData['format']['wide'])), + 'monthNamesShort' => array_values(iterator_to_array($monthsData['format']['abbreviated'])), + 'dayNames' => array_values(iterator_to_array($daysData['format']['wide'])), + 'dayNamesShort' => array_values(iterator_to_array($daysData['format']['abbreviated'])), + 'dayNamesMin' => array_values(iterator_to_array($daysData['format']['short'])), + ] + ); + } + + /** + * Set 2 places for day value in format string + * + * @param string $format + * @return string + */ + private function setTwoDayPlaces(string $format): string + { + return preg_replace( + '/(?<!d)d(?!d)/', + 'dd', + $format + ); + } } diff --git a/app/code/Magento/Customer/Controller/Account/EditPost.php b/app/code/Magento/Customer/Controller/Account/EditPost.php index 04b5b72ae776b..6b59986f8ec5f 100644 --- a/app/code/Magento/Customer/Controller/Account/EditPost.php +++ b/app/code/Magento/Customer/Controller/Account/EditPost.php @@ -33,7 +33,8 @@ use Magento\Framework\Phrase; /** - * Class EditPost + * Customer edit account information controller + * * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class EditPost extends AbstractAccount implements CsrfAwareActionInterface, HttpPostActionInterface @@ -185,6 +186,7 @@ public function validateForCsrf(RequestInterface $request): ?bool * Change customer email or password action * * @return \Magento\Framework\Controller\Result\Redirect + * @SuppressWarnings(PHPMD.CyclomaticComplexity) */ public function execute() { @@ -217,6 +219,12 @@ public function execute() ); $this->dispatchSuccessEvent($customerCandidateDataObject); $this->messageManager->addSuccessMessage(__('You saved the account information.')); + // logout from current session if password changed. + if ($isPasswordChanged) { + $this->session->logout(); + $this->session->start(); + return $resultRedirect->setPath('customer/account/login'); + } return $resultRedirect->setPath('customer/account'); } catch (InvalidEmailOrPasswordException $e) { $this->messageManager->addErrorMessage($this->escaper->escapeHtml($e->getMessage())); diff --git a/app/code/Magento/Customer/Controller/Account/ResetPasswordPost.php b/app/code/Magento/Customer/Controller/Account/ResetPasswordPost.php index a127f2acf538f..1bb5aea9b1dc9 100644 --- a/app/code/Magento/Customer/Controller/Account/ResetPasswordPost.php +++ b/app/code/Magento/Customer/Controller/Account/ResetPasswordPost.php @@ -14,9 +14,7 @@ use Magento\Customer\Model\Customer\CredentialsValidator; /** - * Class ResetPasswordPost - * - * @package Magento\Customer\Controller\Account + * Customer reset password controller */ class ResetPasswordPost extends \Magento\Customer\Controller\AbstractAccount implements HttpPostActionInterface { @@ -91,6 +89,11 @@ public function execute() $resetPasswordToken, $password ); + // logout from current session if password changed. + if ($this->session->isLoggedIn()) { + $this->session->logout(); + $this->session->start(); + } $this->session->unsRpToken(); $this->messageManager->addSuccessMessage(__('You updated your password.')); $resultRedirect->setPath('*/*/login'); diff --git a/app/code/Magento/Customer/Controller/Adminhtml/Index.php b/app/code/Magento/Customer/Controller/Adminhtml/Index.php index 51dc39a2fc658..9595e473c1869 100644 --- a/app/code/Magento/Customer/Controller/Adminhtml/Index.php +++ b/app/code/Magento/Customer/Controller/Adminhtml/Index.php @@ -289,9 +289,8 @@ protected function prepareDefaultCustomerTitle(\Magento\Backend\Model\View\Resul protected function _addSessionErrorMessages($messages) { $messages = (array)$messages; - $session = $this->_getSession(); - $callback = function ($error) use ($session) { + $callback = function ($error) { if (!$error instanceof Error) { $error = new Error($error); } diff --git a/app/code/Magento/Customer/Model/AccountManagement.php b/app/code/Magento/Customer/Model/AccountManagement.php index d22a10145c7be..67017562e105c 100644 --- a/app/code/Magento/Customer/Model/AccountManagement.php +++ b/app/code/Magento/Customer/Model/AccountManagement.php @@ -977,7 +977,6 @@ protected function sendEmailConfirmation(CustomerInterface $customer, $redirectU $templateType = self::NEW_ACCOUNT_EMAIL_REGISTERED_NO_PASSWORD; } $this->getEmailNotification()->newAccount($customer, $templateType, $redirectUrl, $customer->getStoreId()); - $customer->setConfirmation(null); } catch (MailException $e) { // If we are not able to send a new account email, this should be ignored $this->logger->critical($e); @@ -1615,37 +1614,6 @@ private function getEmailNotification() } } - /** - * 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) - { - $this->sessionManager->regenerateId(); - $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->saveHandler->destroy($sessionId); - } - } - /** * Set ignore_validation_flag for reset password flow to skip unnecessary address and customer validation * diff --git a/app/code/Magento/Customer/Model/AccountManagementApi.php b/app/code/Magento/Customer/Model/AccountManagementApi.php new file mode 100644 index 0000000000000..02a05705b57ef --- /dev/null +++ b/app/code/Magento/Customer/Model/AccountManagementApi.php @@ -0,0 +1,31 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +namespace Magento\Customer\Model; + +use Magento\Customer\Api\Data\CustomerInterface; + +/** + * Account Management service implementation for external API access. + * Handle various customer account actions. + * + * @SuppressWarnings(PHPMD.CookieAndSessionMisuse) + */ +class AccountManagementApi extends AccountManagement +{ + /** + * @inheritDoc + * + * Override createAccount method to unset confirmation attribute for security purposes. + */ + public function createAccount(CustomerInterface $customer, $password = null, $redirectUrl = '') + { + $customer = parent::createAccount($customer, $password, $redirectUrl); + $customer->setConfirmation(null); + + return $customer; + } +} diff --git a/app/code/Magento/Customer/Model/Address.php b/app/code/Magento/Customer/Model/Address.php index 241abbb06f8a1..c89821d5c984a 100644 --- a/app/code/Magento/Customer/Model/Address.php +++ b/app/code/Magento/Customer/Model/Address.php @@ -15,8 +15,8 @@ * Customer address model * * @api - * @method int getParentId() getParentId() - * @method \Magento\Customer\Model\Address setParentId() setParentId(int $parentId) + * @method int getParentId() + * @method \Magento\Customer\Model\Address setParentId(int $parentId) * @SuppressWarnings(PHPMD.CouplingBetweenObjects) * @since 100.0.2 */ diff --git a/app/code/Magento/Customer/Model/Address/CompositeValidator.php b/app/code/Magento/Customer/Model/Address/CompositeValidator.php index 4c77f10c11de4..62308ba329d03 100644 --- a/app/code/Magento/Customer/Model/Address/CompositeValidator.php +++ b/app/code/Magento/Customer/Model/Address/CompositeValidator.php @@ -30,11 +30,11 @@ public function __construct( */ public function validate(AbstractAddress $address) { - $errors = [[]]; + $errors = []; foreach ($this->validators as $validator) { $errors[] = $validator->validate($address); } - return array_merge(...$errors); + return array_merge([], ...$errors); } } diff --git a/app/code/Magento/Customer/Model/FileProcessor.php b/app/code/Magento/Customer/Model/FileProcessor.php index c16faea284296..c596f8c313ab3 100644 --- a/app/code/Magento/Customer/Model/FileProcessor.php +++ b/app/code/Magento/Customer/Model/FileProcessor.php @@ -233,7 +233,8 @@ public function moveTemporaryFile($fileName) ); } catch (\Exception $e) { throw new \Magento\Framework\Exception\LocalizedException( - __('Something went wrong while saving the file.') + __('Something went wrong while saving the file.'), + $e ); } diff --git a/app/code/Magento/Customer/Model/ForgotPasswordToken/ConfirmCustomerByToken.php b/app/code/Magento/Customer/Model/ForgotPasswordToken/ConfirmCustomerByToken.php index 6aadc814a4b9b..1000575805018 100644 --- a/app/code/Magento/Customer/Model/ForgotPasswordToken/ConfirmCustomerByToken.php +++ b/app/code/Magento/Customer/Model/ForgotPasswordToken/ConfirmCustomerByToken.php @@ -8,6 +8,8 @@ namespace Magento\Customer\Model\ForgotPasswordToken; use Magento\Customer\Api\CustomerRepositoryInterface; +use Magento\Customer\Api\Data\CustomerInterface; +use Magento\Framework\Exception\LocalizedException; /** * Confirm customer by reset password token @@ -25,15 +27,11 @@ class ConfirmCustomerByToken private $customerRepository; /** - * ConfirmByToken constructor. - * * @param GetCustomerByToken $getByToken * @param CustomerRepositoryInterface $customerRepository */ - public function __construct( - GetCustomerByToken $getByToken, - CustomerRepositoryInterface $customerRepository - ) { + public function __construct(GetCustomerByToken $getByToken, CustomerRepositoryInterface $customerRepository) + { $this->getByToken = $getByToken; $this->customerRepository = $customerRepository; } @@ -42,17 +40,29 @@ public function __construct( * Confirm customer account my rp_token * * @param string $resetPasswordToken - * * @return void - * @throws \Magento\Framework\Exception\LocalizedException + * @throws LocalizedException */ public function execute(string $resetPasswordToken): void { $customer = $this->getByToken->execute($resetPasswordToken); if ($customer->getConfirmation()) { - $this->customerRepository->save( - $customer->setConfirmation(null) - ); + $this->resetConfirmation($customer); } } + + /** + * Reset customer confirmation + * + * @param CustomerInterface $customer + * @return void + */ + private function resetConfirmation(CustomerInterface $customer): void + { + // skip unnecessary address and customer validation + $customer->setData('ignore_validation_flag', true); + $customer->setConfirmation(null); + + $this->customerRepository->save($customer); + } } diff --git a/app/code/Magento/Customer/Model/ForgotPasswordToken/GetCustomerByToken.php b/app/code/Magento/Customer/Model/ForgotPasswordToken/GetCustomerByToken.php index 211a71d827f7e..6d2274351faee 100644 --- a/app/code/Magento/Customer/Model/ForgotPasswordToken/GetCustomerByToken.php +++ b/app/code/Magento/Customer/Model/ForgotPasswordToken/GetCustomerByToken.php @@ -54,7 +54,7 @@ public function __construct( * @throws NoSuchEntityException * @throws \Magento\Framework\Exception\LocalizedException */ - public function execute(string $resetPasswordToken):CustomerInterface + public function execute(string $resetPasswordToken): CustomerInterface { $this->searchCriteriaBuilder->addFilter( 'rp_token', diff --git a/app/code/Magento/Customer/Model/Metadata/Form.php b/app/code/Magento/Customer/Model/Metadata/Form.php index 85637ebf508b8..81ded6dec071a 100644 --- a/app/code/Magento/Customer/Model/Metadata/Form.php +++ b/app/code/Magento/Customer/Model/Metadata/Form.php @@ -363,11 +363,11 @@ public function validateData(array $data) { $validator = $this->_getValidator($data); if (!$validator->isValid(false)) { - $messages = [[]]; + $messages = []; foreach ($validator->getMessages() as $errorMessages) { $messages[] = (array)$errorMessages; } - return array_merge(...$messages); + return array_merge([], ...$messages); } return true; } diff --git a/app/code/Magento/Customer/Model/ResourceModel/Address/Attribute/Source/CountryWithWebsites.php b/app/code/Magento/Customer/Model/ResourceModel/Address/Attribute/Source/CountryWithWebsites.php index 020067570efb4..1ca1c5622803f 100644 --- a/app/code/Magento/Customer/Model/ResourceModel/Address/Attribute/Source/CountryWithWebsites.php +++ b/app/code/Magento/Customer/Model/ResourceModel/Address/Attribute/Source/CountryWithWebsites.php @@ -84,7 +84,7 @@ public function getAllOptions($withEmpty = true, $defaultValues = false) $websiteIds = []; if (!$this->shareConfig->isGlobalScope()) { - $allowedCountries = [[]]; + $allowedCountries = []; foreach ($this->storeManager->getWebsites() as $website) { $countries = $this->allowedCountriesReader @@ -96,7 +96,7 @@ public function getAllOptions($withEmpty = true, $defaultValues = false) } } - $allowedCountries = array_unique(array_merge(...$allowedCountries)); + $allowedCountries = array_unique(array_merge([], ...$allowedCountries)); } else { $allowedCountries = $this->allowedCountriesReader->getAllowedCountries(); } diff --git a/app/code/Magento/Customer/Model/ResourceModel/Customer.php b/app/code/Magento/Customer/Model/ResourceModel/Customer.php index 1477287f79f4b..6ebd6b9410a0c 100644 --- a/app/code/Magento/Customer/Model/ResourceModel/Customer.php +++ b/app/code/Magento/Customer/Model/ResourceModel/Customer.php @@ -85,7 +85,7 @@ public function __construct( $this->accountConfirmation = $accountConfirmation ?: ObjectManager::getInstance() ->get(AccountConfirmation::class); $this->setType('customer'); - $this->setConnection('customer_read', 'customer_write'); + $this->setConnection('customer_read'); $this->storeManager = $storeManager; } diff --git a/app/code/Magento/Customer/Model/Session/SessionCleaner.php b/app/code/Magento/Customer/Model/Session/SessionCleaner.php index 1423c94782535..5118c20329aaa 100644 --- a/app/code/Magento/Customer/Model/Session/SessionCleaner.php +++ b/app/code/Magento/Customer/Model/Session/SessionCleaner.php @@ -71,13 +71,6 @@ public function __construct( */ public function clearFor(int $customerId): void { - if ($this->sessionManager->isSessionExists()) { - //delete old session and move data to the new session - //use this instead of $this->sessionManager->regenerateId because last one doesn't delete old session - // phpcs:ignore Magento2.Functions.DiscouragedFunction - session_regenerate_id(true); - } - $sessionLifetime = $this->scopeConfig->getValue( Config::XML_PATH_COOKIE_LIFETIME, ScopeInterface::SCOPE_STORE @@ -89,6 +82,8 @@ public function clearFor(int $customerId): void $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(); diff --git a/app/code/Magento/Customer/Model/StoreSwitcher/RedirectDataPostprocessor.php b/app/code/Magento/Customer/Model/StoreSwitcher/RedirectDataPostprocessor.php new file mode 100644 index 0000000000000..9fb3d6f432c2f --- /dev/null +++ b/app/code/Magento/Customer/Model/StoreSwitcher/RedirectDataPostprocessor.php @@ -0,0 +1,75 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Customer\Model\StoreSwitcher; + +use Magento\Customer\Model\CustomerRegistry; +use Magento\Customer\Model\Session; +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\Exception\NoSuchEntityException; +use Magento\Store\Model\StoreSwitcher\ContextInterface; +use Magento\Store\Model\StoreSwitcher\RedirectDataPostprocessorInterface; +use Psr\Log\LoggerInterface; + +/** + * Process customer data redirected from origin store + * + * @SuppressWarnings(PHPMD.CookieAndSessionMisuse) + */ +class RedirectDataPostprocessor implements RedirectDataPostprocessorInterface +{ + /** + * @var Session + */ + private $session; + /** + * @var LoggerInterface + */ + private $logger; + /** + * @var CustomerRegistry + */ + private $customerRegistry; + + /** + * @param CustomerRegistry $customerRegistry + * @param Session $session + * @param LoggerInterface $logger + */ + public function __construct( + CustomerRegistry $customerRegistry, + Session $session, + LoggerInterface $logger + ) { + $this->customerRegistry = $customerRegistry; + $this->session = $session; + $this->logger = $logger; + } + + /** + * @inheritDoc + */ + public function process(ContextInterface $context, array $data): void + { + if (!empty($data['customer_id'])) { + try { + $customer = $this->customerRegistry->retrieve($data['customer_id']); + if (!$this->session->isLoggedIn() + && in_array($context->getTargetStore()->getId(), $customer->getSharedStoreIds()) + ) { + $this->session->setCustomerDataAsLoggedIn($customer->getDataModel()); + } + } catch (NoSuchEntityException $e) { + $this->logger->error($e); + throw new LocalizedException(__('Something went wrong.'), $e); + } catch (LocalizedException $e) { + $this->logger->error($e); + throw new LocalizedException(__('Something went wrong.'), $e); + } + } + } +} diff --git a/app/code/Magento/Customer/Model/StoreSwitcher/RedirectDataPreprocessor.php b/app/code/Magento/Customer/Model/StoreSwitcher/RedirectDataPreprocessor.php new file mode 100644 index 0000000000000..94f7619678df0 --- /dev/null +++ b/app/code/Magento/Customer/Model/StoreSwitcher/RedirectDataPreprocessor.php @@ -0,0 +1,70 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Customer\Model\StoreSwitcher; + +use Magento\Customer\Model\CustomerRegistry; +use Magento\Customer\Model\Session; +use Magento\Store\Model\StoreSwitcher\ContextInterface; +use Magento\Store\Model\StoreSwitcher\RedirectDataPreprocessorInterface; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Collect customer data to be redirected to target store + * + * @SuppressWarnings(PHPMD.CookieAndSessionMisuse) + */ +class RedirectDataPreprocessor implements RedirectDataPreprocessorInterface +{ + /** + * @var Session + */ + private $session; + /** + * @var LoggerInterface + */ + private $logger; + /** + * @var CustomerRegistry + */ + private $customerRegistry; + + /** + * @param CustomerRegistry $customerRegistry + * @param Session $session + * @param LoggerInterface $logger + */ + public function __construct( + CustomerRegistry $customerRegistry, + Session $session, + LoggerInterface $logger + ) { + $this->customerRegistry = $customerRegistry; + $this->session = $session; + $this->logger = $logger; + } + + /** + * @inheritDoc + */ + public function process(ContextInterface $context, array $data): array + { + if ($this->session->isLoggedIn()) { + try { + $customer = $this->customerRegistry->retrieve($this->session->getCustomerId()); + if (in_array($context->getTargetStore()->getId(), $customer->getSharedStoreIds())) { + $data['customer_id'] = (int) $customer->getId(); + } + } catch (Throwable $e) { + $this->logger->error($e); + } + } + + return $data; + } +} diff --git a/app/code/Magento/Customer/Observer/AfterAddressSaveObserver.php b/app/code/Magento/Customer/Observer/AfterAddressSaveObserver.php index fd5004ae0548f..af0a04827d30f 100644 --- a/app/code/Magento/Customer/Observer/AfterAddressSaveObserver.php +++ b/app/code/Magento/Customer/Observer/AfterAddressSaveObserver.php @@ -130,6 +130,7 @@ public function execute(Observer $observer) if (!$this->_customerAddress->isVatValidationEnabled($customer->getStore()) || $this->_coreRegistry->registry(self::VIV_PROCESSED_FLAG) || !$this->_canProcessAddress($customerAddress) + || $customerAddress->getShouldIgnoreValidation() ) { return; } diff --git a/app/code/Magento/Customer/Test/Mftf/ActionGroup/AdminOpenCustomersGridActionGroup.xml b/app/code/Magento/Customer/Test/Mftf/ActionGroup/AdminOpenCustomersGridActionGroup.xml new file mode 100644 index 0000000000000..c1dedfefda309 --- /dev/null +++ b/app/code/Magento/Customer/Test/Mftf/ActionGroup/AdminOpenCustomersGridActionGroup.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminOpenCustomersGridActionGroup"> + <annotations> + <description>Open the Admin Customers grid page.</description> + </annotations> + + <amOnPage url="{{AdminCustomerPage.url}}" stepKey="openCustomersGridPage"/> + <waitForPageLoad stepKey="waitForPageLoad"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Customer/Test/Mftf/ActionGroup/AssertStorefrontCustomerHasNoOtherAddressesActionGroup.xml b/app/code/Magento/Customer/Test/Mftf/ActionGroup/AssertStorefrontCustomerHasNoOtherAddressesActionGroup.xml new file mode 100644 index 0000000000000..1f56ba505128f --- /dev/null +++ b/app/code/Magento/Customer/Test/Mftf/ActionGroup/AssertStorefrontCustomerHasNoOtherAddressesActionGroup.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AssertStorefrontCustomerHasNoOtherAddressesActionGroup"> + <annotations> + <description>Verifies customer has no additional address in address book</description> + </annotations> + <amOnPage url="{{StorefrontCustomerAddressesPage.url}}" stepKey="goToAddressPage"/> + <waitForText userInput="You have no other address entries in your address book." selector=".block-addresses-list" stepKey="assertOtherAddresses"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Customer/Test/Mftf/ActionGroup/FillCustomerSignInPopupFormActionGroup.xml b/app/code/Magento/Customer/Test/Mftf/ActionGroup/FillCustomerSignInPopupFormActionGroup.xml index 71c19b6d138d1..b0ff5f001e517 100644 --- a/app/code/Magento/Customer/Test/Mftf/ActionGroup/FillCustomerSignInPopupFormActionGroup.xml +++ b/app/code/Magento/Customer/Test/Mftf/ActionGroup/FillCustomerSignInPopupFormActionGroup.xml @@ -15,7 +15,7 @@ <arguments> <argument name="customer" type="entity"/> </arguments> - + <waitForElementVisible selector="{{StorefrontCustomerSignInPopupFormSection.email}}" stepKey="waitEmailFieldVisible"/> <fillField selector="{{StorefrontCustomerSignInPopupFormSection.email}}" userInput="{{customer.email}}" stepKey="fillCustomerEmail"/> <fillField selector="{{StorefrontCustomerSignInPopupFormSection.password}}" userInput="{{customer.password}}" stepKey="fillCustomerPassword"/> diff --git a/app/code/Magento/Customer/Test/Mftf/ActionGroup/NavigateCustomerActionGroup.xml b/app/code/Magento/Customer/Test/Mftf/ActionGroup/NavigateCustomerActionGroup.xml index 73748b9a6bad6..ca6a1498c4215 100644 --- a/app/code/Magento/Customer/Test/Mftf/ActionGroup/NavigateCustomerActionGroup.xml +++ b/app/code/Magento/Customer/Test/Mftf/ActionGroup/NavigateCustomerActionGroup.xml @@ -8,7 +8,7 @@ <actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> - <actionGroup name="NavigateToAllCustomerPage"> + <actionGroup name="NavigateToAllCustomerPage" deprecated="Use AdminOpenCustomersGridActionGroup instead."> <annotations> <description>Goes to the Admin Customers grid page.</description> </annotations> diff --git a/app/code/Magento/Customer/Test/Mftf/ActionGroup/StorefrontVerifyCustomerAccountInformationActionGroup.xml b/app/code/Magento/Customer/Test/Mftf/ActionGroup/StorefrontVerifyCustomerAccountInformationActionGroup.xml new file mode 100644 index 0000000000000..a20ed21a1466f --- /dev/null +++ b/app/code/Magento/Customer/Test/Mftf/ActionGroup/StorefrontVerifyCustomerAccountInformationActionGroup.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="StorefrontVerifyCustomerAccountInformationActionGroup"> + <arguments> + <argument name="customer"/> + </arguments> + <annotations> + <description>Verifies customer information on the Edit Account Information page on the storefront</description> + </annotations> + <seeInField selector="{{StorefrontCustomerAccountInformationSection.firstName}}" userInput="{{customer.firstname}}" stepKey="seeCustomerFirstName"/> + <seeInField selector="{{StorefrontCustomerAccountInformationSection.lastName}}" userInput="{{customer.lastname}}" stepKey="seeCustomerLastName"/> + <seeElement selector="{{StorefrontCustomerAccountInformationSection.changeEmail}}" stepKey="seeChangeEmail"/> + <seeElement selector="{{StorefrontCustomerAccountInformationSection.changePassword}}" stepKey="seeChangePassword"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Customer/Test/Mftf/Section/AdminCustomerGridMainActionsSection.xml b/app/code/Magento/Customer/Test/Mftf/Section/AdminCustomerGridMainActionsSection.xml index d644b581088bc..44ab653259b55 100644 --- a/app/code/Magento/Customer/Test/Mftf/Section/AdminCustomerGridMainActionsSection.xml +++ b/app/code/Magento/Customer/Test/Mftf/Section/AdminCustomerGridMainActionsSection.xml @@ -11,9 +11,11 @@ <section name="AdminCustomerGridMainActionsSection"> <element name="addNewCustomer" type="button" selector="#add" timeout="30"/> <element name="multicheck" type="checkbox" selector="#container>div>div.admin__data-grid-wrap>table>thead>tr>th.data-grid-multicheck-cell>div>label"/> + <element name="multicheckTick" type="checkbox" selector="#container>div>div.admin__data-grid-wrap>table>thead>tr>th.data-grid-multicheck-cell>div>input"/> <element name="delete" type="button" selector="//*[contains(@class, 'admin__data-grid-header')]//span[contains(@class,'action-menu-item') and text()='Delete']"/> - <element name="actions" type="text" selector=".action-select"/> <element name="customerCheckbox" type="button" selector="//*[contains(text(),'{{arg}}')]/parent::td/preceding-sibling::td/label[@class='data-grid-checkbox-cell-inner']//input" parameterized="true"/> <element name="ok" type="button" selector="//button[@data-role='action']//span[text()='OK']"/> + <element name="addNewAddress" type="button" selector=".add-new-address-button" timeout="30"/> + <element name="actions" type="text" selector=".admin__data-grid-header-row .action-select"/> </section> </sections> diff --git a/app/code/Magento/Customer/Test/Mftf/Section/AdminCustomerShoppingCartSection/AdminCustomerShoppingCartProductItemSection.xml b/app/code/Magento/Customer/Test/Mftf/Section/AdminCustomerShoppingCartSection/AdminCustomerShoppingCartProductItemSection.xml index 40fc23b6c72c1..eedfe47b7775c 100644 --- a/app/code/Magento/Customer/Test/Mftf/Section/AdminCustomerShoppingCartSection/AdminCustomerShoppingCartProductItemSection.xml +++ b/app/code/Magento/Customer/Test/Mftf/Section/AdminCustomerShoppingCartSection/AdminCustomerShoppingCartProductItemSection.xml @@ -14,5 +14,6 @@ <element name="firstProductCheckbox" type="checkbox" selector="//*[@id='source_products_table']/tbody/tr[1]//*[@name='source_products']"/> <element name="addSelectionsToMyCartButton" type="button" selector="//*[@id='products_search']/div[1]//*[text()='Add selections to my cart']"/> <element name="addedProductName" type="text" selector="//*[@id='order-items_grid']//*[text()='{{var}}']" parameterized="true"/> + <element name="addedProductQty" type="input" selector="//*[@id='order-items_grid']//*[text()='{{var}}']//..//..//*[@class='col-qty']//input" parameterized="true"/> </section> </sections> diff --git a/app/code/Magento/Customer/Test/Mftf/Test/AdminAddNewDefaultBillingShippingCustomerAddressTest.xml b/app/code/Magento/Customer/Test/Mftf/Test/AdminAddNewDefaultBillingShippingCustomerAddressTest.xml index b061b6a256471..2220f69700265 100644 --- a/app/code/Magento/Customer/Test/Mftf/Test/AdminAddNewDefaultBillingShippingCustomerAddressTest.xml +++ b/app/code/Magento/Customer/Test/Mftf/Test/AdminAddNewDefaultBillingShippingCustomerAddressTest.xml @@ -30,7 +30,7 @@ Step2. On *Customers* page choose customer from preconditions and open it to edit Step3. Open *Addresses* tab on edit customer page and press *Add New Address* button <!- --> - <amOnPage url="{{AdminCustomerPage.url}}" stepKey="openCustomersGridPage"/> + <actionGroup ref="AdminOpenCustomersGridActionGroup" stepKey="openCustomersGridPage"/> <actionGroup ref="OpenEditCustomerFromAdminActionGroup" stepKey="openEditCustomerPage"> <argument name="customer" value="Simple_US_Customer_Multiple_Addresses_No_Default_Address"/> </actionGroup> diff --git a/app/code/Magento/Customer/Test/Mftf/Test/AdminChangeCustomerGenderInCustomersGridTest.xml b/app/code/Magento/Customer/Test/Mftf/Test/AdminChangeCustomerGenderInCustomersGridTest.xml index 423954a7d9bf7..d1934a82bea0e 100644 --- a/app/code/Magento/Customer/Test/Mftf/Test/AdminChangeCustomerGenderInCustomersGridTest.xml +++ b/app/code/Magento/Customer/Test/Mftf/Test/AdminChangeCustomerGenderInCustomersGridTest.xml @@ -26,8 +26,7 @@ <after> <deleteData createDataKey="createCustomer" stepKey="deleteCustomer"/> <!-- Reset customer grid filter --> - <amOnPage url="{{AdminCustomerPage.url}}" stepKey="goToCustomersGridPage"/> - <waitForPageLoad stepKey="waitForCustomersGrid"/> + <actionGroup ref="AdminOpenCustomersGridActionGroup" stepKey="goToCustomersGridPage"/> <actionGroup ref="AdminResetFilterInCustomerGrid" stepKey="resetFilter"/> <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> diff --git a/app/code/Magento/Customer/Test/Mftf/Test/AdminChangeSingleCustomerGroupViaGridTest.xml b/app/code/Magento/Customer/Test/Mftf/Test/AdminChangeSingleCustomerGroupViaGridTest.xml index a7383af2d7eea..5833bf07aeae2 100644 --- a/app/code/Magento/Customer/Test/Mftf/Test/AdminChangeSingleCustomerGroupViaGridTest.xml +++ b/app/code/Magento/Customer/Test/Mftf/Test/AdminChangeSingleCustomerGroupViaGridTest.xml @@ -29,12 +29,12 @@ <!--Delete created data--> <deleteData createDataKey="createCustomer" stepKey="deleteCustomer"/> <deleteData createDataKey="createCustomerGroup" stepKey="deleteCustomerGroup"/> - <actionGroup ref="NavigateToAllCustomerPage" stepKey="navigateToCustomersPage"/> + <actionGroup ref="AdminOpenCustomersGridActionGroup" stepKey="navigateToCustomersPage"/> <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearCustomersGridFilter"/> <actionGroup ref="AdminLogoutActionGroup" stepKey="logoutFromAdmin"/> </after> - <actionGroup ref="NavigateToAllCustomerPage" stepKey="navigateToCustomersPage"/> + <actionGroup ref="AdminOpenCustomersGridActionGroup" stepKey="navigateToCustomersPage"/> <actionGroup ref="AdminFilterCustomerGridByEmail" stepKey="filterCustomer"> <argument name="email" value="$$createCustomer.email$$"/> </actionGroup> diff --git a/app/code/Magento/Customer/Test/Mftf/Test/AdminCreateCustomerTest.xml b/app/code/Magento/Customer/Test/Mftf/Test/AdminCreateCustomerTest.xml index cb003ed837294..7442a32d58b2d 100644 --- a/app/code/Magento/Customer/Test/Mftf/Test/AdminCreateCustomerTest.xml +++ b/app/code/Magento/Customer/Test/Mftf/Test/AdminCreateCustomerTest.xml @@ -40,8 +40,9 @@ <actionGroup ref="CliIndexerReindexActionGroup" stepKey="reindex"> <argument name="indices" value=""/> </actionGroup> - <reloadPage stepKey="reloadPage"/> - <waitForPageLoad stepKey="waitForLoad2"/> + <actionGroup ref="ReloadPageActionGroup" stepKey="reloadPage"/> + <comment userInput="Replacing reload action and preserve Backward Compatibility" stepKey="waitForLoad2"/> + <click selector="{{AdminCustomerFiltersSection.filtersButton}}" stepKey="openFilter"/> <fillField userInput="{{CustomerEntityOne.email}}" selector="{{AdminCustomerFiltersSection.emailInput}}" stepKey="filterEmail"/> <click selector="{{AdminCustomerFiltersSection.apply}}" stepKey="applyFilter"/> diff --git a/app/code/Magento/Customer/Test/Mftf/Test/AdminCreateNewCustomerOnStorefrontSignupNewsletterTest.xml b/app/code/Magento/Customer/Test/Mftf/Test/AdminCreateNewCustomerOnStorefrontSignupNewsletterTest.xml index 683b275ca1ed6..44eab9d0c19ae 100644 --- a/app/code/Magento/Customer/Test/Mftf/Test/AdminCreateNewCustomerOnStorefrontSignupNewsletterTest.xml +++ b/app/code/Magento/Customer/Test/Mftf/Test/AdminCreateNewCustomerOnStorefrontSignupNewsletterTest.xml @@ -35,8 +35,7 @@ </actionGroup> <!--Assert verify created new customer in grid--> - <amOnPage url="{{AdminCustomerPage.url}}" stepKey="navigateToCustomers"/> - <waitForPageLoad stepKey="waitForNavigateToCustomersPageLoad"/> + <actionGroup ref="AdminOpenCustomersGridActionGroup" stepKey="navigateToCustomers"/> <click selector="{{AdminCustomerFiltersSection.filtersButton}}" stepKey="clickFilterButton"/> <fillField userInput="{{CustomerEntityOne.email}}" selector="{{AdminCustomerFiltersSection.emailInput}}" stepKey="filterEmail"/> <click selector="{{AdminCustomerFiltersSection.apply}}" stepKey="clickApplyFilter"/> diff --git a/app/code/Magento/Customer/Test/Mftf/Test/AdminDeleteCustomerAddressesFromTheGridTest.xml b/app/code/Magento/Customer/Test/Mftf/Test/AdminDeleteCustomerAddressesFromTheGridTest.xml index 615a6ebcf24cc..62dcd6fc4d894 100644 --- a/app/code/Magento/Customer/Test/Mftf/Test/AdminDeleteCustomerAddressesFromTheGridTest.xml +++ b/app/code/Magento/Customer/Test/Mftf/Test/AdminDeleteCustomerAddressesFromTheGridTest.xml @@ -34,7 +34,7 @@ Step2. On *Customers* page choose customer from preconditions and open it to edit Step3. On edit customer page open *Addresses* tab and find a grid with the additional addresses <!- --> - <amOnPage url="{{AdminCustomerPage.url}}" stepKey="openCustomersGridPage"/> + <actionGroup ref="AdminOpenCustomersGridActionGroup" stepKey="openCustomersGridPage"/> <actionGroup ref="OpenEditCustomerFromAdminActionGroup" stepKey="openEditCustomerPage"> <argument name="customer" value="Simple_US_Customer_Multiple_Addresses"/> </actionGroup> diff --git a/app/code/Magento/Customer/Test/Mftf/Test/AdminDeleteCustomerAddressesFromTheGridViaMassActionsTest.xml b/app/code/Magento/Customer/Test/Mftf/Test/AdminDeleteCustomerAddressesFromTheGridViaMassActionsTest.xml index 57446a1ee0c72..c6e72901b062c 100644 --- a/app/code/Magento/Customer/Test/Mftf/Test/AdminDeleteCustomerAddressesFromTheGridViaMassActionsTest.xml +++ b/app/code/Magento/Customer/Test/Mftf/Test/AdminDeleteCustomerAddressesFromTheGridViaMassActionsTest.xml @@ -34,7 +34,7 @@ Step2. On *Customers* page choose customer from preconditions and open it to edit Step3. On edit customer page open *Addresses* tab and find a grid with the additional addresses <!- --> - <amOnPage url="{{AdminCustomerPage.url}}" stepKey="openCustomersGridPage"/> + <actionGroup ref="AdminOpenCustomersGridActionGroup" stepKey="openCustomersGridPage"/> <actionGroup ref="OpenEditCustomerFromAdminActionGroup" stepKey="openEditCustomerPage"> <argument name="customer" value="Simple_US_Customer_Multiple_Addresses"/> </actionGroup> diff --git a/app/code/Magento/Customer/Test/Mftf/Test/AdminDeleteDefaultBillingCustomerAddressTest.xml b/app/code/Magento/Customer/Test/Mftf/Test/AdminDeleteDefaultBillingCustomerAddressTest.xml index f08ea83a70da6..52c8029b8f778 100644 --- a/app/code/Magento/Customer/Test/Mftf/Test/AdminDeleteDefaultBillingCustomerAddressTest.xml +++ b/app/code/Magento/Customer/Test/Mftf/Test/AdminDeleteDefaultBillingCustomerAddressTest.xml @@ -33,7 +33,7 @@ Step1. Login to admin and go to Customers > All Customers. Step2. On *Customers* page choose customer from preconditions and open it to edit <!- --> - <amOnPage url="{{AdminCustomerPage.url}}" stepKey="openCustomersGridPage"/> + <actionGroup ref="AdminOpenCustomersGridActionGroup" stepKey="openCustomersGridPage"/> <actionGroup ref="OpenEditCustomerFromAdminActionGroup" stepKey="openEditCustomerPage"> <argument name="customer" value="Simple_US_Customer_Multiple_Addresses"/> </actionGroup> diff --git a/app/code/Magento/Customer/Test/Mftf/Test/AdminEditDefaultBillingShippingCustomerAddressTest.xml b/app/code/Magento/Customer/Test/Mftf/Test/AdminEditDefaultBillingShippingCustomerAddressTest.xml index 6e44fe96b0d7b..72bda91445256 100644 --- a/app/code/Magento/Customer/Test/Mftf/Test/AdminEditDefaultBillingShippingCustomerAddressTest.xml +++ b/app/code/Magento/Customer/Test/Mftf/Test/AdminEditDefaultBillingShippingCustomerAddressTest.xml @@ -30,7 +30,7 @@ Step2. On *Customers* page choose customer from preconditions and open it to edit Step3. Open *Addresses* tab on edit customer page and press *Add New Address* button <!- --> - <amOnPage url="{{AdminCustomerPage.url}}" stepKey="openCustomersGridPage"/> + <actionGroup ref="AdminOpenCustomersGridActionGroup" stepKey="openCustomersGridPage"/> <actionGroup ref="OpenEditCustomerFromAdminActionGroup" stepKey="openEditCustomerPage"> <argument name="customer" value="Simple_US_Customer_Multiple_Addresses"/> </actionGroup> diff --git a/app/code/Magento/Customer/Test/Mftf/Test/AdminExactMatchSearchInCustomerGridTest.xml b/app/code/Magento/Customer/Test/Mftf/Test/AdminExactMatchSearchInCustomerGridTest.xml index ea4b3645d371f..14d569ed9101d 100644 --- a/app/code/Magento/Customer/Test/Mftf/Test/AdminExactMatchSearchInCustomerGridTest.xml +++ b/app/code/Magento/Customer/Test/Mftf/Test/AdminExactMatchSearchInCustomerGridTest.xml @@ -28,12 +28,12 @@ <after> <deleteData createDataKey="createFirstCustomer" stepKey="deleteFirstCustomer"/> <deleteData createDataKey="createSecondCustomer" stepKey="deleteSecondCustomer"/> - <amOnPage url="{{AdminCustomerPage.url}}" stepKey="openCustomersGridPage"/> + <actionGroup ref="AdminOpenCustomersGridActionGroup" stepKey="openCustomersGridPage"/> <actionGroup ref="AdminResetFilterInCustomerAddressGrid" stepKey="clearCustomerGridFilter"/> <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> </after> <!--Step 1: Go to Customers > All Customers--> - <amOnPage url="{{AdminCustomerPage.url}}" stepKey="openCustomersGridPage"/> + <actionGroup ref="AdminOpenCustomersGridActionGroup" stepKey="openCustomersGridPage"/> <!--Step 2: On Customers grid page search customer by keyword with quotes--> <actionGroup ref="SearchAdminDataGridByKeywordActionGroup" stepKey="searchCustomer"> <argument name="keyword" value="$$createSecondCustomer.firstname$$"/> diff --git a/app/code/Magento/Customer/Test/Mftf/Test/AdminGridSearchSelectAllTest.xml b/app/code/Magento/Customer/Test/Mftf/Test/AdminGridSearchSelectAllTest.xml new file mode 100644 index 0000000000000..7f1b1dfee7ce0 --- /dev/null +++ b/app/code/Magento/Customer/Test/Mftf/Test/AdminGridSearchSelectAllTest.xml @@ -0,0 +1,56 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminGridSearchSelectAllTest"> + <annotations> + <stories value="Selection should be removed during search."/> + <title value="Selection should be removed during search."/> + <description value="Empty selected before and after search, like it works for filter"/> + <testCaseId value="MC-37659"/> + <severity value="CRITICAL"/> + <group value="uI"/> + </annotations> + <before> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + <!--Create three customers--> + <createData entity="Simple_US_Customer" stepKey="firstCustomer"/> + <createData entity="Simple_US_Customer" stepKey="secondCustomer"/> + <createData entity="Simple_US_Customer" stepKey="thirdCustomer"/> + </before> + <after> + <!--Remove two created customers, third already deleted--> + <deleteData createDataKey="firstCustomer" stepKey="deleteFirstCustomer"/> + <deleteData createDataKey="secondCustomer" stepKey="deleteSecondCustomer"/> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> + </after> + <actionGroup ref="AdminOpenCustomersGridActionGroup" stepKey="openCustomerPage"/> + <!-- search Admin Data Grid By Keyword --> + <conditionalClick selector="{{AdminDataGridHeaderSection.clearFilters}}" dependentSelector="{{AdminDataGridHeaderSection.clearFilters}}" visible="true" stepKey="clickClearFilters"/> + <fillField selector="{{AdminDataGridHeaderSection.search}}" userInput="$$secondCustomer.email$$" stepKey="fillKeywordSearchFieldWithSecondCustomerEmail"/> + <click selector="{{AdminDataGridHeaderSection.submitSearch}}" stepKey="clickKeywordSearch"/> + <waitForPageLoad stepKey="waitForPageLoad"/> + <!-- Select all from dropdown --> + <actionGroup ref="AdminGridSelectAllActionGroup" stepKey="selectAllCustomers"/> + <!-- Clear searching By Keyword--> + <conditionalClick selector="{{AdminDataGridHeaderSection.clearFilters}}" dependentSelector="{{AdminDataGridHeaderSection.clearFilters}}" visible="true" stepKey="clickClearFiltersAfterSearch"/> + <waitForPageLoad stepKey="waitForPageLoadAfterSearchRemoved"/> + <!-- Check if selection has bee removed --> + <dontSeeCheckboxIsChecked selector="{{AdminCustomerGridMainActionsSection.customerCheckbox($$secondCustomer.email$$)}}" stepKey="checkSecondCustomerCheckboxIsUnchecked"/> + <!-- Check delete action --> + <click selector="{{AdminCustomerGridMainActionsSection.customerCheckbox(($$thirdCustomer.email$$)}}" stepKey="selectThirdCustomer"/> + <seeCheckboxIsChecked selector="{{AdminCustomerGridMainActionsSection.customerCheckbox($$thirdCustomer.email$$)}}" stepKey="checkThirdCustomerIsChecked"/> + <!-- Use delete action for selected --> + <click selector="{{AdminCustomerGridMainActionsSection.actions}}" stepKey="clickActions"/> + <click selector="{{AdminCustomerGridMainActionsSection.delete}}" stepKey="clickDelete"/> + <waitForAjaxLoad stepKey="waitForLoadConfirmation"/> + <click selector="{{AdminConfirmationModalSection.ok}}" stepKey="confirmDelete"/> + <!-- Check if only one record record has been deleted --> + <see selector="{{AdminMessagesSection.success}}" userInput="A total of 1 record(s) were deleted" stepKey="seeSuccess"/> + </test> +</tests> diff --git a/app/code/Magento/Customer/Test/Mftf/Test/AdminGridSelectAllOnPageTest.xml b/app/code/Magento/Customer/Test/Mftf/Test/AdminGridSelectAllOnPageTest.xml new file mode 100644 index 0000000000000..578c6786c1505 --- /dev/null +++ b/app/code/Magento/Customer/Test/Mftf/Test/AdminGridSelectAllOnPageTest.xml @@ -0,0 +1,56 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminGridSelectAllOnPageTest"> + <annotations> + <stories value="Toggle select page."/> + <title value="Toggle select page."/> + <description value="Empty selected before and after search, like it works for filter"/> + <testCaseId value="MC-37660"/> + <severity value="CRITICAL"/> + <group value="uI"/> + </annotations> + <before> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + <!--Create three customers--> + <createData entity="Simple_US_Customer" stepKey="firstCustomer"/> + <createData entity="Simple_US_Customer" stepKey="secondCustomer"/> + <createData entity="Simple_US_Customer" stepKey="thirdCustomer"/> + </before> + <after> + <!--Remove created customers --> + <deleteData createDataKey="firstCustomer" stepKey="deleteFirstCustomer"/> + <deleteData createDataKey="secondCustomer" stepKey="deleteSecondCustomer"/> + <deleteData createDataKey="thirdCustomer" stepKey="deleteThirdCustomer"/> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> + </after> + <actionGroup ref="AdminOpenCustomersGridActionGroup" stepKey="openCustomerPage"/> + <!-- Select all from dropdown --> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearFilters" /> + <actionGroup ref="AdminGridSelectAllActionGroup" stepKey="selectAllCustomers"/> + <!-- Deselect third customer --> + <click selector="{{AdminCustomerGridMainActionsSection.customerCheckbox(($$thirdCustomer.email$$)}}" stepKey="selectThirdCustomer"/> + <dontSeeCheckboxIsChecked selector="{{AdminCustomerGridMainActionsSection.customerCheckbox($$thirdCustomer.email$$)}}" stepKey="checkThirdCustomerCheckboxIsUnchecked"/> + <!-- Click select all on page checkbox --> + <actionGroup ref="AdminSelectAllCustomers" stepKey="selectAllCustomersOnPage"/> + <seeElement selector="{{AdminCustomerGridMainActionsSection.multicheckTick}}" stepKey="waitForElement"/> + <seeCheckboxIsChecked selector="{{AdminCustomerGridMainActionsSection.multicheckTick}}" stepKey="checkAllSelectedCheckBoxIsChecked"/> + <!-- Check all created records selected --> + <seeCheckboxIsChecked selector="{{AdminCustomerGridMainActionsSection.customerCheckbox($$firstCustomer.email$$)}}" stepKey="checkFirstCustomerIsCheckedAfterSelectPage"/> + <seeCheckboxIsChecked selector="{{AdminCustomerGridMainActionsSection.customerCheckbox($$secondCustomer.email$$)}}" stepKey="checkSecondCustomerIsCheckedAfterSelectPage"/> + <seeCheckboxIsChecked selector="{{AdminCustomerGridMainActionsSection.customerCheckbox($$thirdCustomer.email$$)}}" stepKey="checkThirdCustomerIsCheckedAfterSelectPage"/> + <!-- Click deselect all on page checkbox --> + <actionGroup ref="AdminSelectAllCustomers" stepKey="deselectAllCustomersCheckbox"/> + <dontSeeCheckboxIsChecked selector="{{AdminCustomerGridMainActionsSection.multicheckTick}}" stepKey="checkAllSelectedCheckBoxUnchecked"/> + <!-- Check all created records unselected --> + <dontSeeCheckboxIsChecked selector="{{AdminCustomerGridMainActionsSection.customerCheckbox($$firstCustomer.email$$)}}" stepKey="checkFirstCustomerIsUncheckedAfterSelectPage"/> + <dontSeeCheckboxIsChecked selector="{{AdminCustomerGridMainActionsSection.customerCheckbox($$secondCustomer.email$$)}}" stepKey="checkSecondCustomerIsUncheckedAfterSelectPage"/> + <dontSeeCheckboxIsChecked selector="{{AdminCustomerGridMainActionsSection.customerCheckbox($$thirdCustomer.email$$)}}" stepKey="checkThirdCustomerIsUncheckedAfterSelectPage"/> + </test> +</tests> diff --git a/app/code/Magento/Customer/Test/Mftf/Test/AdminSearchCustomerAddressByKeywordTest.xml b/app/code/Magento/Customer/Test/Mftf/Test/AdminSearchCustomerAddressByKeywordTest.xml index b13a06b9ef858..bac1c665cbe78 100644 --- a/app/code/Magento/Customer/Test/Mftf/Test/AdminSearchCustomerAddressByKeywordTest.xml +++ b/app/code/Magento/Customer/Test/Mftf/Test/AdminSearchCustomerAddressByKeywordTest.xml @@ -34,7 +34,7 @@ Step2. On *Customers* page choose customer from preconditions and open it to edit Step3. On edit customer page open *Addresses* tab and find a grid with the additional addresses <!- --> - <amOnPage url="{{AdminCustomerPage.url}}" stepKey="openCustomersGridPage"/> + <actionGroup ref="AdminOpenCustomersGridActionGroup" stepKey="openCustomersGridPage"/> <actionGroup ref="OpenEditCustomerFromAdminActionGroup" stepKey="openEditCustomerPage"> <argument name="customer" value="Simple_US_Customer_Multiple_Addresses"/> </actionGroup> diff --git a/app/code/Magento/Customer/Test/Mftf/Test/AdminSetCustomerDefaultBillingAddressTest.xml b/app/code/Magento/Customer/Test/Mftf/Test/AdminSetCustomerDefaultBillingAddressTest.xml index 5ce96a8dcab3c..65dcf572f19fb 100644 --- a/app/code/Magento/Customer/Test/Mftf/Test/AdminSetCustomerDefaultBillingAddressTest.xml +++ b/app/code/Magento/Customer/Test/Mftf/Test/AdminSetCustomerDefaultBillingAddressTest.xml @@ -30,7 +30,7 @@ Step2. On *Customers* page choose customer from preconditions and open it to edit Step3. On edit customer page open *Addresses* tab and find a grid with the additional addresses <!- --> - <amOnPage url="{{AdminCustomerPage.url}}" stepKey="openCustomersGridPage"/> + <actionGroup ref="AdminOpenCustomersGridActionGroup" stepKey="openCustomersGridPage"/> <actionGroup ref="OpenEditCustomerFromAdminActionGroup" stepKey="openEditCustomerPage"> <argument name="customer" value="Simple_US_Customer_Multiple_Addresses_No_Default_Address"/> </actionGroup> diff --git a/app/code/Magento/Customer/Test/Mftf/Test/AdminSetCustomerDefaultShippingAddressTest.xml b/app/code/Magento/Customer/Test/Mftf/Test/AdminSetCustomerDefaultShippingAddressTest.xml index a9832c86562f1..bf76e29b185ba 100644 --- a/app/code/Magento/Customer/Test/Mftf/Test/AdminSetCustomerDefaultShippingAddressTest.xml +++ b/app/code/Magento/Customer/Test/Mftf/Test/AdminSetCustomerDefaultShippingAddressTest.xml @@ -30,7 +30,7 @@ Step2. On *Customers* page choose customer from preconditions and open it to edit Step3. On edit customer page open *Addresses* tab and find a grid with the additional addresses <!- --> - <amOnPage url="{{AdminCustomerPage.url}}" stepKey="openCustomersGridPage"/> + <actionGroup ref="AdminOpenCustomersGridActionGroup" stepKey="openCustomersGridPage"/> <actionGroup ref="OpenEditCustomerFromAdminActionGroup" stepKey="openEditCustomerPage"> <argument name="customer" value="Simple_US_Customer_Multiple_Addresses_No_Default_Address"/> </actionGroup> diff --git a/app/code/Magento/Customer/Test/Mftf/Test/AdminUpdateCustomerTest/AdminUpdateCustomerInfoFromDefaultToNonDefaultTest.xml b/app/code/Magento/Customer/Test/Mftf/Test/AdminUpdateCustomerTest/AdminUpdateCustomerInfoFromDefaultToNonDefaultTest.xml index 8af07bc2c2d53..fb6793b1751a6 100644 --- a/app/code/Magento/Customer/Test/Mftf/Test/AdminUpdateCustomerTest/AdminUpdateCustomerInfoFromDefaultToNonDefaultTest.xml +++ b/app/code/Magento/Customer/Test/Mftf/Test/AdminUpdateCustomerTest/AdminUpdateCustomerInfoFromDefaultToNonDefaultTest.xml @@ -27,9 +27,7 @@ </before> <after> <deleteData stepKey="deleteCustomer" createDataKey="customer"/> - <!-- Reset customer grid filter --> - <amOnPage stepKey="goToCustomersGridPage" url="{{AdminCustomerPage.url}}"/> - <waitForPageLoad stepKey="waitForCustomersGrid"/> + <actionGroup ref="AdminOpenCustomersGridActionGroup" stepKey="goToCustomersGridPage"/> <actionGroup stepKey="resetFilter" ref="AdminResetFilterInCustomerGrid"/> <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> </after> @@ -47,9 +45,7 @@ <argument name="customerAddress" value="CustomerAddressSimple"/> </actionGroup> <actionGroup stepKey="saveAndCheckSuccessMessage" ref="AdminSaveCustomerAndAssertSuccessMessage"/> - <!-- Assert Customer in Customer grid --> - <amOnPage stepKey="goToCustomersGridPage" url="{{AdminCustomerPage.url}}"/> - <waitForPageLoad stepKey="waitForCustomersGrid"/> + <actionGroup ref="AdminOpenCustomersGridActionGroup" stepKey="goToCustomersGridPage"/> <actionGroup stepKey="resetFilter" ref="AdminResetFilterInCustomerGrid"/> <actionGroup stepKey="filterByEamil" ref="AdminFilterCustomerGridByEmail"> <argument name="email" value="updated$$customer.email$$"/> diff --git a/app/code/Magento/Customer/Test/Mftf/Test/AdminVerifyCustomerAddressStateContainValuesOnceTest.xml b/app/code/Magento/Customer/Test/Mftf/Test/AdminVerifyCustomerAddressStateContainValuesOnceTest.xml index 2aa85f8c966a9..785ee1a02abe1 100644 --- a/app/code/Magento/Customer/Test/Mftf/Test/AdminVerifyCustomerAddressStateContainValuesOnceTest.xml +++ b/app/code/Magento/Customer/Test/Mftf/Test/AdminVerifyCustomerAddressStateContainValuesOnceTest.xml @@ -31,9 +31,7 @@ <actionGroup ref="AdminClearCustomersFiltersActionGroup" stepKey="clearFilters"/> <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> </after> - - <!-- Go to Customers > All Customers.--> - <amOnPage url="{{AdminCustomerPage.url}}" stepKey="openCustomersGridPage"/> + <actionGroup ref="AdminOpenCustomersGridActionGroup" stepKey="openCustomersGridPage"/> <!--Select created customer, Click Edit mode--> <actionGroup ref="OpenEditCustomerFromAdminActionGroup" stepKey="openEditCustomerPageWithAddresses"> diff --git a/app/code/Magento/Customer/Test/Mftf/Test/AllowedCountriesRestrictionApplyOnBackendTest.xml b/app/code/Magento/Customer/Test/Mftf/Test/AllowedCountriesRestrictionApplyOnBackendTest.xml index 781d721fd5132..6a157c6312530 100644 --- a/app/code/Magento/Customer/Test/Mftf/Test/AllowedCountriesRestrictionApplyOnBackendTest.xml +++ b/app/code/Magento/Customer/Test/Mftf/Test/AllowedCountriesRestrictionApplyOnBackendTest.xml @@ -116,8 +116,7 @@ <waitForPageLoad stepKey="waitForCustomersPage"/> <!--Go to Customers grid and check that filter countries amount is the same as initial allowed countries amount--> <comment userInput="Go to Customers grid and check that filter countries amount is the same as initial allowed countries amount" stepKey="compareCountriesAmount"/> - <amOnPage url="{{AdminCustomerPage.url}}" stepKey="goToCustomersGrid"/> - <waitForPageLoad stepKey="waitForCustomersGrid"/> + <actionGroup ref="AdminOpenCustomersGridActionGroup" stepKey="goToCustomersGrid"/> <click selector="{{AdminDataGridHeaderSection.filters}}" stepKey="openFiltersSectionOnCustomersGrid"/> <executeJS function="var len = document.querySelectorAll('{{AdminCustomerFiltersSection.countryOptions}}').length; return len-1;" stepKey="countriesAmount2"/> <assertEquals stepKey="assertCountryAmounts"> diff --git a/app/code/Magento/Customer/Test/Mftf/Test/SearchByEmailInCustomerGridTest.xml b/app/code/Magento/Customer/Test/Mftf/Test/SearchByEmailInCustomerGridTest.xml index d4351c8bcdc84..b2a78686cdad5 100644 --- a/app/code/Magento/Customer/Test/Mftf/Test/SearchByEmailInCustomerGridTest.xml +++ b/app/code/Magento/Customer/Test/Mftf/Test/SearchByEmailInCustomerGridTest.xml @@ -26,12 +26,12 @@ <after> <deleteData createDataKey="createFirstCustomer" stepKey="deleteFirstCustomer"/> <deleteData createDataKey="createSecondCustomer" stepKey="deleteSecondCustomer"/> - <amOnPage url="{{AdminCustomerPage.url}}" stepKey="openCustomersGridPage"/> + <actionGroup ref="AdminOpenCustomersGridActionGroup" stepKey="openCustomersGridPage"/> <actionGroup ref="AdminResetFilterInCustomerAddressGrid" stepKey="clearCustomerGridFilter"/> <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> </after> <!--Step 1: Go to Customers > All Customers--> - <amOnPage url="{{AdminCustomerPage.url}}" stepKey="openCustomersGridPage"/> + <actionGroup ref="AdminOpenCustomersGridActionGroup" stepKey="openCustomersGridPage"/> <!--Step 2: On Customers grid page search customer by keyword--> <actionGroup ref="SearchAdminDataGridByKeywordActionGroup" stepKey="searchCustomer"> <argument name="keyword" value="$$createSecondCustomer.email$$"/> diff --git a/app/code/Magento/Customer/Test/Mftf/Test/StorefrontClearAllCompareProductsTest.xml b/app/code/Magento/Customer/Test/Mftf/Test/StorefrontClearAllCompareProductsTest.xml index 81208da18373c..130e1ba6723ae 100644 --- a/app/code/Magento/Customer/Test/Mftf/Test/StorefrontClearAllCompareProductsTest.xml +++ b/app/code/Magento/Customer/Test/Mftf/Test/StorefrontClearAllCompareProductsTest.xml @@ -186,8 +186,7 @@ <argument name="productVar" value="$$createDownloadableProduct1$$"/> </actionGroup> - <amOnPage url="{{StorefrontCustomerDashboardPage.url}}" stepKey="amOnMyAccountDashboard1"/> - <waitForPageLoad stepKey="waitForPageLoad1"/> + <actionGroup ref="StorefrontOpenMyAccountPageActionGroup" stepKey="amOnMyAccountDashboard1"/> <actionGroup ref="StorefrontClearCompareActionGroup" stepKey="clearComparedProducts1"/> </test> diff --git a/app/code/Magento/Customer/Test/Mftf/Test/StorefrontCreateCustomerWithInvalidDataTest.xml b/app/code/Magento/Customer/Test/Mftf/Test/StorefrontCreateCustomerWithInvalidDataTest.xml new file mode 100644 index 0000000000000..ef610831a721d --- /dev/null +++ b/app/code/Magento/Customer/Test/Mftf/Test/StorefrontCreateCustomerWithInvalidDataTest.xml @@ -0,0 +1,39 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="StorefrontCreateCustomerWithInvalidDataTest"> + <annotations> + <stories value="Create a Customer via the Storefront"/> + <features value="Customer"/> + <title value="Register customer on storefront after customer form validation failed."/> + <description value="Customer should be able to re-submit register form after correcting invalid form data on storefront."/> + <severity value="CRITICAL"/> + <testCaseId value="MC-38532"/> + <useCaseId value="MC-38509"/> + <group value="customer"/> + </annotations> + + <actionGroup ref="StorefrontOpenCustomerAccountCreatePageActionGroup" stepKey="openCreateAccountPage"/> + <!--Try to submit register form with wrong password.--> + <actionGroup ref="StorefrontFillCustomerAccountCreationFormActionGroup" stepKey="fillCreateAccountFormWithWrongData"> + <argument name="customer" value="Simple_Customer_With_Password_Length_Is_Below_Eight_Characters"/> + </actionGroup> + <actionGroup ref="StorefrontClickCreateAnAccountCustomerAccountCreationFormActionGroup" stepKey="tryToSubmitFormWithWrongPassword"/> + <actionGroup ref="AssertMessageCustomerCreateAccountPasswordComplexityActionGroup" stepKey="seeTheErrorPasswordLength"> + <argument name="message" value="Minimum length of this field must be equal or greater than 8 symbols. Leading and trailing spaces will be ignored."/> + </actionGroup> + <!--Re-submit customer register form with correct data.--> + <actionGroup ref="StorefrontFillCustomerAccountCreationFormActionGroup" stepKey="fillCreateAccountFormWithCorrectData"> + <argument name="customer" value="Simple_US_Customer"/> + </actionGroup> + <actionGroup ref="StorefrontClickCreateAnAccountCustomerAccountCreationFormActionGroup" stepKey="submitCreateAccountForm"/> + <actionGroup ref="AssertMessageCustomerCreateAccountActionGroup" stepKey="seeSuccessMessage"/> + </test> +</tests> diff --git a/app/code/Magento/Customer/Test/Unit/Block/Widget/DobTest.php b/app/code/Magento/Customer/Test/Unit/Block/Widget/DobTest.php index 39071f25ea18c..6da31b552c622 100644 --- a/app/code/Magento/Customer/Test/Unit/Block/Widget/DobTest.php +++ b/app/code/Magento/Customer/Test/Unit/Block/Widget/DobTest.php @@ -17,8 +17,10 @@ use Magento\Framework\Data\Form\FilterFactory; use Magento\Framework\Escaper; use Magento\Framework\Exception\NoSuchEntityException; +use Magento\Framework\Json\EncoderInterface; use Magento\Framework\Locale\Resolver; use Magento\Framework\Locale\ResolverInterface; +use Magento\Framework\Stdlib\DateTime\Intl\DateFormatterFactory; use Magento\Framework\Stdlib\DateTime\Timezone; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; use Magento\Framework\View\Element\Html\Date; @@ -50,7 +52,7 @@ class DobTest extends TestCase const YEAR = '2014'; // Value of date('Y', strtotime(self::DATE)) - const DATE_FORMAT = 'M/d/Y'; + const DATE_FORMAT = 'M/dd/y'; /** Constants used by Dob::setDateInput($code, $html) */ const DAY_HTML = @@ -90,6 +92,16 @@ class DobTest extends TestCase */ private $_locale; + /** + * @var EncoderInterface + */ + private $encoder; + + /** + * @var ResolverInterface + */ + private $localeResolver; + /** * @inheritDoc */ @@ -109,17 +121,18 @@ protected function setUp(): void $cache->expects($this->any())->method('getFrontend')->willReturn($frontendCache); $objectManager = new ObjectManager($this); - $localeResolver = $this->getMockForAbstractClass(ResolverInterface::class); - $localeResolver->expects($this->any()) + $this->localeResolver = $this->getMockForAbstractClass(ResolverInterface::class); + $this->localeResolver->expects($this->any()) ->method('getLocale') ->willReturnCallback( function () { return $this->_locale; } ); + $localeResolver = $this->localeResolver; $timezone = $objectManager->getObject( Timezone::class, - ['localeResolver' => $localeResolver] + ['localeResolver' => $localeResolver, 'dateFormatterFactory' => new DateFormatterFactory()] ); $this->_locale = Resolver::DEFAULT_LOCALE; @@ -156,12 +169,17 @@ function () use ($timezone, $localeResolver) { } ); + $this->encoder = $this->getMockForAbstractClass(EncoderInterface::class); + $this->_block = new Dob( $this->context, $this->createMock(Address::class), $this->customerMetadata, $this->createMock(Date::class), - $this->filterFactory + $this->filterFactory, + [], + $this->encoder, + $this->localeResolver ); } @@ -355,10 +373,15 @@ public function getDateFormatDataProvider(): array [ 'ar_SA', preg_replace( - '/[^MmDdYy\/\.\-]/', - '', - (new \IntlDateFormatter('ar_SA', \IntlDateFormatter::SHORT, \IntlDateFormatter::NONE)) - ->getPattern() + '/(?<!d)d(?!d)/', + 'dd', + preg_replace( + '/[^MmDdYy\/\.\-]/', + '', + (new DateFormatterFactory()) + ->create('ar_SA', \IntlDateFormatter::SHORT, \IntlDateFormatter::NONE) + ->getPattern() + ) ) ], [Resolver::DEFAULT_LOCALE, self::DATE_FORMAT], @@ -596,4 +619,80 @@ public function testGetHtmlExtraParamsWithRequiredOption() $this->_block->getHtmlExtraParams() ); } + + /** + * Tests getTranslatedCalendarConfigJson() + * + * @param string $locale + * @param array $expectedArray + * @param string $expectedJson + * @dataProvider getTranslatedCalendarConfigJsonDataProvider + * @return void + */ + public function testGetTranslatedCalendarConfigJson( + string $locale, + array $expectedArray, + string $expectedJson + ): void { + $this->_locale = $locale; + + $this->encoder->expects($this->once()) + ->method('encode') + ->with($expectedArray) + ->willReturn($expectedJson); + + $this->assertEquals( + $expectedJson, + $this->_block->getTranslatedCalendarConfigJson() + ); + } + + /** + * Provider for testGetTranslatedCalendarConfigJson + * + * @return array + */ + public function getTranslatedCalendarConfigJsonDataProvider() + { + return [ + [ + 'locale' => 'en_US', + 'expectedArray' => [ + 'closeText' => 'Done', + 'prevText' => 'Prev', + 'nextText' => 'Next', + 'currentText' => 'Today', + 'monthNames' => ['January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December'], + 'monthNamesShort' => ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'], + 'dayNames' => ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'], + 'dayNamesShort' => ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], + 'dayNamesMin' => ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'], + ], + // phpcs:disable Generic.Files.LineLength.TooLong + 'expectedJson' => '{"closeText":"Done","prevText":"Prev","nextText":"Next","currentText":"Today","monthNames":["January","February","March","April","May","June","July","August","September","October","November","December"],"monthNamesShort":["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],"dayNames":["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],"dayNamesShort":["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],"dayNamesMin":["Su","Mo","Tu","We","Th","Fr","Sa"]}' + // phpcs:enable Generic.Files.LineLength.TooLong + ], + [ + 'locale' => 'de_DE', + 'expectedArray' => [ + 'closeText' => 'Done', + 'prevText' => 'Prev', + 'nextText' => 'Next', + 'currentText' => 'Today', + 'monthNames' => ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', + 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'], + 'monthNamesShort' => ['Jan.', 'Feb.', 'März', 'Apr.', 'Mai', 'Juni', + 'Juli', 'Aug.', 'Sept.', 'Okt.', 'Nov.', 'Dez.'], + 'dayNames' => ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'], + 'dayNamesShort' => ['So.', 'Mo.', 'Di.', 'Mi.', 'Do.', 'Fr.', 'Sa.'], + 'dayNamesMin' => ['So.', 'Mo.', 'Di.', 'Mi.', 'Do.', 'Fr.', 'Sa.'], + ], + // phpcs:disable Generic.Files.LineLength.TooLong + 'expectedJson' => '{"closeText":"Done","prevText":"Prev","nextText":"Next","currentText":"Today","monthNames":["Januar","Februar","M\u00e4rz","April","Mai","Juni","Juli","August","September","Oktober","November","Dezember"],"monthNamesShort":["Jan.","Feb.","M\u00e4rz","Apr.","Mai","Juni","Juli","Aug.","Sept.","Okt.","Nov.","Dez."],"dayNames":["Sonntag","Montag","Dienstag","Mittwoch","Donnerstag","Freitag","Samstag"],"dayNamesShort":["So.","Mo.","Di.","Mi.","Do.","Fr.","Sa."],"dayNamesMin":["So.","Mo.","Di.","Mi.","Do.","Fr.","Sa."]}' + // phpcs:enable Generic.Files.LineLength.TooLong + ], + ]; + } } 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 8cb5957cbd672..ea8a37127e85d 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 @@ -257,11 +257,7 @@ public function testExecuteInvalidFile() $this->urlDecoderMock->expects($this->once())->method('decode')->with($decodedFile)->willReturn($file); $fileFactoryMock = $this->createMock( - FileFactory::class, - [], - [], - '', - false + FileFactory::class ); $controller = $this->objectManager->getObject( diff --git a/app/code/Magento/Customer/Test/Unit/Model/Customer/Attribute/Backend/BillingTest.php b/app/code/Magento/Customer/Test/Unit/Model/Customer/Attribute/Backend/BillingTest.php index cd7154de14858..65f9b62b426c0 100644 --- a/app/code/Magento/Customer/Test/Unit/Model/Customer/Attribute/Backend/BillingTest.php +++ b/app/code/Magento/Customer/Test/Unit/Model/Customer/Attribute/Backend/BillingTest.php @@ -12,7 +12,6 @@ use Magento\Eav\Model\Entity\Attribute\AbstractAttribute; use Magento\Framework\DataObject; use PHPUnit\Framework\TestCase; -use Psr\Log\LoggerInterface; class BillingTest extends TestCase { @@ -23,10 +22,7 @@ class BillingTest extends TestCase protected function setUp(): void { - $logger = $this->getMockBuilder(LoggerInterface::class) - ->getMock(); - /** @var LoggerInterface $logger */ - $this->testable = new Billing($logger); + $this->testable = new Billing(); } public function testBeforeSave() diff --git a/app/code/Magento/Customer/Test/Unit/Model/Customer/Attribute/Backend/ShippingTest.php b/app/code/Magento/Customer/Test/Unit/Model/Customer/Attribute/Backend/ShippingTest.php index 3947a01582313..1f5485309cc19 100644 --- a/app/code/Magento/Customer/Test/Unit/Model/Customer/Attribute/Backend/ShippingTest.php +++ b/app/code/Magento/Customer/Test/Unit/Model/Customer/Attribute/Backend/ShippingTest.php @@ -12,7 +12,6 @@ use Magento\Eav\Model\Entity\Attribute\AbstractAttribute; use Magento\Framework\DataObject; use PHPUnit\Framework\TestCase; -use Psr\Log\LoggerInterface; class ShippingTest extends TestCase { @@ -23,10 +22,7 @@ class ShippingTest extends TestCase protected function setUp(): void { - $logger = $this->getMockBuilder(LoggerInterface::class) - ->getMock(); - /** @var LoggerInterface $logger */ - $this->testable = new Shipping($logger); + $this->testable = new Shipping(); } public function testBeforeSave() diff --git a/app/code/Magento/Customer/Test/Unit/Model/FileProcessorTest.php b/app/code/Magento/Customer/Test/Unit/Model/FileProcessorTest.php index 62964a311af42..e1c771d79694e 100644 --- a/app/code/Magento/Customer/Test/Unit/Model/FileProcessorTest.php +++ b/app/code/Magento/Customer/Test/Unit/Model/FileProcessorTest.php @@ -11,10 +11,13 @@ use Magento\Customer\Api\CustomerMetadataInterface; use Magento\Customer\Model\FileProcessor; use Magento\Framework\App\Filesystem\DirectoryList; +use Magento\Framework\App\ObjectManager; use Magento\Framework\Exception\LocalizedException; use Magento\Framework\File\Mime; use Magento\Framework\Filesystem; +use Magento\Framework\Filesystem\Directory\ReadInterface; use Magento\Framework\Filesystem\Directory\WriteInterface; +use Magento\Framework\ObjectManagerInterface; use Magento\Framework\Url\EncoderInterface; use Magento\Framework\UrlInterface; use Magento\MediaStorage\Model\File\Uploader; @@ -363,17 +366,73 @@ public function testMoveTemporaryFile() $path = CustomerMetadataInterface::ENTITY_TYPE_CUSTOMER . '/' . FileProcessor::TMP_DIR . $filePath; $newPath = $destinationPath . $filePath; + $objectManagerMock = $this->getMockForAbstractClass(ObjectManagerInterface::class); + $mockFileSystem = $this->createMock(Filesystem::class); + $mockRead = $this->createMock(ReadInterface::class); + $objectManagerMock->method('get')->willReturn($mockFileSystem); + $mockFileSystem->method('getDirectoryRead')->willReturn($mockRead); + $mockRead->method('isExist')->willReturn(false); + ObjectManager::setInstance($objectManagerMock); + $this->mediaDirectory->expects($this->once()) ->method('renameFile') ->with($path, $newPath) ->willReturn(true); + $model = $this->getModel(CustomerMetadataInterface::ENTITY_TYPE_CUSTOMER); $this->assertEquals('/f/i' . $filePath, $model->moveTemporaryFile($filePath)); } + public function testMoveTemporaryFileNewFileName() + { + $filePath = '/filename.ext1'; + + $destinationPath = 'customer/f/i'; + + $this->mediaDirectory->expects($this->once()) + ->method('create') + ->with($destinationPath) + ->willReturn(true); + $this->mediaDirectory->expects($this->once()) + ->method('isWritable') + ->with($destinationPath) + ->willReturn(true); + $this->mediaDirectory->expects($this->once()) + ->method('getAbsolutePath') + ->with($destinationPath) + ->willReturn('/' . $destinationPath); + + $path = CustomerMetadataInterface::ENTITY_TYPE_CUSTOMER . '/' . FileProcessor::TMP_DIR . $filePath; + + $objectManagerMock = $this->getMockForAbstractClass(ObjectManagerInterface::class); + $mockFileSystem = $this->createMock(Filesystem::class); + $mockRead = $this->createMock(ReadInterface::class); + $objectManagerMock->method('get')->willReturn($mockFileSystem); + $mockFileSystem->method('getDirectoryRead')->willReturn($mockRead); + $mockRead->method('isExist')->willReturnOnConsecutiveCalls(true, true, false); + ObjectManager::setInstance($objectManagerMock); + + $this->mediaDirectory->expects($this->once()) + ->method('renameFile') + ->with($path, 'customer/f/i/filename_2.ext1') + ->willReturn(true); + + + $model = $this->getModel(CustomerMetadataInterface::ENTITY_TYPE_CUSTOMER); + $this->assertEquals('/f/i/filename_2.ext1', $model->moveTemporaryFile($filePath)); + } + public function testMoveTemporaryFileWithException() { + $objectManagerMock = $this->getMockForAbstractClass(ObjectManagerInterface::class); + $mockFileSystem = $this->createMock(Filesystem::class); + $mockRead = $this->createMock(ReadInterface::class); + $objectManagerMock->method($this->logicalOr('get', 'create'))->willReturn($mockFileSystem); + $mockFileSystem->method('getDirectoryRead')->willReturn($mockRead); + $mockRead->method('isExist')->willReturn(false); + ObjectManager::setInstance($objectManagerMock); + $this->expectException(LocalizedException::class); $this->expectExceptionMessage('Something went wrong while saving the file'); diff --git a/app/code/Magento/Customer/Test/Unit/Model/ForgotPasswordToken/ConfirmCustomerByTokenTest.php b/app/code/Magento/Customer/Test/Unit/Model/ForgotPasswordToken/ConfirmCustomerByTokenTest.php new file mode 100644 index 0000000000000..4a6769e0653ad --- /dev/null +++ b/app/code/Magento/Customer/Test/Unit/Model/ForgotPasswordToken/ConfirmCustomerByTokenTest.php @@ -0,0 +1,95 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Customer\Test\Unit\Model\ForgotPasswordToken; + +use Magento\Customer\Api\CustomerRepositoryInterface; +use Magento\Customer\Api\Data\CustomerInterface; +use Magento\Customer\Model\ForgotPasswordToken\ConfirmCustomerByToken; +use Magento\Customer\Model\ForgotPasswordToken\GetCustomerByToken; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * Test for \Magento\Customer\Model\ForgotPasswordToken\ConfirmCustomerByToken. + */ +class ConfirmCustomerByTokenTest extends TestCase +{ + private const STUB_RESET_PASSWORD_TOKEN = 'resetPassword'; + + /** + * @var ConfirmCustomerByToken; + */ + private $model; + + /** + * @var CustomerInterface|MockObject + */ + private $customerMock; + + /** + * @var CustomerRepositoryInterface|MockObject + */ + private $customerRepositoryMock; + + /** + * @inheritDoc + */ + protected function setUp(): void + { + $this->customerMock = $this->getMockBuilder(CustomerInterface::class) + ->disableOriginalConstructor() + ->addMethods(['setData']) + ->getMockForAbstractClass(); + + $this->customerRepositoryMock = $this->createMock(CustomerRepositoryInterface::class); + + $getCustomerByTokenMock = $this->createMock(GetCustomerByToken::class); + $getCustomerByTokenMock->method('execute')->willReturn($this->customerMock); + + $this->model = new ConfirmCustomerByToken($getCustomerByTokenMock, $this->customerRepositoryMock); + } + + /** + * Confirm customer with confirmation + * + * @return void + */ + public function testExecuteWithConfirmation(): void + { + $this->customerMock->expects($this->once()) + ->method('getConfirmation') + ->willReturn('GWz2ik7Kts517MXAgrm4DzfcxKayGCm4'); + $this->customerMock->expects($this->once()) + ->method('setData') + ->with('ignore_validation_flag', true); + $this->customerMock->expects($this->once()) + ->method('setConfirmation') + ->with(null); + $this->customerRepositoryMock->expects($this->once()) + ->method('save') + ->with($this->customerMock); + + $this->model->execute(self::STUB_RESET_PASSWORD_TOKEN); + } + + /** + * Confirm customer without confirmation + * + * @return void + */ + public function testExecuteWithoutConfirmation(): void + { + $this->customerMock->expects($this->once()) + ->method('getConfirmation') + ->willReturn(null); + $this->customerRepositoryMock->expects($this->never()) + ->method('save'); + + $this->model->execute(self::STUB_RESET_PASSWORD_TOKEN); + } +} diff --git a/app/code/Magento/Customer/Test/Unit/Model/Plugin/CustomerRepository/TransactionWrapperTest.php b/app/code/Magento/Customer/Test/Unit/Model/Plugin/CustomerRepository/TransactionWrapperTest.php index c00b5cce02146..634b0d73219db 100644 --- a/app/code/Magento/Customer/Test/Unit/Model/Plugin/CustomerRepository/TransactionWrapperTest.php +++ b/app/code/Magento/Customer/Test/Unit/Model/Plugin/CustomerRepository/TransactionWrapperTest.php @@ -62,7 +62,7 @@ protected function setUp(): void $this->closureMock = function () use ($customerMock) { return $customerMock; }; - $this->rollbackClosureMock = function () use ($customerMock) { + $this->rollbackClosureMock = function () { throw new \Exception(self::ERROR_MSG); }; diff --git a/app/code/Magento/Customer/Test/Unit/Model/StoreSwitcher/RedirectDataPostprocessorTest.php b/app/code/Magento/Customer/Test/Unit/Model/StoreSwitcher/RedirectDataPostprocessorTest.php new file mode 100644 index 0000000000000..0be0212652058 --- /dev/null +++ b/app/code/Magento/Customer/Test/Unit/Model/StoreSwitcher/RedirectDataPostprocessorTest.php @@ -0,0 +1,136 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Customer\Test\Unit\Model\StoreSwitcher; + +use Magento\Customer\Api\Data\CustomerInterface; +use Magento\Customer\Model\Customer; +use Magento\Customer\Model\CustomerRegistry; +use Magento\Customer\Model\Session; +use Magento\Customer\Model\StoreSwitcher\RedirectDataPostprocessor; +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\Exception\NoSuchEntityException; +use Magento\Store\Api\Data\StoreInterface; +use Magento\Store\Model\StoreSwitcher\ContextInterface; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +class RedirectDataPostprocessorTest extends TestCase +{ + /** + * @var Session + */ + private $session; + /** + * @var RedirectDataPostprocessor + */ + private $model; + /** + * @var ContextInterface|MockObject + */ + private $context; + + /** + * @inheritDoc + */ + protected function setUp(): void + { + parent::setUp(); + + $customerRegistry = $this->createMock(CustomerRegistry::class); + $this->session = $this->createMock(Session::class); + $logger = $this->createMock(LoggerInterface::class); + $this->model = new RedirectDataPostprocessor( + $customerRegistry, + $this->session, + $logger + ); + + $store1 = $this->createConfiguredMock( + StoreInterface::class, + [ + 'getCode' => 'en', + 'getId' => 1, + ] + ); + $store2 = $this->createConfiguredMock( + StoreInterface::class, + [ + 'getCode' => 'fr', + 'getId' => 2, + ] + ); + $this->context = $this->createConfiguredMock( + ContextInterface::class, + [ + 'getFromStore' => $store2, + 'getTargetStore' => $store1, + ] + ); + + $customerRegistry->method('retrieve') + ->willReturnCallback( + function ($id) { + switch ($id) { + case 1: + throw new NoSuchEntityException(__('Customer does not exist')); + case 2: + throw new LocalizedException(__('Something went wrong')); + default: + $customer = $this->createMock(Customer::class); + $customer->method('getSharedStoreIds') + ->willReturn(!($id % 2) ? [1, 2] : [2]); + $customer->method('getDataModel') + ->willReturn( + $this->createConfiguredMock( + CustomerInterface::class, + [ + 'getId' => $id + ] + ) + ); + return $customer; + } + } + ); + } + + public function testProcessShouldLoginCustomerIfCustomerIsRegisteredInTargetStore(): void + { + $data = ['customer_id' => 4]; + $this->session->expects($this->once()) + ->method('setCustomerDataAsLoggedIn'); + $this->model->process($this->context, $data); + } + + public function testProcessShouldNotLoginCustomerIfNotRegisteredInTargetStore(): void + { + $data = ['customer_id' => 3]; + $this->session->expects($this->never()) + ->method('setCustomerDataAsLoggedIn'); + $this->model->process($this->context, $data); + } + + public function testProcessShouldThrowExceptionIfCustomerDoesNotExist(): void + { + $this->expectErrorMessage('Something went wrong.'); + $data = ['customer_id' => 1]; + $this->session->expects($this->never()) + ->method('setCustomerDataAsLoggedIn'); + $this->model->process($this->context, $data); + } + + public function testProcessShouldThrowExceptionIfAnErrorOccur(): void + { + $this->expectErrorMessage('Something went wrong.'); + $data = ['customer_id' => 2]; + $this->session->expects($this->never()) + ->method('setCustomerDataAsLoggedIn'); + $this->model->process($this->context, $data); + } +} diff --git a/app/code/Magento/Customer/Test/Unit/Model/StoreSwitcher/RedirectDataPreprocessorTest.php b/app/code/Magento/Customer/Test/Unit/Model/StoreSwitcher/RedirectDataPreprocessorTest.php new file mode 100644 index 0000000000000..3d0c9c2e0a630 --- /dev/null +++ b/app/code/Magento/Customer/Test/Unit/Model/StoreSwitcher/RedirectDataPreprocessorTest.php @@ -0,0 +1,121 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Customer\Test\Unit\Model\StoreSwitcher; + +use Magento\Authorization\Model\UserContextInterface; +use Magento\Customer\Model\Customer; +use Magento\Customer\Model\CustomerRegistry; +use Magento\Customer\Model\Session; +use Magento\Customer\Model\StoreSwitcher\RedirectDataPreprocessor; +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\Exception\NoSuchEntityException; +use Magento\Store\Api\Data\StoreInterface; +use Magento\Store\Model\StoreSwitcher\ContextInterface; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +class RedirectDataPreprocessorTest extends TestCase +{ + /** + * @var RedirectDataPreprocessor + */ + private $model; + /** + * @var Session|MockObject + */ + private $context; + /** + * @var UserContextInterface|MockObject + */ + private $session; + + /** + * @inheritDoc + */ + protected function setUp(): void + { + parent::setUp(); + $customerRegistry = $this->createMock(CustomerRegistry::class); + $logger = $this->createMock(LoggerInterface::class); + $this->session = $this->createMock(Session::class); + $this->model = new RedirectDataPreprocessor( + $customerRegistry, + $this->session, + $logger + ); + + $store1 = $this->createConfiguredMock( + StoreInterface::class, + [ + 'getCode' => 'en', + 'getId' => 1, + ] + ); + $store2 = $this->createConfiguredMock( + StoreInterface::class, + [ + 'getCode' => 'fr', + 'getId' => 2, + ] + ); + $this->context = $this->createConfiguredMock( + ContextInterface::class, + [ + 'getFromStore' => $store2, + 'getTargetStore' => $store1, + ] + ); + + $customerRegistry->method('retrieve') + ->willReturnCallback( + function ($id) { + switch ($id) { + case 1: + throw new NoSuchEntityException(__('Customer does not exist')); + case 2: + throw new LocalizedException(__('Something went wrong')); + default: + $customer = $this->createMock(Customer::class); + $customer->method('getSharedStoreIds') + ->willReturn(!($id % 2) ? [1, 2] : [2]); + $customer->method('getId') + ->willReturn($id); + return $customer; + } + } + ); + } + + /** + * @dataProvider processDataProvider + * @param int|null $customerId + * @param array $data + */ + public function testProcess(?int $customerId, array $data): void + { + $this->session->method('isLoggedIn') + ->willReturn(true); + $this->session->method('getCustomerId') + ->willReturn($customerId); + $this->assertEquals($data, $this->model->process($this->context, [])); + } + + /** + * @return array + */ + public function processDataProvider(): array + { + return [ + [1, []], + [2, []], + [3, []], + [4, ['customer_id' => 4]] + ]; + } +} diff --git a/app/code/Magento/Customer/etc/frontend/di.xml b/app/code/Magento/Customer/etc/frontend/di.xml index 2a6e36a1ea3d7..31f3e11522e12 100644 --- a/app/code/Magento/Customer/etc/frontend/di.xml +++ b/app/code/Magento/Customer/etc/frontend/di.xml @@ -113,4 +113,18 @@ </argument> </arguments> </type> + <type name="Magento\Store\Model\StoreSwitcher\RedirectDataPreprocessorComposite"> + <arguments> + <argument name="processors" xsi:type="array"> + <item name="customer_session" xsi:type="object">Magento\Customer\Model\StoreSwitcher\RedirectDataPreprocessor</item> + </argument> + </arguments> + </type> + <type name="Magento\Store\Model\StoreSwitcher\RedirectDataPostprocessorComposite"> + <arguments> + <argument name="processors" xsi:type="array"> + <item name="customer_session" xsi:type="object">Magento\Customer\Model\StoreSwitcher\RedirectDataPostprocessor</item> + </argument> + </arguments> + </type> </config> diff --git a/app/code/Magento/Customer/etc/webapi.xml b/app/code/Magento/Customer/etc/webapi.xml index 38717619406aa..68c8da8744a05 100644 --- a/app/code/Magento/Customer/etc/webapi.xml +++ b/app/code/Magento/Customer/etc/webapi.xml @@ -227,7 +227,7 @@ <route url="/V1/customers/:customerId" method="DELETE"> <service class="Magento\Customer\Api\CustomerRepositoryInterface" method="deleteById"/> <resources> - <resource ref="Magento_Customer::manage"/> + <resource ref="Magento_Customer::delete"/> </resources> </route> <route url="/V1/customers/isEmailAvailable" method="POST"> diff --git a/app/code/Magento/Customer/etc/webapi_rest/di.xml b/app/code/Magento/Customer/etc/webapi_rest/di.xml index d07d1a61c3d62..18627b68320ed 100644 --- a/app/code/Magento/Customer/etc/webapi_rest/di.xml +++ b/app/code/Magento/Customer/etc/webapi_rest/di.xml @@ -31,4 +31,6 @@ </argument> </arguments> </type> + <preference for="Magento\Customer\Api\AccountManagementInterface" + type="Magento\Customer\Model\AccountManagementApi" /> </config> diff --git a/app/code/Magento/Customer/etc/webapi_soap/di.xml b/app/code/Magento/Customer/etc/webapi_soap/di.xml index c23de8ef3f7e1..cb0b1ce58a594 100644 --- a/app/code/Magento/Customer/etc/webapi_soap/di.xml +++ b/app/code/Magento/Customer/etc/webapi_soap/di.xml @@ -18,4 +18,6 @@ </argument> </arguments> </type> + <preference for="Magento\Customer\Api\AccountManagementInterface" + type="Magento\Customer\Model\AccountManagementApi" /> </config> diff --git a/app/code/Magento/Customer/view/adminhtml/web/js/form/components/insert-listing.js b/app/code/Magento/Customer/view/adminhtml/web/js/form/components/insert-listing.js index 912d4b32130ec..1578677414b78 100644 --- a/app/code/Magento/Customer/view/adminhtml/web/js/form/components/insert-listing.js +++ b/app/code/Magento/Customer/view/adminhtml/web/js/form/components/insert-listing.js @@ -62,7 +62,9 @@ define([ * @param {Object} data - customer address */ deleteMassaction: function (data) { - var ids = _.map(data, function (val) { + var ids = data.selected || this.selections().selected(); + + ids = _.map(ids, function (val) { return parseFloat(val); }); @@ -70,7 +72,7 @@ define([ }, /** - * Delete customer address by ids + * Delete customer address and selections by provided ids. * * @param {Array} ids */ @@ -85,6 +87,10 @@ define([ if (ids.indexOf(defaultBillingId) !== -1) { this.source.set('data.default_billing_address', []); } + + _.each(ids, function (id) { + this.selections().deselect(id.toString(), false); + }, this); } }); }); diff --git a/app/code/Magento/Customer/view/base/ui_component/customer_form.xml b/app/code/Magento/Customer/view/base/ui_component/customer_form.xml index 78bbd612f5b70..065d87792665f 100644 --- a/app/code/Magento/Customer/view/base/ui_component/customer_form.xml +++ b/app/code/Magento/Customer/view/base/ui_component/customer_form.xml @@ -511,7 +511,7 @@ <imports>true</imports> </dataLinks> <externalProvider>customer_address_listing.customer_address_listing_data_source</externalProvider> - <selectionsProvider>customer_address_listing.customer_address_listing.customer_address_listing_columns.ids</selectionsProvider> + <selectionsProvider>customer_address_listing.customer_address_listing.customer_address_columns.ids</selectionsProvider> <autoRender>true</autoRender> <dataScope>customer_address_listing</dataScope> <ns>customer_address_listing</ns> diff --git a/app/code/Magento/Customer/view/frontend/templates/address/edit.phtml b/app/code/Magento/Customer/view/frontend/templates/address/edit.phtml index 7f09361e4d505..a4a500b7d1b37 100644 --- a/app/code/Magento/Customer/view/frontend/templates/address/edit.phtml +++ b/app/code/Magento/Customer/view/frontend/templates/address/edit.phtml @@ -58,6 +58,13 @@ $viewModel = $block->getViewModel(); <div class="field street required"> <label for="street_1" class="label"><span><?= /* @noEscape */ $_street ?></span></label> <div class="control"> + <div class="field primary"> + <label for="street_1" class="label"> + <span> + <?= $escaper->escapeHtml(__('Street Address: Line %1', 1)) ?> + </span> + </label> + </div> <input type="text" name="street[]" value="<?= $escaper->escapeHtmlAttr($block->getStreetLine(1)) ?>" @@ -68,7 +75,7 @@ $viewModel = $block->getViewModel(); <?php for ($_i = 1, $_n = $viewModel->addressGetStreetLines(); $_i < $_n; $_i++): ?> <div class="field additional"> <label class="label" for="street_<?= /* @noEscape */ $_i + 1 ?>"> - <span><?= $escaper->escapeHtml(__('Street Address %1', $_i + 1)) ?></span> + <span><?= $escaper->escapeHtml(__('Street Address: Line %1', $_i + 1)) ?></span> </label> <div class="control"> <input type="text" name="street[]" diff --git a/app/code/Magento/Customer/view/frontend/templates/form/edit.phtml b/app/code/Magento/Customer/view/frontend/templates/form/edit.phtml index 9821cff73a3dd..b64ad58c17afc 100644 --- a/app/code/Magento/Customer/view/frontend/templates/form/edit.phtml +++ b/app/code/Magento/Customer/view/frontend/templates/form/edit.phtml @@ -105,7 +105,11 @@ use Magento\Customer\Block\Widget\Name; </div> </div> </fieldset> - <?= $block->getChildHtml('form_additional_info') ?> + + <fieldset class="fieldset additional_info"> + <?= $block->getChildHtml('form_additional_info') ?> + </fieldset> + <div class="actions-toolbar"> <div class="primary"> <button type="submit" class="action save primary" title="<?= $block->escapeHtmlAttr(__('Save')) ?>"> diff --git a/app/code/Magento/Customer/view/frontend/templates/form/login.phtml b/app/code/Magento/Customer/view/frontend/templates/form/login.phtml index a1d1a0260672a..73e9ec35d51c8 100644 --- a/app/code/Magento/Customer/view/frontend/templates/form/login.phtml +++ b/app/code/Magento/Customer/view/frontend/templates/form/login.phtml @@ -50,14 +50,13 @@ </fieldset> </form> </div> -</div> - -<script type="text/x-magento-init"> - { - "*": { - "Magento_Customer/js/block-submit-on-send": { - "formId": "login-form" + <script type="text/x-magento-init"> + { + "*": { + "Magento_Customer/js/block-submit-on-send": { + "formId": "login-form" + } } } - } -</script> + </script> +</div> diff --git a/app/code/Magento/Customer/view/frontend/templates/form/register.phtml b/app/code/Magento/Customer/view/frontend/templates/form/register.phtml index 99040706e50ac..5e58f94683ec1 100644 --- a/app/code/Magento/Customer/view/frontend/templates/form/register.phtml +++ b/app/code/Magento/Customer/view/frontend/templates/form/register.phtml @@ -259,8 +259,12 @@ $formData = $block->getFormData(); autocomplete="off"> </div> </div> + </fieldset> + + <fieldset class="fieldset additional_info"> <?= $block->getChildHtml('form_additional_info') ?> </fieldset> + <div class="actions-toolbar"> <div class="primary"> <button type="submit" diff --git a/app/code/Magento/Customer/view/frontend/templates/widget/dob.phtml b/app/code/Magento/Customer/view/frontend/templates/widget/dob.phtml index 3c2f970faadee..da1c85cce9856 100644 --- a/app/code/Magento/Customer/view/frontend/templates/widget/dob.phtml +++ b/app/code/Magento/Customer/view/frontend/templates/widget/dob.phtml @@ -4,6 +4,7 @@ * See COPYING.txt for license details. */ +/** @var \Magento\Framework\View\Helper\SecureHtmlRenderer $secureRenderer */ /** @var \Magento\Customer\Block\Widget\Dob $block */ /* @@ -23,14 +24,17 @@ NOTE: Regarding styles - if we leave it this way, we'll move it to boxes.css. Al automatically using block input parameters. */ +$translatedCalendarConfigJson = $block->getTranslatedCalendarConfigJson(); $fieldCssClass = 'field date field-' . $block->getHtmlId(); $fieldCssClass .= $block->isRequired() ? ' required' : ''; ?> <div class="<?= $block->escapeHtmlAttr($fieldCssClass) ?>"> - <label class="label" for="<?= $block->escapeHtmlAttr($block->getHtmlId()) ?>"><span><?= $block->escapeHtml($block->getStoreLabel('dob')) ?></span></label> + <label class="label" for="<?= $block->escapeHtmlAttr($block->getHtmlId()) ?>"> + <span><?= $block->escapeHtml($block->getStoreLabel('dob')) ?></span> + </label> <div class="control customer-dob"> <?= $block->getFieldHtml() ?> - <?php if ($_message = $block->getAdditionalDescription()) : ?> + <?php if ($_message = $block->getAdditionalDescription()): ?> <div class="note"><?= $block->escapeHtml($_message) ?></div> <?php endif; ?> </div> @@ -42,4 +46,22 @@ $fieldCssClass .= $block->isRequired() ? ' required' : ''; "Magento_Customer/js/validation": {} } } - </script> +</script> + +<?php $scriptString = <<<code + +require([ + 'jquery', + 'jquery-ui-modules/datepicker' +], function($){ + +//<![CDATA[ + $.extend(true, $, { + calendarConfig: {$translatedCalendarConfigJson} + }); +//]]> + +}); +code; +?> +<?= /* @noEscape */ $secureRenderer->renderTag('script', [], $scriptString, false) ?> diff --git a/app/code/Magento/Customer/view/frontend/web/js/block-submit-on-send.js b/app/code/Magento/Customer/view/frontend/web/js/block-submit-on-send.js index b941ec7a254d8..75f4ee6097685 100644 --- a/app/code/Magento/Customer/view/frontend/web/js/block-submit-on-send.js +++ b/app/code/Magento/Customer/view/frontend/web/js/block-submit-on-send.js @@ -14,9 +14,15 @@ define([ dataForm.submit(function () { $(this).find(':submit').attr('disabled', 'disabled'); + + if (this.isValid === false) { + $(this).find(':submit').prop('disabled', false); + } + this.isValid = true; }); dataForm.bind('invalid-form.validate', function () { $(this).find(':submit').prop('disabled', false); + this.isValid = false; }); }; }); 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 5321dfecba182..2d7e26ecef768 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 @@ -38,9 +38,9 @@ define([ if (new Date($.localStorage.get('mage-cache-timeout')) < new Date()) { storage.removeAll(); - date = new Date(Date.now() + parseInt(invalidateOptions.cookieLifeTime, 10) * 1000); - $.localStorage.set('mage-cache-timeout', date); } + date = new Date(Date.now() + parseInt(invalidateOptions.cookieLifeTime, 10) * 1000); + $.localStorage.set('mage-cache-timeout', date); }; /** @@ -78,7 +78,7 @@ define([ var parameters; sectionNames = sectionConfig.filterClientSideSections(sectionNames); - parameters = _.isArray(sectionNames) ? { + parameters = _.isArray(sectionNames) && sectionNames.indexOf('*') < 0 ? { sections: sectionNames.join(',') } : []; parameters['force_new_section_timestamp'] = forceNewSectionTimestamp; diff --git a/app/code/Magento/CustomerGraphQl/Model/Resolver/ResetPassword.php b/app/code/Magento/CustomerGraphQl/Model/Resolver/ResetPassword.php index fa2ae669cc89d..a098325c820d6 100644 --- a/app/code/Magento/CustomerGraphQl/Model/Resolver/ResetPassword.php +++ b/app/code/Magento/CustomerGraphQl/Model/Resolver/ResetPassword.php @@ -118,7 +118,7 @@ public function resolve( $args['newPassword'] ); } catch (LocalizedException $e) { - throw new GraphQlInputException(__('Cannot set the customer\'s password'), $e); + throw new GraphQlInputException(__($e->getMessage()), $e); } } } diff --git a/app/code/Magento/CustomerImportExport/Model/Import/Address.php b/app/code/Magento/CustomerImportExport/Model/Import/Address.php index f15f920fe95f4..0c3be73ec5047 100644 --- a/app/code/Magento/CustomerImportExport/Model/Import/Address.php +++ b/app/code/Magento/CustomerImportExport/Model/Import/Address.php @@ -525,12 +525,12 @@ public function validateData() protected function _importData() { //Preparing data for mass validation/import. - $rows = [[]]; + $rows = []; while ($bunch = $this->_dataSourceModel->getNextBunch()) { $rows[] = $bunch; } - $this->prepareCustomerData(array_merge(...$rows)); + $this->prepareCustomerData(array_merge([], ...$rows)); unset($bunch, $rows); $this->_dataSourceModel->getIterator()->rewind(); diff --git a/app/code/Magento/CustomerImportExport/Model/Import/Customer.php b/app/code/Magento/CustomerImportExport/Model/Import/Customer.php index 5ebf242bd6ac4..2a02205bdc7e5 100644 --- a/app/code/Magento/CustomerImportExport/Model/Import/Customer.php +++ b/app/code/Magento/CustomerImportExport/Model/Import/Customer.php @@ -514,8 +514,8 @@ protected function _importData() { while ($bunch = $this->_dataSourceModel->getNextBunch()) { $this->prepareCustomerData($bunch); - $entitiesToCreate = [[]]; - $entitiesToUpdate = [[]]; + $entitiesToCreate = []; + $entitiesToUpdate = []; $entitiesToDelete = []; $attributesToSave = []; @@ -549,8 +549,8 @@ protected function _importData() } } - $entitiesToCreate = array_merge(...$entitiesToCreate); - $entitiesToUpdate = array_merge(...$entitiesToUpdate); + $entitiesToCreate = array_merge([], ...$entitiesToCreate); + $entitiesToUpdate = array_merge([], ...$entitiesToUpdate); $this->updateItemsCounterStats($entitiesToCreate, $entitiesToUpdate, $entitiesToDelete); /** diff --git a/app/code/Magento/Deploy/Console/Command/App/ConfigImportCommand.php b/app/code/Magento/Deploy/Console/Command/App/ConfigImportCommand.php index 8a75ad0def222..eb87a9c12125b 100644 --- a/app/code/Magento/Deploy/Console/Command/App/ConfigImportCommand.php +++ b/app/code/Magento/Deploy/Console/Command/App/ConfigImportCommand.php @@ -3,14 +3,21 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + namespace Magento\Deploy\Console\Command\App; +use Magento\Config\Console\Command\EmulatedAdminhtmlAreaProcessor; +use Magento\Deploy\Console\Command\App\ConfigImport\Processor; +use Magento\Framework\App\Area; +use Magento\Framework\App\AreaList; +use Magento\Framework\App\DeploymentConfig; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\Console\Cli; +use Magento\Framework\Exception\FileSystemException; use Magento\Framework\Exception\RuntimeException; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Magento\Framework\Console\Cli; -use Magento\Deploy\Console\Command\App\ConfigImport\Processor; /** * Runs the process of importing configuration data from shared source to appropriate application sources @@ -21,9 +28,6 @@ */ class ConfigImportCommand extends Command { - /** - * Command name. - */ const COMMAND_NAME = 'app:config:import'; /** @@ -33,12 +37,40 @@ class ConfigImportCommand extends Command */ private $processor; + /** + * @var EmulatedAdminhtmlAreaProcessor + */ + private $adminhtmlAreaProcessor; + + /** + * @var DeploymentConfig + */ + private $deploymentConfig; + + /** + * @var AreaList + */ + private $areaList; + /** * @param Processor $processor the configuration importer + * @param DeploymentConfig|null $deploymentConfig + * @param EmulatedAdminhtmlAreaProcessor|null $adminhtmlAreaProcessor + * @param AreaList|null $areaList */ - public function __construct(Processor $processor) - { + public function __construct( + Processor $processor, + DeploymentConfig $deploymentConfig = null, + EmulatedAdminhtmlAreaProcessor $adminhtmlAreaProcessor = null, + AreaList $areaList = null + ) { $this->processor = $processor; + $this->deploymentConfig = $deploymentConfig + ?? ObjectManager::getInstance()->get(DeploymentConfig::class); + $this->adminhtmlAreaProcessor = $adminhtmlAreaProcessor + ?? ObjectManager::getInstance()->get(EmulatedAdminhtmlAreaProcessor::class); + $this->areaList = $areaList + ?? ObjectManager::getInstance()->get(AreaList::class); parent::__construct(); } @@ -55,12 +87,26 @@ protected function configure() } /** - * Imports data from deployment configuration files to the DB. {@inheritdoc} + * Imports data from deployment configuration files to the DB. + * {@inheritdoc} + * + * @param InputInterface $input + * @param OutputInterface $output + * @return int + * @throws \Exception */ protected function execute(InputInterface $input, OutputInterface $output) { try { - $this->processor->execute($input, $output); + if ($this->canEmulateAdminhtmlArea()) { + // Emulate adminhtml area in order to execute all needed plugins declared only for this area + // For instance URL rewrite generation during creating store view + $this->adminhtmlAreaProcessor->process(function () use ($input, $output) { + $this->processor->execute($input, $output); + }); + } else { + $this->processor->execute($input, $output); + } } catch (RuntimeException $e) { $output->writeln('<error>' . $e->getMessage() . '</error>'); @@ -69,4 +115,19 @@ protected function execute(InputInterface $input, OutputInterface $output) return Cli::RETURN_SUCCESS; } + + /** + * Detects if we can emulate adminhtml area + * + * This area could be not available for instance during setup:install + * + * @return bool + * @throws RuntimeException + * @throws FileSystemException + */ + private function canEmulateAdminhtmlArea(): bool + { + return $this->deploymentConfig->isAvailable() + && in_array(Area::AREA_ADMINHTML, $this->areaList->getCodes()); + } } diff --git a/app/code/Magento/Deploy/Package/Package.php b/app/code/Magento/Deploy/Package/Package.php index 2a83d0d4c56ec..5780b46365680 100644 --- a/app/code/Magento/Deploy/Package/Package.php +++ b/app/code/Magento/Deploy/Package/Package.php @@ -443,11 +443,11 @@ public function getResultMap() */ public function getParentMap() { - $map = [[]]; + $map = []; foreach ($this->getParentPackages() as $parentPackage) { $map[] = $parentPackage->getMap(); } - return array_merge(...$map); + return array_merge([], ...$map); } /** @@ -458,7 +458,7 @@ public function getParentMap() */ public function getParentFiles($type = null) { - $files = [[]]; + $files = []; foreach ($this->getParentPackages() as $parentPackage) { if ($type === null) { $files[] = $parentPackage->getFiles(); @@ -466,7 +466,7 @@ public function getParentFiles($type = null) $files[] = $parentPackage->getFilesByType($type); } } - return array_merge(...$files); + return array_merge([], ...$files); } /** @@ -535,7 +535,7 @@ private function collectParentPaths( $area, $theme, $locale, - array & $result = [], + array &$result = [], ThemeInterface $themeModel = null ) { if (($package->getArea() != $area) || ($package->getTheme() != $theme) || ($package->getLocale() != $locale)) { diff --git a/app/code/Magento/Deploy/Package/Processor/PreProcessor/Css.php b/app/code/Magento/Deploy/Package/Processor/PreProcessor/Css.php index 42775a2e2f6bf..152c95f86552c 100644 --- a/app/code/Magento/Deploy/Package/Processor/PreProcessor/Css.php +++ b/app/code/Magento/Deploy/Package/Processor/PreProcessor/Css.php @@ -10,12 +10,12 @@ use Magento\Deploy\Package\Package; use Magento\Deploy\Package\PackageFile; use Magento\Deploy\Package\Processor\ProcessorInterface; +use Magento\Framework\App\Filesystem\DirectoryList; use Magento\Framework\Css\PreProcessor\Instruction\Import; use Magento\Framework\Filesystem; use Magento\Framework\Filesystem\Directory\ReadInterface; -use Magento\Framework\App\Filesystem\DirectoryList; -use Magento\Framework\View\Url\CssResolver; use Magento\Framework\View\Asset\Minification; +use Magento\Framework\View\Url\CssResolver; /** * Pre-processor for speeding up deployment of CSS files @@ -137,7 +137,7 @@ private function buildMap($packagePath, $filePath, $fullPath) $content = $this->staticDir->readFile($this->minification->addMinifiedSign($fullPath)); - $callback = function ($matchContent) use ($packagePath, $filePath, & $imports) { + $callback = function ($matchContent) use ($packagePath, $filePath, &$imports) { $importRelPath = $this->normalize(pathinfo($filePath, PATHINFO_DIRNAME) . '/' . $matchContent['path']); $imports[$importRelPath] = $this->normalize( $packagePath . '/' . pathinfo($filePath, PATHINFO_DIRNAME) . '/' . $matchContent['path'] @@ -175,15 +175,17 @@ private function buildMap($packagePath, $filePath, $fullPath) * * @param string $fileName * @return array - * phpcs:disable Magento2.Performance.ForeachArrayMerge */ - private function collectFileMap($fileName) + private function collectFileMap(string $fileName): array { - $result = isset($this->map[$fileName]) ? $this->map[$fileName] : []; - foreach ($result as $path) { - $result = array_merge($result, $this->collectFileMap($path)); + $valueFromMap = $this->map[$fileName] ?? []; + $result = [$valueFromMap]; + + foreach ($valueFromMap as $path) { + $result[] = $this->collectFileMap($path); } - return array_unique($result); + + return array_unique(array_merge([], ...$result)); } /** diff --git a/app/code/Magento/Deploy/Test/Unit/Console/Command/App/ConfigImportCommandTest.php b/app/code/Magento/Deploy/Test/Unit/Console/Command/App/ConfigImportCommandTest.php index da790a19f480a..32bdd63ef4638 100644 --- a/app/code/Magento/Deploy/Test/Unit/Console/Command/App/ConfigImportCommandTest.php +++ b/app/code/Magento/Deploy/Test/Unit/Console/Command/App/ConfigImportCommandTest.php @@ -7,8 +7,11 @@ namespace Magento\Deploy\Test\Unit\Console\Command\App; +use Magento\Config\Console\Command\EmulatedAdminhtmlAreaProcessor; use Magento\Deploy\Console\Command\App\ConfigImport\Processor; use Magento\Deploy\Console\Command\App\ConfigImportCommand; +use Magento\Framework\App\AreaList; +use Magento\Framework\App\DeploymentConfig; use Magento\Framework\Console\Cli; use Magento\Framework\Exception\RuntimeException; use PHPUnit\Framework\MockObject\MockObject; @@ -27,16 +30,37 @@ class ConfigImportCommandTest extends TestCase */ private $commandTester; + /** + * @var DeploymentConfig|MockObject + */ + private $deploymentConfigMock; + + /** + * @var EmulatedAdminhtmlAreaProcessor|MockObject + */ + private $adminhtmlAreaProcessorMock; + + /** + * @var AreaList|MockObject + */ + private $areaListMock; + /** * @return void */ protected function setUp(): void { - $this->processorMock = $this->getMockBuilder(Processor::class) - ->disableOriginalConstructor() - ->getMock(); + $this->processorMock = $this->createMock(Processor::class); + $this->deploymentConfigMock = $this->createMock(DeploymentConfig::class); + $this->adminhtmlAreaProcessorMock = $this->createMock(EmulatedAdminhtmlAreaProcessor::class); + $this->areaListMock = $this->createMock(AreaList::class); - $configImportCommand = new ConfigImportCommand($this->processorMock); + $configImportCommand = new ConfigImportCommand( + $this->processorMock, + $this->deploymentConfigMock, + $this->adminhtmlAreaProcessorMock, + $this->areaListMock + ); $this->commandTester = new CommandTester($configImportCommand); } @@ -46,6 +70,13 @@ protected function setUp(): void */ public function testExecute() { + $this->deploymentConfigMock->expects($this->once())->method('isAvailable')->willReturn(true); + $this->adminhtmlAreaProcessorMock->expects($this->once()) + ->method('process')->willReturnCallback(function (callable $callback, array $params = []) { + return $callback(...$params); + }); + $this->areaListMock->expects($this->once())->method('getCodes')->willReturn(['adminhtml']); + $this->processorMock->expects($this->once()) ->method('execute'); @@ -57,6 +88,13 @@ public function testExecute() */ public function testExecuteWithException() { + $this->deploymentConfigMock->expects($this->once())->method('isAvailable')->willReturn(true); + $this->adminhtmlAreaProcessorMock->expects($this->once()) + ->method('process')->willReturnCallback(function (callable $callback, array $params = []) { + return $callback(...$params); + }); + $this->areaListMock->expects($this->once())->method('getCodes')->willReturn(['adminhtml']); + $this->processorMock->expects($this->once()) ->method('execute') ->willThrowException(new RuntimeException(__('Some error'))); @@ -64,4 +102,34 @@ public function testExecuteWithException() $this->assertSame(Cli::RETURN_FAILURE, $this->commandTester->execute([])); $this->assertStringContainsString('Some error', $this->commandTester->getDisplay()); } + + /** + * @return void + */ + public function testExecuteWithDeploymentConfigNotAvailable() + { + $this->deploymentConfigMock->expects($this->once())->method('isAvailable')->willReturn(false); + $this->adminhtmlAreaProcessorMock->expects($this->never())->method('process'); + $this->areaListMock->expects($this->never())->method('getCodes'); + + $this->processorMock->expects($this->once()) + ->method('execute'); + + $this->assertSame(Cli::RETURN_SUCCESS, $this->commandTester->execute([])); + } + + /** + * @return void + */ + public function testExecuteWithMissingAdminhtmlLocale() + { + $this->deploymentConfigMock->expects($this->once())->method('isAvailable')->willReturn(true); + $this->adminhtmlAreaProcessorMock->expects($this->never())->method('process'); + $this->areaListMock->expects($this->once())->method('getCodes')->willReturn([]); + + $this->processorMock->expects($this->once()) + ->method('execute'); + + $this->assertSame(Cli::RETURN_SUCCESS, $this->commandTester->execute([])); + } } diff --git a/app/code/Magento/Deploy/etc/di.xml b/app/code/Magento/Deploy/etc/di.xml index 0c32baebf12df..d40ed3144e7e6 100644 --- a/app/code/Magento/Deploy/etc/di.xml +++ b/app/code/Magento/Deploy/etc/di.xml @@ -35,6 +35,12 @@ </argument> </arguments> </type> + <type name="Magento\Deploy\Console\Command\App\ConfigImportCommand"> + <arguments> + <argument name="adminhtmlAreaProcessor" xsi:type="object">Magento\Config\Console\Command\EmulatedAdminhtmlAreaProcessor\Proxy</argument> + <argument name="areaList" xsi:type="object">Magento\Framework\App\AreaList\Proxy</argument> + </arguments> + </type> <type name="Magento\Deploy\Model\Filesystem"> <arguments> <argument name="shell" xsi:type="object">Magento\Framework\App\Shell</argument> diff --git a/app/code/Magento/Developer/Console/Command/XmlCatalogGenerateCommand.php b/app/code/Magento/Developer/Console/Command/XmlCatalogGenerateCommand.php index 9e473ccaa2d92..8bd827958df15 100644 --- a/app/code/Magento/Developer/Console/Command/XmlCatalogGenerateCommand.php +++ b/app/code/Magento/Developer/Console/Command/XmlCatalogGenerateCommand.php @@ -116,7 +116,7 @@ private function getUrnDictionary(OutputInterface $output) $files = $this->filesUtility->getXmlCatalogFiles('*.xml'); $files = array_merge($files, $this->filesUtility->getXmlCatalogFiles('*.xsd')); - $urns = [[]]; + $urns = []; foreach ($files as $file) { // phpcs:ignore Magento2.Functions.DiscouragedFunction $fileDir = dirname($file[0]); @@ -130,7 +130,7 @@ private function getUrnDictionary(OutputInterface $output) $urns[] = $matches[1]; } } - $urns = array_unique(array_merge(...$urns)); + $urns = array_unique(array_merge([], ...$urns)); $paths = []; foreach ($urns as $urn) { try { diff --git a/app/code/Magento/Dhl/Model/Carrier.php b/app/code/Magento/Dhl/Model/Carrier.php index 204094571ba3b..c5eb27b21e58b 100644 --- a/app/code/Magento/Dhl/Model/Carrier.php +++ b/app/code/Magento/Dhl/Model/Carrier.php @@ -826,10 +826,9 @@ protected function _getAllItems() $fullItems[] = array_fill(0, $qty, $this->_getWeight($itemWeight)); } } - if ($fullItems) { - $fullItems = array_merge(...$fullItems); - sort($fullItems); - } + + $fullItems = array_merge([], ...$fullItems); + sort($fullItems); return $fullItems; } diff --git a/app/code/Magento/Directory/Model/AllowedCountries.php b/app/code/Magento/Directory/Model/AllowedCountries.php index 2ceeb70ba5b01..69326439edc03 100644 --- a/app/code/Magento/Directory/Model/AllowedCountries.php +++ b/app/code/Magento/Directory/Model/AllowedCountries.php @@ -62,11 +62,11 @@ public function getAllowedCountries( switch ($scope) { case ScopeInterface::SCOPE_WEBSITES: case ScopeInterface::SCOPE_STORES: - $allowedCountries = [[]]; + $allowedCountries = []; foreach ($scopeCode as $singleFilter) { $allowedCountries[] = $this->getCountriesFromConfig($this->getSingleScope($scope), $singleFilter); } - $allowedCountries = array_merge(...$allowedCountries); + $allowedCountries = array_merge([], ...$allowedCountries); break; default: $allowedCountries = $this->getCountriesFromConfig($scope, $scopeCode); diff --git a/app/code/Magento/Directory/Model/CurrencyConfig.php b/app/code/Magento/Directory/Model/CurrencyConfig.php index f7230df6e86ea..b574170ac5d3c 100644 --- a/app/code/Magento/Directory/Model/CurrencyConfig.php +++ b/app/code/Magento/Directory/Model/CurrencyConfig.php @@ -73,7 +73,7 @@ public function getConfigCurrencies(string $path) */ private function getConfigForAllStores(string $path) { - $storesResult = [[]]; + $storesResult = []; foreach ($this->storeManager->getStores() as $store) { $storesResult[] = explode( ',', @@ -81,7 +81,7 @@ private function getConfigForAllStores(string $path) ); } - return array_merge(...$storesResult); + return array_merge([], ...$storesResult); } /** diff --git a/app/code/Magento/Directory/Setup/Patch/Data/AddDataForUruguay.php b/app/code/Magento/Directory/Setup/Patch/Data/AddDataForUruguay.php new file mode 100644 index 0000000000000..d9aa041c1f7d1 --- /dev/null +++ b/app/code/Magento/Directory/Setup/Patch/Data/AddDataForUruguay.php @@ -0,0 +1,104 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Directory\Setup\Patch\Data; + +use Magento\Directory\Setup\DataInstaller; +use Magento\Directory\Setup\DataInstallerFactory; +use Magento\Framework\Setup\ModuleDataSetupInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; + +/** + * Add Uruguay States/Regions + */ +class AddDataForUruguay implements DataPatchInterface +{ + /** + * @var ModuleDataSetupInterface + */ + private $moduleDataSetup; + + /** + * @var DataInstallerFactory + */ + private $dataInstallerFactory; + + /** + * @param ModuleDataSetupInterface $moduleDataSetup + * @param DataInstallerFactory $dataInstallerFactory + */ + public function __construct( + ModuleDataSetupInterface $moduleDataSetup, + DataInstallerFactory $dataInstallerFactory + ) { + $this->moduleDataSetup = $moduleDataSetup; + $this->dataInstallerFactory = $dataInstallerFactory; + } + + /** + * @inheritdoc + */ + public function apply() + { + /** @var DataInstaller $dataInstaller */ + $dataInstaller = $this->dataInstallerFactory->create(); + $dataInstaller->addCountryRegions( + $this->moduleDataSetup->getConnection(), + $this->getDataForUruguay() + ); + + return $this; + } + + /** + * Uruguay states data. + * + * @return array + */ + private function getDataForUruguay(): array + { + return [ + ['UY', 'UY-AR', 'Artigas'], + ['UY', 'UY-CA', 'Canelones'], + ['UY', 'UY-CL', 'Cerro Largo'], + ['UY', 'UY-CO', 'Colonia'], + ['UY', 'UY-DU', 'Durazno'], + ['UY', 'UY-FS', 'Flores'], + ['UY', 'UY-FD', 'Florida'], + ['UY', 'UY-LA', 'Lavalleja'], + ['UY', 'UY-MA', 'Maldonado'], + ['UY', 'UY-MO', 'Montevideo'], + ['UY', 'UY-PA', 'Paysandu'], + ['UY', 'UY-RN', 'Río Negro'], + ['UY', 'UY-RV', 'Rivera'], + ['UY', 'UY-RO', 'Rocha'], + ['UY', 'UY-SA', 'Salto'], + ['UY', 'UY-SJ', 'San José'], + ['UY', 'UY-SO', 'Soriano'], + ['UY', 'UY-TA', 'Tacuarembó'], + ['UY', 'UY-TT', 'Treinta y Tres'] + ]; + } + + /** + * @inheritdoc + */ + public static function getDependencies() + { + return [ + InitializeDirectoryData::class, + ]; + } + + /** + * @inheritdoc + */ + public function getAliases() + { + return []; + } +} diff --git a/app/code/Magento/Directory/etc/zip_codes.xml b/app/code/Magento/Directory/etc/zip_codes.xml index 3c540f7ce0ffd..634d4abe06763 100644 --- a/app/code/Magento/Directory/etc/zip_codes.xml +++ b/app/code/Magento/Directory/etc/zip_codes.xml @@ -19,6 +19,7 @@ <zip countryCode="AR"> <codes> <code id="pattern_1" active="true" example="1234">^[0-9]{4}$</code> + <code id="pattern_2" active="true" example="A1234BCD">^[a-zA-z]{1}[0-9]{4}[a-zA-z]{3}$</code> </codes> </zip> <zip countryCode="AM"> @@ -64,7 +65,7 @@ </zip> <zip countryCode="BR"> <codes> - <code id="pattern_1" active="true" example="12345">^[0-9]{5}$</code> + <code id="pattern_1" active="true" example="12345678">^[0-9]{8}$</code> <code id="pattern_2" active="true" example="12345-678">^[0-9]{5}\-[0-9]{3}$</code> </codes> </zip> @@ -228,6 +229,7 @@ <zip countryCode="KR"> <codes> <code id="pattern_1" active="true" example="123-456">^[0-9]{3}-[0-9]{3}$</code> + <code id="pattern_2" active="true" example="12345">^[0-9]{5}$</code> </codes> </zip> <zip countryCode="KG"> diff --git a/app/code/Magento/Downloadable/Helper/Download.php b/app/code/Magento/Downloadable/Helper/Download.php index 6b7db3af51195..1425f71f2fd8a 100644 --- a/app/code/Magento/Downloadable/Helper/Download.php +++ b/app/code/Magento/Downloadable/Helper/Download.php @@ -7,6 +7,8 @@ namespace Magento\Downloadable\Helper; use Magento\Framework\App\Filesystem\DirectoryList; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\File\Mime; use Magento\Framework\Filesystem; use Magento\Framework\Exception\LocalizedException as CoreException; @@ -18,12 +20,12 @@ class Download extends \Magento\Framework\App\Helper\AbstractHelper { /** - * Link type url + * Link type for url */ const LINK_TYPE_URL = 'url'; /** - * Link type file + * Link type for file */ const LINK_TYPE_FILE = 'file'; @@ -109,6 +111,11 @@ class Download extends \Magento\Framework\App\Helper\AbstractHelper */ protected $_session; + /** + * @var Mime + */ + private $mime; + /** * @param \Magento\Framework\App\Helper\Context $context * @param File $downloadableFile @@ -116,6 +123,7 @@ class Download extends \Magento\Framework\App\Helper\AbstractHelper * @param Filesystem $filesystem * @param \Magento\Framework\Session\SessionManagerInterface $session * @param Filesystem\File\ReadFactory $fileReadFactory + * @param Mime|null $mime */ public function __construct( \Magento\Framework\App\Helper\Context $context, @@ -123,7 +131,8 @@ public function __construct( \Magento\MediaStorage\Helper\File\Storage\Database $coreFileStorageDb, \Magento\Framework\Filesystem $filesystem, \Magento\Framework\Session\SessionManagerInterface $session, - \Magento\Framework\Filesystem\File\ReadFactory $fileReadFactory + \Magento\Framework\Filesystem\File\ReadFactory $fileReadFactory, + Mime $mime = null ) { parent::__construct($context); $this->_downloadableFile = $downloadableFile; @@ -131,6 +140,7 @@ public function __construct( $this->_filesystem = $filesystem; $this->_session = $session; $this->fileReadFactory = $fileReadFactory; + $this->mime = $mime ?? ObjectManager::getInstance()->get(Mime::class); } /** @@ -148,6 +158,7 @@ protected function _getHandle() if ($this->_handle === null) { if ($this->_linkType == self::LINK_TYPE_URL) { $path = $this->_resourceFile; + // phpcs:ignore Magento2.Functions.DiscouragedFunction $protocol = strtolower(parse_url($path, PHP_URL_SCHEME)); if ($protocol) { // Strip down protocol from path @@ -188,14 +199,8 @@ public function getContentType() { $this->_getHandle(); if ($this->_linkType === self::LINK_TYPE_FILE) { - if (function_exists('mime_content_type') - && ($contentType = mime_content_type( - $this->_workingDirectory->getAbsolutePath($this->_resourceFile) - )) - ) { - return $contentType; - } - return $this->_downloadableFile->getFileType($this->_resourceFile); + $absolutePath = $this->_workingDirectory->getAbsolutePath($this->_resourceFile); + return $this->mime->getMimeType($absolutePath); } if ($this->_linkType === self::LINK_TYPE_URL) { return (is_array($this->_handle->stat($this->_resourceFile)['type']) @@ -209,6 +214,8 @@ public function getContentType() * Return name of the file * * @return string + * phpcs:disable Magento2.Functions.DiscouragedFunction + * phpcs:disable Generic.PHP.NoSilencedErrors */ public function getFilename() { @@ -254,20 +261,21 @@ public function setResource($resourceFile, $linkType = self::LINK_TYPE_FILE) ); } } - + $this->_resourceFile = $resourceFile; - + /** * check header for urls */ if ($linkType === self::LINK_TYPE_URL) { + // phpcs:ignore Magento2.Functions.DiscouragedFunction $headers = array_change_key_case(get_headers($this->_resourceFile, 1), CASE_LOWER); if (isset($headers['location'])) { $this->_resourceFile = is_array($headers['location']) ? current($headers['location']) : $headers['location']; } } - + $this->_linkType = $linkType; return $this; } @@ -282,6 +290,7 @@ public function output() $handle = $this->_getHandle(); $this->_session->writeClose(); while (true == ($buffer = $handle->read(1024))) { + // phpcs:ignore Magento2.Security.LanguageConstruct echo $buffer; //@codingStandardsIgnoreLine } } diff --git a/app/code/Magento/Downloadable/Model/Product/Type.php b/app/code/Magento/Downloadable/Model/Product/Type.php index cb79dda3baccb..45a03b50d78b8 100644 --- a/app/code/Magento/Downloadable/Model/Product/Type.php +++ b/app/code/Magento/Downloadable/Model/Product/Type.php @@ -7,6 +7,7 @@ use Magento\Catalog\Api\ProductRepositoryInterface; use Magento\Framework\Api\ExtensionAttribute\JoinProcessorInterface; +use Magento\Framework\File\UploaderFactory; /** * Downloadable product type model @@ -67,8 +68,6 @@ class Type extends \Magento\Catalog\Model\Product\Type\Virtual private $extensionAttributesJoinProcessor; /** - * Construct - * * @param \Magento\Catalog\Model\Product\Option $catalogProductOption * @param \Magento\Eav\Model\Config $eavConfig * @param \Magento\Catalog\Model\Product\Type $catalogProductType @@ -87,6 +86,7 @@ class Type extends \Magento\Catalog\Model\Product\Type\Virtual * @param TypeHandler\TypeHandlerInterface $typeHandler * @param JoinProcessorInterface $extensionAttributesJoinProcessor * @param \Magento\Framework\Serialize\Serializer\Json|null $serializer + * @param UploaderFactory|null $uploaderFactory * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -107,7 +107,8 @@ public function __construct( \Magento\Downloadable\Model\LinkFactory $linkFactory, \Magento\Downloadable\Model\Product\TypeHandler\TypeHandlerInterface $typeHandler, JoinProcessorInterface $extensionAttributesJoinProcessor, - \Magento\Framework\Serialize\Serializer\Json $serializer = null + \Magento\Framework\Serialize\Serializer\Json $serializer = null, + UploaderFactory $uploaderFactory = null ) { $this->_sampleResFactory = $sampleResFactory; $this->_linkResource = $linkResource; @@ -127,7 +128,8 @@ public function __construct( $coreRegistry, $logger, $productRepository, - $serializer + $serializer, + $uploaderFactory ); } diff --git a/app/code/Magento/Customer/Test/Mftf/Section/AdminCustomerAddressGridMainActionsSection.xml b/app/code/Magento/Downloadable/Test/Mftf/Section/StorefrontDownloadableLinkSection.xml similarity index 55% rename from app/code/Magento/Customer/Test/Mftf/Section/AdminCustomerAddressGridMainActionsSection.xml rename to app/code/Magento/Downloadable/Test/Mftf/Section/StorefrontDownloadableLinkSection.xml index f226d49e3bf54..6364600faee30 100644 --- a/app/code/Magento/Customer/Test/Mftf/Section/AdminCustomerAddressGridMainActionsSection.xml +++ b/app/code/Magento/Downloadable/Test/Mftf/Section/StorefrontDownloadableLinkSection.xml @@ -8,8 +8,8 @@ <sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> - <section name="AdminCustomerGridMainActionsSection"> - <element name="addNewAddress" type="button" selector=".add-new-address-button" timeout="30"/> - <element name="actions" type="text" selector=".admin__data-grid-header-row .action-select"/> + <section name="StorefrontDownloadableLinkSection"> + <element name="downloadedImage" type="text" selector="//img[contains(@style, '-webkit-user-select')]"/> + <element name="downloadedSvg" type="text" selector="//*[@id='{{id}}']" parameterized="true"/> </section> </sections> diff --git a/app/code/Magento/Downloadable/Test/Mftf/Test/AdminProductTypeSwitchingOnEditingTest/AdminSimpleProductTypeSwitchingToDownloadableProductTest.xml b/app/code/Magento/Downloadable/Test/Mftf/Test/AdminProductTypeSwitchingOnEditingTest/AdminSimpleProductTypeSwitchingToDownloadableProductTest.xml index 0f03a6a47c795..0237eca61b784 100644 --- a/app/code/Magento/Downloadable/Test/Mftf/Test/AdminProductTypeSwitchingOnEditingTest/AdminSimpleProductTypeSwitchingToDownloadableProductTest.xml +++ b/app/code/Magento/Downloadable/Test/Mftf/Test/AdminProductTypeSwitchingOnEditingTest/AdminSimpleProductTypeSwitchingToDownloadableProductTest.xml @@ -49,7 +49,7 @@ <actionGroup ref="SaveProductFormActionGroup" stepKey="saveDownloadableProductForm"/> <!--Assert downloadable product on Admin product page grid--> <comment userInput="Assert configurable product in Admin product page grid" stepKey="commentAssertDownloadableProductOnAdmin"/> - <amOnPage url="{{AdminCatalogProductPage.url}}" stepKey="goToCatalogProductPage"/> + <actionGroup ref="AdminOpenCatalogProductPageActionGroup" stepKey="goToCatalogProductPage"/> <actionGroup ref="FilterProductGridBySku2ActionGroup" stepKey="filterProductGridBySku"> <argument name="sku" value="$$createProduct.sku$$"/> </actionGroup> diff --git a/app/code/Magento/Downloadable/Test/Unit/Helper/DownloadTest.php b/app/code/Magento/Downloadable/Test/Unit/Helper/DownloadTest.php index 59de5b0139ff6..da89efac59fa8 100644 --- a/app/code/Magento/Downloadable/Test/Unit/Helper/DownloadTest.php +++ b/app/code/Magento/Downloadable/Test/Unit/Helper/DownloadTest.php @@ -10,6 +10,7 @@ use Magento\Downloadable\Helper\Download as DownloadHelper; use Magento\Downloadable\Helper\File as DownloadableFile; use Magento\Framework\App\Filesystem\DirectoryList; +use Magento\Framework\File\Mime; use Magento\Framework\Filesystem; use Magento\Framework\Filesystem\Directory\ReadInterface as DirReadInterface; use Magento\Framework\Filesystem\File\ReadFactory; @@ -62,6 +63,11 @@ class DownloadTest extends TestCase const URL = 'http://example.com'; + /** + * @var Mime|MockObject + */ + private $mime; + protected function setUp(): void { require_once __DIR__ . '/../_files/download_mock.php'; @@ -77,6 +83,7 @@ protected function setUp(): void SessionManagerInterface::class ); $this->fileReadFactory = $this->createMock(ReadFactory::class); + $this->mime = $this->createMock(Mime::class); $this->_helper = (new ObjectManager($this))->getObject( \Magento\Downloadable\Helper\Download::class, @@ -85,6 +92,7 @@ protected function setUp(): void 'filesystem' => $this->_filesystemMock, 'session' => $this->sessionManager, 'fileReadFactory' => $this->fileReadFactory, + 'mime' => $this->mime ] ); } @@ -132,8 +140,17 @@ public function testGetFileSizeNoFile() public function testGetContentType() { + $this->mime->expects( + self::once() + )->method( + 'getMimeType' + )->willReturn( + self::MIME_TYPE + ); $this->_setupFileMocks(); $this->_downloadableFileMock->expects($this->never())->method('getFileType'); + $this->_workingDirectoryMock->expects($this->once())->method('getAbsolutePath') + ->willReturn('/path/to/file.txt'); $this->assertEquals(self::MIME_TYPE, $this->_helper->getContentType()); } @@ -146,10 +163,10 @@ public function testGetContentTypeThroughHelper($functionExistsResult, $mimeCont self::$functionExists = $functionExistsResult; self::$mimeContentType = $mimeContentTypeResult; - $this->_downloadableFileMock->expects( - $this->once() + $this->mime->expects( + self::once() )->method( - 'getFileType' + 'getMimeType' )->willReturn( self::MIME_TYPE ); diff --git a/app/code/Magento/Downloadable/Test/Unit/Model/Product/TypeHandler/SampleTest.php b/app/code/Magento/Downloadable/Test/Unit/Model/Product/TypeHandler/SampleTest.php index a77ebf9ba7edb..675f900a8b9c1 100644 --- a/app/code/Magento/Downloadable/Test/Unit/Model/Product/TypeHandler/SampleTest.php +++ b/app/code/Magento/Downloadable/Test/Unit/Model/Product/TypeHandler/SampleTest.php @@ -96,7 +96,7 @@ protected function setUp(): void */ public function testSave($product, array $data, array $modelData) { - $link = $this->createSampleModel($product, $modelData, true); + $link = $this->createSampleModel($product, $modelData); $this->metadataMock->expects($this->once())->method('getLinkField')->willReturn('id'); $this->sampleFactory->expects($this->once()) ->method('create') diff --git a/app/code/Magento/DownloadableGraphQl/Resolver/Order/Item/Links.php b/app/code/Magento/DownloadableGraphQl/Resolver/Order/Item/Links.php new file mode 100644 index 0000000000000..a578e98e02727 --- /dev/null +++ b/app/code/Magento/DownloadableGraphQl/Resolver/Order/Item/Links.php @@ -0,0 +1,125 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\DownloadableGraphQl\Resolver\Order\Item; + +use Magento\Downloadable\Model\ResourceModel\Link\Collection; +use Magento\Downloadable\Model\ResourceModel\Link\CollectionFactory; +use Magento\DownloadableGraphQl\Model\ConvertLinksToArray; +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\GraphQl\Config\Element\Field; +use Magento\Framework\GraphQl\Query\Resolver\ValueFactory; +use Magento\Framework\GraphQl\Query\ResolverInterface; +use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; +use Magento\Framework\Serialize\Serializer\Json; +use Magento\Sales\Api\Data\CreditmemoItemInterface; +use Magento\Sales\Api\Data\InvoiceItemInterface; +use Magento\Sales\Api\Data\OrderItemInterface; +use Magento\Sales\Api\Data\ShipmentItemInterface; +use Magento\Store\Api\Data\StoreInterface; + +/** + * Resolver fetches downloadable order item links and formats it according to the GraphQL schema. + */ +class Links implements ResolverInterface +{ + /** + * @var ConvertLinksToArray + */ + private $convertLinksToArray; + + /** + * @var CollectionFactory + */ + private $linkCollectionFactory; + + /** + * Serializer + * + * @var Json + */ + private $serializer; + + /** + * @var ValueFactory + */ + private $valueFactory; + + /** + * @param ConvertLinksToArray $convertLinksToArray + * @param CollectionFactory $linkCollectionFactory + * @param ValueFactory $valueFactory + * @param Json $serializer + */ + public function __construct( + ConvertLinksToArray $convertLinksToArray, + CollectionFactory $linkCollectionFactory, + ValueFactory $valueFactory, + Json $serializer + ) { + $this->convertLinksToArray = $convertLinksToArray; + $this->linkCollectionFactory = $linkCollectionFactory; + $this->valueFactory = $valueFactory; + $this->serializer = $serializer; + } + + /** + * @inheritdoc + */ + public function resolve( + Field $field, + $context, + ResolveInfo $info, + array $value = null, + array $args = null + ) { + /** @var StoreInterface $store */ + $store = $context->getExtensionAttributes()->getStore(); + + return $this->valueFactory->create(function () use ($value, $store) { + if (!isset($value['model'])) { + throw new LocalizedException(__('"model" value should be specified')); + } + + if ($value['model'] instanceof OrderItemInterface) { + return $this->formatLinksData($value['model'], $store); + } elseif ($value['model'] instanceof InvoiceItemInterface + || $value['model'] instanceof CreditmemoItemInterface + || $value['model'] instanceof ShipmentItemInterface) { + $item = $value['model']; + return $this->formatLinksData($item->getOrderItem(), $store); + } + return null; + }); + } + + /** + * Format values from order links item + * + * @param OrderItemInterface $item + * @param StoreInterface $store + * @return array + */ + private function formatLinksData( + OrderItemInterface $item, + StoreInterface $store + ): array { + $linksData = []; + if ($item->getProductType() === 'downloadable') { + $orderLinks = $item->getProductOptionByCode('links') ?? []; + + /** @var Collection */ + $linksCollection = $this->linkCollectionFactory->create(); + $linksCollection->addTitleToResult($store->getId()) + ->addPriceToResult($store->getWebsiteId()) + ->addFieldToFilter('main_table.link_id', ['in' => $orderLinks]); + + $linksData = $this->convertLinksToArray->execute($linksCollection->getItems()); + } + return $linksData; + } +} diff --git a/app/code/Magento/DownloadableGraphQl/composer.json b/app/code/Magento/DownloadableGraphQl/composer.json index 185a50f77cc15..d03a5953506e5 100644 --- a/app/code/Magento/DownloadableGraphQl/composer.json +++ b/app/code/Magento/DownloadableGraphQl/composer.json @@ -4,14 +4,17 @@ "type": "magento2-module", "require": { "php": "~7.3.0||~7.4.0", + "magento/module-store": "*", "magento/module-catalog": "*", "magento/module-downloadable": "*", "magento/module-quote": "*", + "magento/module-sales": "*", "magento/module-quote-graph-ql": "*", "magento/framework": "*" }, "suggest": { - "magento/module-catalog-graph-ql": "*" + "magento/module-catalog-graph-ql": "*", + "magento/module-sales-graph-ql": "*" }, "license": [ "OSL-3.0", diff --git a/app/code/Magento/DownloadableGraphQl/etc/graphql/di.xml b/app/code/Magento/DownloadableGraphQl/etc/graphql/di.xml index 51a630d59ca0f..d752b3f135278 100644 --- a/app/code/Magento/DownloadableGraphQl/etc/graphql/di.xml +++ b/app/code/Magento/DownloadableGraphQl/etc/graphql/di.xml @@ -39,6 +39,27 @@ </argument> </arguments> </type> + <type name="Magento\SalesGraphQl\Model\TypeResolver\OrderItem"> + <arguments> + <argument name="productTypeMap" xsi:type="array"> + <item name="downloadable" xsi:type="string">DownloadableOrderItem</item> + </argument> + </arguments> + </type> + <type name="Magento\SalesGraphQl\Model\TypeResolver\InvoiceItem"> + <arguments> + <argument name="productTypeMap" xsi:type="array"> + <item name="downloadable" xsi:type="string">DownloadableInvoiceItem</item> + </argument> + </arguments> + </type> + <type name="Magento\SalesGraphQl\Model\TypeResolver\CreditMemoItem"> + <arguments> + <argument name="productTypeMap" xsi:type="array"> + <item name="downloadable" xsi:type="string">DownloadableCreditMemoItem</item> + </argument> + </arguments> + </type> <type name="Magento\WishlistGraphQl\Model\Resolver\Type\WishlistItemType"> <arguments> <argument name="supportedTypes" xsi:type="array"> diff --git a/app/code/Magento/DownloadableGraphQl/etc/schema.graphqls b/app/code/Magento/DownloadableGraphQl/etc/schema.graphqls index ba178bb1a427e..8248343fcb120 100644 --- a/app/code/Magento/DownloadableGraphQl/etc/schema.graphqls +++ b/app/code/Magento/DownloadableGraphQl/etc/schema.graphqls @@ -53,7 +53,7 @@ type DownloadableProductLinks @doc(description: "DownloadableProductLinks define link_type: DownloadableFileTypeEnum @deprecated(reason: "`sample_url` serves to get the downloadable sample") sample_type: DownloadableFileTypeEnum @deprecated(reason: "`sample_url` serves to get the downloadable sample") sample_file: String @deprecated(reason: "`sample_url` serves to get the downloadable sample") - uid: ID! @doc(description: "A string that encodes option details.") @resolver(class: "Magento\\DownloadableGraphQl\\Resolver\\Product\\DownloadableLinksValueUid") # A Base64 string that encodes option details. + uid: ID! @doc(description: "A string that encodes option details.") @resolver(class: "Magento\\DownloadableGraphQl\\Resolver\\Product\\DownloadableLinksValueUid") } type DownloadableProductSamples @doc(description: "DownloadableProductSamples defines characteristics of a downloadable product") { @@ -65,6 +65,24 @@ type DownloadableProductSamples @doc(description: "DownloadableProductSamples de sample_file: String @deprecated(reason: "`sample_url` serves to get the downloadable sample") } +type DownloadableOrderItem implements OrderItemInterface { + downloadable_links: [DownloadableItemsLinks] @doc(description: "A list of downloadable links that are ordered from the downloadable product") @resolver(class: "Magento\\DownloadableGraphQl\\Resolver\\Order\\Item\\Links") +} + +type DownloadableInvoiceItem implements InvoiceItemInterface { + downloadable_links: [DownloadableItemsLinks] @doc(description: "A list of downloadable links that are invoiced from the downloadable product") @resolver(class: "Magento\\DownloadableGraphQl\\Resolver\\Order\\Item\\Links") +} + +type DownloadableCreditMemoItem implements CreditMemoItemInterface { + downloadable_links: [DownloadableItemsLinks] @doc(description: "A list of downloadable links that are refunded from the downloadable product") @resolver(class: "Magento\\DownloadableGraphQl\\Resolver\\Order\\Item\\Links") +} + +type DownloadableItemsLinks @doc(description: "DownloadableProductLinks defines characteristics of a downloadable product") { + title: String @doc(description: "The display name of the link") + sort_order: Int @doc(description: "A number indicating the sort order") + uid: ID! @doc(description: "A string that encodes option details.") @resolver(class: "Magento\\DownloadableGraphQl\\Resolver\\Product\\DownloadableLinksValueUid") +} + type DownloadableWishlistItem implements WishlistItemInterface @doc(description: "A downloadable product wish list item") { links_v2: [DownloadableProductLinks] @doc(description: "An array containing information about the selected links") @resolver(class: "\\Magento\\DownloadableGraphQl\\Model\\Wishlist\\ItemLinks") samples: [DownloadableProductSamples] @doc(description: "An array containing information about the selected samples") @resolver(class: "Magento\\DownloadableGraphQl\\Resolver\\Product\\Samples") diff --git a/app/code/Magento/DownloadableImportExport/Helper/Uploader.php b/app/code/Magento/DownloadableImportExport/Helper/Uploader.php index e6ead5d5cc021..3450376365cd0 100644 --- a/app/code/Magento/DownloadableImportExport/Helper/Uploader.php +++ b/app/code/Magento/DownloadableImportExport/Helper/Uploader.php @@ -6,6 +6,7 @@ namespace Magento\DownloadableImportExport\Helper; use Magento\Framework\App\Filesystem\DirectoryList; +use Magento\Framework\Filesystem\Driver\File; /** * Uploader helper for downloadable products @@ -82,6 +83,11 @@ public function getUploader($type, $parameters) $dirConfig = DirectoryList::getDefaultConfig(); $dirAddon = $dirConfig[DirectoryList::MEDIA][DirectoryList::PATH]; + // make media folder a primary folder for media in external storages + if (!is_a($this->mediaDirectory->getDriver(), File::class)) { + $dirAddon = DirectoryList::MEDIA; + } + if (!empty($parameters[\Magento\ImportExport\Model\Import::FIELD_NAME_IMG_FILE_DIR])) { $tmpPath = $parameters[\Magento\ImportExport\Model\Import::FIELD_NAME_IMG_FILE_DIR]; } else { @@ -113,7 +119,9 @@ public function getUploader($type, $parameters) */ public function isFileExist(string $fileName): bool { - return $this->mediaDirectory->isExist($this->fileUploader->getDestDir().$fileName); + $fileName = '/' . ltrim($fileName, '/'); + + return $this->mediaDirectory->isExist($this->fileUploader->getDestDir() . $fileName); } /** diff --git a/app/code/Magento/DownloadableImportExport/Model/Export/RowCustomizer.php b/app/code/Magento/DownloadableImportExport/Model/Export/RowCustomizer.php index daa874e829e54..5dc98f2d150f4 100644 --- a/app/code/Magento/DownloadableImportExport/Model/Export/RowCustomizer.php +++ b/app/code/Magento/DownloadableImportExport/Model/Export/RowCustomizer.php @@ -82,7 +82,9 @@ public function prepareData($collection, $productIds): void ->addAttributeToSelect('samples_title'); // set global scope during export $this->storeManager->setCurrentStore(Store::DEFAULT_STORE_ID); - foreach ($collection as $product) { + + while ($product = $productCollection->fetchItem()) { + /** @var $product \Magento\Catalog\Api\Data\ProductInterface */ $productLinks = $this->linkRepository->getLinksByProduct($product); $productSamples = $this->sampleRepository->getSamplesByProduct($product); $this->downloadableData[$product->getId()] = []; diff --git a/app/code/Magento/DownloadableImportExport/Model/Import/Product/Type/Downloadable.php b/app/code/Magento/DownloadableImportExport/Model/Import/Product/Type/Downloadable.php index a45fded76325f..5d6989aac6403 100644 --- a/app/code/Magento/DownloadableImportExport/Model/Import/Product/Type/Downloadable.php +++ b/app/code/Magento/DownloadableImportExport/Model/Import/Product/Type/Downloadable.php @@ -370,12 +370,12 @@ protected function isRowValidSample(array $rowData) foreach ($sampleData as $link) { if ($this->hasDomainNotInWhitelist($link, 'link_type', 'link_url')) { - $this->_entityModel->addRowError(static::ERROR_LINK_URL_NOT_IN_DOMAIN_WHITELIST, $this->rowNum); + $this->_entityModel->addRowError(self::ERROR_LINK_URL_NOT_IN_DOMAIN_WHITELIST, $this->rowNum); $result = true; } if ($this->hasDomainNotInWhitelist($link, 'sample_type', 'sample_url')) { - $this->_entityModel->addRowError(static::ERROR_SAMPLE_URL_NOT_IN_DOMAIN_WHITELIST, $this->rowNum); + $this->_entityModel->addRowError(self::ERROR_SAMPLE_URL_NOT_IN_DOMAIN_WHITELIST, $this->rowNum); $result = true; } } @@ -406,12 +406,12 @@ protected function isRowValidLink(array $rowData) foreach ($linkData as $link) { if ($this->hasDomainNotInWhitelist($link, 'link_type', 'link_url')) { - $this->_entityModel->addRowError(static::ERROR_LINK_URL_NOT_IN_DOMAIN_WHITELIST, $this->rowNum); + $this->_entityModel->addRowError(self::ERROR_LINK_URL_NOT_IN_DOMAIN_WHITELIST, $this->rowNum); $result = true; } if ($this->hasDomainNotInWhitelist($link, 'sample_type', 'sample_url')) { - $this->_entityModel->addRowError(static::ERROR_SAMPLE_URL_NOT_IN_DOMAIN_WHITELIST, $this->rowNum); + $this->_entityModel->addRowError(self::ERROR_SAMPLE_URL_NOT_IN_DOMAIN_WHITELIST, $this->rowNum); $result = true; } } diff --git a/app/code/Magento/DownloadableImportExport/Test/Unit/Model/Export/RowCustomizerTest.php b/app/code/Magento/DownloadableImportExport/Test/Unit/Model/Export/RowCustomizerTest.php new file mode 100644 index 0000000000000..f36676c1a8749 --- /dev/null +++ b/app/code/Magento/DownloadableImportExport/Test/Unit/Model/Export/RowCustomizerTest.php @@ -0,0 +1,113 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\DownloadableImportExport\Test\Unit\Model\Export; + +use Magento\Catalog\Api\Data\ProductInterface; +use Magento\Catalog\Model\ResourceModel\Product\Collection; +use Magento\Downloadable\Model\LinkRepository; +use Magento\Downloadable\Model\SampleRepository; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager as ObjectManagerHelper; +use Magento\Store\Model\StoreManagerInterface; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * Class RowCustomizerTest for export RowCustomizer + */ +class RowCustomizerTest extends TestCase +{ + /** + * @var StoreManagerInterface|MockObject + */ + private $storeManagerMock; + + /** + * @var LinkRepository|MockObject + */ + private $linkRepositoryMock; + + /** + * @var SampleRepository|MockObject + */ + private $sampleRepositoryMock; + + /** + * @var \Magento\DownloadableImportExport\Model\Export\RowCustomizer + */ + private $model; + + /** + * Setup + * + * @return void + */ + protected function setUp(): void + { + $this->storeManagerMock = $this->getMockBuilder(StoreManagerInterface::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->linkRepositoryMock = $this->getMockBuilder(LinkRepository::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->sampleRepositoryMock = $this->getMockBuilder(SampleRepository::class) + ->disableOriginalConstructor() + ->getMock(); + + $objectManagerHelper = new ObjectManagerHelper($this); + $this->model = $objectManagerHelper->getObject( + \Magento\DownloadableImportExport\Model\Export\RowCustomizer::class, + [ + 'storeManager' => $this->storeManagerMock, + 'linkRepository' => $this->linkRepositoryMock, + 'sampleRepository' => $this->sampleRepositoryMock, + ] + ); + } + + /** + * Test Prepare configurable data for export + */ + public function testPrepareData() + { + $product1 = $this->getMockBuilder(ProductInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $product1->expects($this->any()) + ->method('getId') + ->willReturn(1); + $product2 = $this->getMockBuilder(ProductInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $product2->expects($this->any()) + ->method('getId') + ->willReturn(2); + $collection = $this->getMockBuilder(Collection::class) + ->disableOriginalConstructor() + ->getMock(); + $collection->expects($this->atLeastOnce()) + ->method('fetchItem') + ->willReturn($product1, $product2); + + $collection->expects($this->exactly(2)) + ->method('addAttributeToFilter') + ->willReturnSelf(); + $collection->expects($this->exactly(2)) + ->method('addAttributeToSelect') + ->willReturnSelf(); + $this->linkRepositoryMock->expects($this->exactly(2)) + ->method('getLinksByProduct') + ->will($this->returnValue([])); + $this->sampleRepositoryMock->expects($this->exactly(2)) + ->method('getSamplesByProduct') + ->will($this->returnValue([])); + + $this->model->prepareData($collection, []); + } +} diff --git a/app/code/Magento/Eav/Block/Adminhtml/Attribute/Edit/Options/Options.php b/app/code/Magento/Eav/Block/Adminhtml/Attribute/Edit/Options/Options.php index 69f417e1ea732..f53f1e97a872d 100644 --- a/app/code/Magento/Eav/Block/Adminhtml/Attribute/Edit/Options/Options.php +++ b/app/code/Magento/Eav/Block/Adminhtml/Attribute/Edit/Options/Options.php @@ -152,7 +152,7 @@ protected function _prepareOptionValues( $inputType = ''; } - $values = [[]]; + $values = []; $isSystemAttribute = is_array($optionCollection); if ($isSystemAttribute) { $values[] = $this->getPreparedValues($optionCollection, $isSystemAttribute, $inputType, $defaultValues); @@ -168,7 +168,7 @@ protected function _prepareOptionValues( } } - return array_merge(...$values); + return array_merge([], ...$values); } /** diff --git a/app/code/Magento/Eav/Model/AttributeRepository.php b/app/code/Magento/Eav/Model/AttributeRepository.php index bb307d5581121..ee2db9a9b6b35 100644 --- a/app/code/Magento/Eav/Model/AttributeRepository.php +++ b/app/code/Magento/Eav/Model/AttributeRepository.php @@ -88,7 +88,7 @@ public function save(\Magento\Eav\Api\Data\AttributeInterface $attribute) try { $this->eavResource->save($attribute); } catch (\Exception $e) { - throw new StateException(__("The attribute can't be saved.")); + throw new StateException(__("The attribute can't be saved."), $e); } return $attribute; } diff --git a/app/code/Magento/Eav/Model/Entity/Attribute/Backend/ArrayBackend.php b/app/code/Magento/Eav/Model/Entity/Attribute/Backend/ArrayBackend.php index 03c91f53287eb..112483094465e 100644 --- a/app/code/Magento/Eav/Model/Entity/Attribute/Backend/ArrayBackend.php +++ b/app/code/Magento/Eav/Model/Entity/Attribute/Backend/ArrayBackend.php @@ -42,12 +42,15 @@ public function beforeSave($object) public function validate($object) { $attributeCode = $this->getAttribute()->getAttributeCode(); - $data = $object->getData($attributeCode); - if (is_array($data)) { - $object->setData($attributeCode, implode(',', array_filter($data))); - } elseif (empty($data)) { - $object->setData($attributeCode, null); + if ($object->hasData($attributeCode)) { + $data = $object->getData($attributeCode); + if (is_array($data)) { + $object->setData($attributeCode, implode(',', array_filter($data))); + } elseif (empty($data)) { + $object->setData($attributeCode, null); + } } + return parent::validate($object); } } diff --git a/app/code/Magento/Eav/Model/Entity/Collection/VersionControl/AbstractCollection.php b/app/code/Magento/Eav/Model/Entity/Collection/VersionControl/AbstractCollection.php index 631bfa3c2d2b5..5ecbf70c246d1 100644 --- a/app/code/Magento/Eav/Model/Entity/Collection/VersionControl/AbstractCollection.php +++ b/app/code/Magento/Eav/Model/Entity/Collection/VersionControl/AbstractCollection.php @@ -87,4 +87,15 @@ protected function beforeAddLoadedItem(\Magento\Framework\DataObject $item) $this->entitySnapshot->registerSnapshot($item); return $item; } + + /** + * Clear collection + * + * @return $this + */ + public function clear() + { + $this->entitySnapshot->clear($this->getNewEmptyItem()); + return parent::clear(); + } } diff --git a/app/code/Magento/Eav/Model/Form.php b/app/code/Magento/Eav/Model/Form.php index 074c6cf46a2f4..b06c084cf6675 100644 --- a/app/code/Magento/Eav/Model/Form.php +++ b/app/code/Magento/Eav/Model/Form.php @@ -487,11 +487,11 @@ public function validateData(array $data) { $validator = $this->_getValidator($data); if (!$validator->isValid($this->getEntity())) { - $messages = [[]]; + $messages = []; foreach ($validator->getMessages() as $errorMessages) { $messages[] = (array)$errorMessages; } - return array_merge(...$messages); + return array_merge([], ...$messages); } return true; } diff --git a/app/code/Magento/Eav/Model/ResourceModel/Helper.php b/app/code/Magento/Eav/Model/ResourceModel/Helper.php index fc8a47994a6aa..c81db40c608a8 100644 --- a/app/code/Magento/Eav/Model/ResourceModel/Helper.php +++ b/app/code/Magento/Eav/Model/ResourceModel/Helper.php @@ -19,6 +19,7 @@ class Helper extends \Magento\Framework\DB\Helper * @param \Magento\Framework\App\ResourceConnection $resource * @param string $modulePrefix * @codeCoverageIgnore + * phpcs:disable Generic.CodeAnalysis.UselessOverridingMethod */ public function __construct(\Magento\Framework\App\ResourceConnection $resource, $modulePrefix = 'Magento_Eav') { @@ -117,7 +118,7 @@ public function getLoadAttributesSelectGroups($selects) if (array_key_exists('all', $mainGroup)) { // it is better to call array_merge once after loop instead of calling it on each loop - $mainGroup['all'] = array_merge(...$mainGroup['all']); + $mainGroup['all'] = array_merge([], ...$mainGroup['all']); } return array_values($mainGroup); diff --git a/app/code/Magento/Eav/Test/Unit/Model/Entity/Attribute/Backend/ArrayBackendTest.php b/app/code/Magento/Eav/Test/Unit/Model/Entity/Attribute/Backend/ArrayBackendTest.php index 7d04d003a0e64..c01dd045e0857 100644 --- a/app/code/Magento/Eav/Test/Unit/Model/Entity/Attribute/Backend/ArrayBackendTest.php +++ b/app/code/Magento/Eav/Test/Unit/Model/Entity/Attribute/Backend/ArrayBackendTest.php @@ -17,40 +17,109 @@ class ArrayBackendTest extends TestCase /** * @var ArrayBackend */ - protected $_model; + private $_model; /** * @var Attribute */ - protected $_attribute; + private $_attribute; protected function setUp(): void { $this->_attribute = $this->createPartialMock( Attribute::class, - ['getAttributeCode', '__wakeup'] + ['getAttributeCode', 'getDefaultValue', '__wakeup'] ); $this->_model = new ArrayBackend(); $this->_model->setAttribute($this->_attribute); } /** - * @dataProvider attributeValueDataProvider + * @dataProvider validateDataProvider + * @param array $productData + * @param bool $hasData + * @param string|int|float|null $expectedValue */ - public function testValidate($data) + public function testValidate(array $productData, bool $hasData, $expectedValue) { - $this->_attribute->expects($this->atLeastOnce())->method('getAttributeCode')->willReturn('code'); - $product = new DataObject(['code' => $data, 'empty' => null]); + $this->_attribute->expects($this->atLeastOnce()) + ->method('getAttributeCode') + ->willReturn('attr'); + + $product = new DataObject($productData); $this->_model->validate($product); - $this->assertEquals('1,2,3', $product->getCode()); - $this->assertNull($product->getEmpty()); + $this->assertEquals($hasData, $product->hasData('attr')); + $this->assertEquals($expectedValue, $product->getAttr()); + } + + /** + * @return array + */ + public static function validateDataProvider(): array + { + return [ + [ + ['sku' => 'test1', 'attr' => [1, 2, 3]], + true, + '1,2,3', + ], + [ + ['sku' => 'test1', 'attr' => '1,2,3'], + true, + '1,2,3', + ], + [ + ['sku' => 'test1', 'attr' => null], + true, + null, + ], + [ + ['sku' => 'test1'], + false, + null, + ], + ]; + } + + /** + * @dataProvider beforeSaveDataProvider + * @param array $productData + * @param string $defaultValue + * @param string $expectedValue + */ + public function testBeforeSave( + array $productData, + string $defaultValue, + string $expectedValue + ) { + $this->_attribute->expects($this->atLeastOnce()) + ->method('getAttributeCode') + ->willReturn('attr'); + $this->_attribute->expects($this->any()) + ->method('getDefaultValue') + ->willReturn($defaultValue); + + $product = new DataObject($productData); + $this->_model->beforeSave($product); + $this->assertEquals($expectedValue, $product->getAttr()); } /** * @return array */ - public static function attributeValueDataProvider() + public function beforeSaveDataProvider(): array { - return [[[1, 2, 3]], ['1,2,3']]; + return [ + [ + ['sku' => 'test1', 'attr' => 'Value 2'], + 'Default value 1', + 'Value 2', + ], + [ + ['sku' => 'test1'], + 'Default value 1', + 'Default value 1', + ], + ]; } } diff --git a/app/code/Magento/Eav/Test/Unit/Model/Entity/AttributeLoaderTest.php b/app/code/Magento/Eav/Test/Unit/Model/Entity/AttributeLoaderTest.php index af6ce1bca8f58..6c3c61c4a6ec6 100644 --- a/app/code/Magento/Eav/Test/Unit/Model/Entity/AttributeLoaderTest.php +++ b/app/code/Magento/Eav/Test/Unit/Model/Entity/AttributeLoaderTest.php @@ -48,13 +48,13 @@ class AttributeLoaderTest extends TestCase protected function setUp(): void { - $this->configMock = $this->createMock(Config::class, [], [], '', false); + $this->configMock = $this->createMock(Config::class); $this->objectManagerMock = $this->getMockBuilder(ObjectManagerInterface::class) ->setMethods(['create']) ->disableOriginalConstructor() ->getMockForAbstractClass(); - $this->entityMock = $this->createMock(AbstractEntity::class, [], [], '', false); - $this->entityTypeMock = $this->createMock(Type::class, [], [], '', false); + $this->entityMock = $this->createMock(AbstractEntity::class); + $this->entityTypeMock = $this->createMock(Type::class); $this->attributeLoader = new AttributeLoader( $this->configMock, $this->objectManagerMock diff --git a/app/code/Magento/Eav/Test/Unit/Model/Entity/Collection/VersionControl/AbstractCollectionTest.php b/app/code/Magento/Eav/Test/Unit/Model/Entity/Collection/VersionControl/AbstractCollectionTest.php index aa49a78ced7e3..bc3f81c7385d1 100644 --- a/app/code/Magento/Eav/Test/Unit/Model/Entity/Collection/VersionControl/AbstractCollectionTest.php +++ b/app/code/Magento/Eav/Test/Unit/Model/Entity/Collection/VersionControl/AbstractCollectionTest.php @@ -36,7 +36,7 @@ protected function setUp(): void $this->entitySnapshot = $this->createPartialMock( Snapshot::class, - ['registerSnapshot'] + ['registerSnapshot', 'clear'] ); $this->subject = $objectManager->getObject( @@ -82,4 +82,11 @@ public static function fetchItemDataProvider() [['attribute' => 'test']] ]; } + + public function testClearSnapshot() + { + $item = $this->getMagentoObject(); + $this->entitySnapshot->expects($this->once())->method('clear')->with($item); + $this->subject->clear(); + } } diff --git a/app/code/Magento/Elasticsearch/Model/Adapter/BatchDataMapper/ProductDataMapper.php b/app/code/Magento/Elasticsearch/Model/Adapter/BatchDataMapper/ProductDataMapper.php index 9fa001097df87..0edc63b10f9ab 100644 --- a/app/code/Magento/Elasticsearch/Model/Adapter/BatchDataMapper/ProductDataMapper.php +++ b/app/code/Magento/Elasticsearch/Model/Adapter/BatchDataMapper/ProductDataMapper.php @@ -90,6 +90,13 @@ class ProductDataMapper implements BatchDataMapperInterface */ private $filterableAttributeTypes; + /** + * @var string[] + */ + private $sortableCaseSensitiveAttributes = [ + 'name', + ]; + /** * @param Builder $builder * @param FieldMapperInterface $fieldMapper @@ -99,6 +106,7 @@ class ProductDataMapper implements BatchDataMapperInterface * @param array $excludedAttributes * @param array $sortableAttributesValuesToImplode * @param array $filterableAttributeTypes + * @param array $sortableCaseSensitiveAttributes */ public function __construct( Builder $builder, @@ -108,7 +116,8 @@ public function __construct( DataProvider $dataProvider, array $excludedAttributes = [], array $sortableAttributesValuesToImplode = [], - array $filterableAttributeTypes = [] + array $filterableAttributeTypes = [], + array $sortableCaseSensitiveAttributes = [] ) { $this->builder = $builder; $this->fieldMapper = $fieldMapper; @@ -122,6 +131,10 @@ public function __construct( $this->dataProvider = $dataProvider; $this->attributeOptionsCache = []; $this->filterableAttributeTypes = $filterableAttributeTypes; + $this->sortableCaseSensitiveAttributes = array_merge( + $this->sortableCaseSensitiveAttributes, + $sortableCaseSensitiveAttributes + ); } /** @@ -259,6 +272,9 @@ private function isAttributeLabelsShouldBeMapped(Attribute $attribute): bool * @param array $attributeValues * @param int $storeId * @return array + * + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.NPathComplexity) */ private function prepareAttributeValues( int $productId, @@ -298,6 +314,12 @@ function (string $valueId) { $attributeValues = [$productId => implode(' ', $attributeValues)]; } + if (in_array($attribute->getAttributeCode(), $this->sortableCaseSensitiveAttributes)) { + foreach ($attributeValues as $key => $attributeValue) { + $attributeValues[$key] = strtolower($attributeValue); + } + } + return $attributeValues; } diff --git a/app/code/Magento/Elasticsearch/Model/Adapter/FieldMapper/Product/AttributeProvider.php b/app/code/Magento/Elasticsearch/Model/Adapter/FieldMapper/Product/AttributeProvider.php index 89c98d29ae03e..75636991e7ee6 100644 --- a/app/code/Magento/Elasticsearch/Model/Adapter/FieldMapper/Product/AttributeProvider.php +++ b/app/code/Magento/Elasticsearch/Model/Adapter/FieldMapper/Product/AttributeProvider.php @@ -10,6 +10,7 @@ use Magento\Eav\Model\Config; use Magento\Catalog\Api\Data\ProductAttributeInterface; use Magento\Elasticsearch\Model\Adapter\FieldMapper\Product\AttributeAdapter\DummyAttribute; +use Magento\Framework\ObjectManagerInterface; use Psr\Log\LoggerInterface; /** @@ -20,7 +21,7 @@ class AttributeProvider /** * Object Manager instance * - * @var \Magento\Framework\ObjectManagerInterface + * @var ObjectManagerInterface */ private $objectManager; @@ -49,13 +50,13 @@ class AttributeProvider /** * Factory constructor * - * @param \Magento\Framework\ObjectManagerInterface $objectManager + * @param ObjectManagerInterface $objectManager * @param Config $eavConfig * @param LoggerInterface $logger * @param string $instanceName */ public function __construct( - \Magento\Framework\ObjectManagerInterface $objectManager, + ObjectManagerInterface $objectManager, Config $eavConfig, LoggerInterface $logger, $instanceName = AttributeAdapter::class @@ -87,4 +88,17 @@ public function getByAttributeCode(string $attributeCode): AttributeAdapter return $this->cachedPool[$attributeCode]; } + + /** + * Remove attribute from cache by code. + * + * @param string $attributeCode + * @return void + */ + public function removeAttributeCacheByCode(string $attributeCode): void + { + if (isset($this->cachedPool[$attributeCode])) { + unset($this->cachedPool[$attributeCode]); + } + } } diff --git a/app/code/Magento/Elasticsearch/Model/Adapter/FieldMapper/Product/CompositeFieldProvider.php b/app/code/Magento/Elasticsearch/Model/Adapter/FieldMapper/Product/CompositeFieldProvider.php index b276b67ff7fba..980842d6233b1 100644 --- a/app/code/Magento/Elasticsearch/Model/Adapter/FieldMapper/Product/CompositeFieldProvider.php +++ b/app/code/Magento/Elasticsearch/Model/Adapter/FieldMapper/Product/CompositeFieldProvider.php @@ -40,12 +40,12 @@ public function __construct(array $providers) */ public function getFields(array $context = []): array { - $allAttributes = [[]]; + $allAttributes = []; foreach ($this->providers as $provider) { $allAttributes[] = $provider->getFields($context); } - return array_merge(...$allAttributes); + return array_merge([], ...$allAttributes); } } diff --git a/app/code/Magento/Elasticsearch/Model/Adapter/FieldMapper/Product/FieldProvider/StaticField.php b/app/code/Magento/Elasticsearch/Model/Adapter/FieldMapper/Product/FieldProvider/StaticField.php index bc031fc988fb0..f1ba54bf7e581 100644 --- a/app/code/Magento/Elasticsearch/Model/Adapter/FieldMapper/Product/FieldProvider/StaticField.php +++ b/app/code/Magento/Elasticsearch/Model/Adapter/FieldMapper/Product/FieldProvider/StaticField.php @@ -103,6 +103,7 @@ public function __construct( * @param array $context * @return array * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @SuppressWarnings(PHPMD.CyclomaticComplexity) */ public function getFields(array $context = []): array { @@ -140,6 +141,9 @@ public function getField(AbstractAttribute $attribute): array $fieldMapping[$fieldName] = [ 'type' => $this->fieldTypeResolver->getFieldType($attributeAdapter), ]; + if ($this->isNeedToAddCustomAnalyzer($fieldName) && $this->getCustomAnalyzer($fieldName)) { + $fieldMapping[$fieldName]['analyzer'] = $this->getCustomAnalyzer($fieldName); + } $index = $this->fieldIndexResolver->getFieldIndex($attributeAdapter); if (null !== $index) { @@ -188,4 +192,26 @@ public function getField(AbstractAttribute $attribute): array return $fieldMapping; } + + /** + * Check is the custom analyzer exists for the field + * + * @param string $fieldName + * @return bool + */ + private function isNeedToAddCustomAnalyzer(string $fieldName): bool + { + return $fieldName === 'sku'; + } + + /** + * Getter for the field custom analyzer if it's exists + * + * @param string $fieldName + * @return string|null + */ + private function getCustomAnalyzer(string $fieldName): ?string + { + return $fieldName === 'sku' ? 'sku' : null; + } } diff --git a/app/code/Magento/Elasticsearch/Model/Adapter/Index/Builder.php b/app/code/Magento/Elasticsearch/Model/Adapter/Index/Builder.php index 773faf49f8fda..1cad781ad6d74 100644 --- a/app/code/Magento/Elasticsearch/Model/Adapter/Index/Builder.php +++ b/app/code/Magento/Elasticsearch/Model/Adapter/Index/Builder.php @@ -8,6 +8,9 @@ use Magento\Framework\Locale\Resolver as LocaleResolver; use Magento\Elasticsearch\Model\Adapter\Index\Config\EsConfigInterface; +/** + * Index Builder + */ class Builder implements BuilderInterface { /** @@ -40,7 +43,7 @@ public function __construct( } /** - * {@inheritdoc} + * @inheritdoc */ public function build() { @@ -59,6 +62,14 @@ public function build() array_keys($filter) ), 'char_filter' => array_keys($charFilter) + ], + 'sku' => [ + 'type' => 'custom', + 'tokenizer' => 'keyword', + 'filter' => array_merge( + ['lowercase', 'keyword_repeat'], + array_keys($filter) + ), ] ], 'tokenizer' => $tokenizer, @@ -71,7 +82,10 @@ public function build() } /** - * {@inheritdoc} + * Setter for storeId property + * + * @param int $storeId + * @return void */ public function setStoreId($storeId) { @@ -79,47 +93,52 @@ public function setStoreId($storeId) } /** + * Return tokenizer configuration + * * @return array */ protected function getTokenizer() { - $tokenizer = [ + return [ 'default_tokenizer' => [ - 'type' => 'standard', - ], + 'type' => 'standard' + ] ]; - return $tokenizer; } /** + * Return filter configuration + * * @return array */ protected function getFilter() { - $filter = [ + return [ 'default_stemmer' => $this->getStemmerConfig(), 'unique_stem' => [ 'type' => 'unique', 'only_on_same_position' => true ] ]; - return $filter; } /** + * Return char filter configuration + * * @return array */ protected function getCharFilter() { - $charFilter = [ + return [ 'default_char_filter' => [ 'type' => 'html_strip', ], ]; - return $charFilter; } /** + * Return stemmer configuration + * * @return array */ protected function getStemmerConfig() diff --git a/app/code/Magento/Elasticsearch/Model/Advanced/ProductCollectionPrepareStrategy.php b/app/code/Magento/Elasticsearch/Model/Advanced/ProductCollectionPrepareStrategy.php index b3f8a56110f8d..d7054e2bb4b11 100644 --- a/app/code/Magento/Elasticsearch/Model/Advanced/ProductCollectionPrepareStrategy.php +++ b/app/code/Magento/Elasticsearch/Model/Advanced/ProductCollectionPrepareStrategy.php @@ -5,9 +5,11 @@ */ namespace Magento\Elasticsearch\Model\Advanced; -use Magento\Catalog\Model\ResourceModel\Product\Collection; use Magento\Catalog\Model\Config; +use Magento\Catalog\Model\Product\Visibility; +use Magento\Catalog\Model\ResourceModel\Product\Collection; use Magento\CatalogSearch\Model\Advanced\ProductCollectionPrepareStrategyInterface; +use Magento\Framework\App\ObjectManager; /** * Strategy interface for preparing product collection. @@ -19,13 +21,22 @@ class ProductCollectionPrepareStrategy implements ProductCollectionPrepareStrate */ private $catalogConfig; + /** + * @var Visibility + */ + private $catalogProductVisibility; + /** * @param Config $catalogConfig + * @param Visibility|null $catalogProductVisibility */ public function __construct( - Config $catalogConfig + Config $catalogConfig, + Visibility $catalogProductVisibility = null ) { $this->catalogConfig = $catalogConfig; + $this->catalogProductVisibility = $catalogProductVisibility + ?? ObjectManager::getInstance()->get(Visibility::class); } /** @@ -36,6 +47,7 @@ public function prepare(Collection $collection) $collection ->addAttributeToSelect($this->catalogConfig->getProductAttributes()) ->addMinimalPrice() - ->addTaxPercents(); + ->addTaxPercents() + ->setVisibility($this->catalogProductVisibility->getVisibleInSearchIds()); } } diff --git a/app/code/Magento/Elasticsearch/Model/Indexer/Fulltext/Plugin/Category/Product/Attribute.php b/app/code/Magento/Elasticsearch/Model/Indexer/Fulltext/Plugin/Category/Product/Attribute.php index 53f036a3b8e38..e15d91148b8ce 100644 --- a/app/code/Magento/Elasticsearch/Model/Indexer/Fulltext/Plugin/Category/Product/Attribute.php +++ b/app/code/Magento/Elasticsearch/Model/Indexer/Fulltext/Plugin/Category/Product/Attribute.php @@ -10,6 +10,7 @@ use Magento\Catalog\Model\ResourceModel\Attribute as AttributeResourceModel; use Magento\CatalogSearch\Model\Indexer\Fulltext\Processor; use Magento\Elasticsearch\Model\Config; +use Magento\Elasticsearch\Model\Adapter\FieldMapper\Product\AttributeProvider; use Magento\Elasticsearch\Model\Indexer\IndexerHandler as ElasticsearchIndexerHandler; use Magento\Framework\Indexer\DimensionProviderInterface; use Magento\CatalogSearch\Model\Indexer\IndexerHandlerFactory; @@ -41,6 +42,11 @@ class Attribute */ private $indexerHandlerFactory; + /** + * @var AttributeProvider + */ + private $attributeProvider; + /** * @var bool */ @@ -56,17 +62,20 @@ class Attribute * @param Processor $indexerProcessor * @param DimensionProviderInterface $dimensionProvider * @param IndexerHandlerFactory $indexerHandlerFactory + * @param AttributeProvider $attributeProvider */ public function __construct( Config $config, Processor $indexerProcessor, DimensionProviderInterface $dimensionProvider, - IndexerHandlerFactory $indexerHandlerFactory + IndexerHandlerFactory $indexerHandlerFactory, + AttributeProvider $attributeProvider ) { $this->config = $config; $this->indexerProcessor = $indexerProcessor; $this->dimensionProvider = $dimensionProvider; $this->indexerHandlerFactory = $indexerHandlerFactory; + $this->attributeProvider = $attributeProvider; } /** @@ -82,6 +91,7 @@ public function afterSave( AttributeResourceModel $subject, AttributeResourceModel $result ): AttributeResourceModel { + $this->attributeProvider->removeAttributeCacheByCode($this->attributeCode); $indexer = $this->indexerProcessor->getIndexer(); if ($this->isNewObject && !$indexer->isScheduled() diff --git a/app/code/Magento/Email/Block/Adminhtml/Template/Preview.php b/app/code/Magento/Email/Block/Adminhtml/Template/Preview.php index ec5596e2194a1..58fa4a1d318ff 100644 --- a/app/code/Magento/Email/Block/Adminhtml/Template/Preview.php +++ b/app/code/Magento/Email/Block/Adminhtml/Template/Preview.php @@ -67,6 +67,7 @@ protected function _toHtml() $template->setTemplateType($request->getParam('type')); $template->setTemplateText($request->getParam('text')); $template->setTemplateStyles($request->getParam('styles')); + $template->setData('is_legacy', false); } \Magento\Framework\Profiler::start($this->profilerName); diff --git a/app/code/Magento/Email/Model/AbstractTemplate.php b/app/code/Magento/Email/Model/AbstractTemplate.php index c697734b9df0f..1a05f88d8fa8f 100644 --- a/app/code/Magento/Email/Model/AbstractTemplate.php +++ b/app/code/Magento/Email/Model/AbstractTemplate.php @@ -361,8 +361,15 @@ public function getProcessedTemplate(array $variables = []) $variables = $this->addEmailVariables($variables, $storeId); $processor->setVariables($variables); + // Type legacy id strict + // db legacy true numeric false + // db new false numeric true + // filesystem false string false + // preview false null true + $isLegacy = $this->getData('is_legacy'); + $templateId = $this->getTemplateId(); $previousStrictMode = $processor->setStrictMode( - !$this->getData('is_legacy') && is_numeric($this->getTemplateId()) + !$isLegacy && (is_numeric($templateId) || empty($templateId)) ); try { diff --git a/app/code/Magento/Email/Test/Mftf/ActionGroup/PrepareDraftCustomTemplateActionGroup.xml b/app/code/Magento/Email/Test/Mftf/ActionGroup/PrepareDraftCustomTemplateActionGroup.xml new file mode 100644 index 0000000000000..72c7a3ee8e4ca --- /dev/null +++ b/app/code/Magento/Email/Test/Mftf/ActionGroup/PrepareDraftCustomTemplateActionGroup.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="PrepareDraftCustomTemplateActionGroup" extends="CreateNewTemplateActionGroup"> + <arguments> + <argument name="template" defaultValue="EmailTemplate"/> + </arguments> + <remove keyForRemoval="selectValueFromTemplateDropDown"/> + <remove keyForRemoval="clickLoadTemplateButton"/> + <remove keyForRemoval="clickSaveTemplateButton"/> + <remove keyForRemoval="waitForSuccessMessage"/> + <remove keyForRemoval="seeSuccessMessage"/> + + <fillField selector="{{AdminEmailTemplateEditSection.templateSubject}}" userInput="{{template.templateSubject}}" after="fillTemplateNameField" stepKey="fillTemplateSubject"/> + <fillField selector="{{AdminEmailTemplateEditSection.templateText}}" userInput="{{template.templateText}}" after="fillTemplateSubject" stepKey="fillTemplateText"/> + </actionGroup> + +</actionGroups> diff --git a/app/code/Magento/Email/Test/Mftf/Data/EmailTemplateData.xml b/app/code/Magento/Email/Test/Mftf/Data/EmailTemplateData.xml index 7f28e2241761b..06aff6ed6ab7e 100644 --- a/app/code/Magento/Email/Test/Mftf/Data/EmailTemplateData.xml +++ b/app/code/Magento/Email/Test/Mftf/Data/EmailTemplateData.xml @@ -13,4 +13,10 @@ <data key="templateSubject" unique="suffix">Template Subject_</data> <data key="templateText" unique="suffix">Template Text_</data> </entity> + <entity name="EmailTemplateWithDirectives" type="template"> + <data key="templateName" unique="suffix">Template</data> + <data key="templateSubject" unique="suffix">Template Subject_</data> + <data key="templateText">Template {{var this.template_id}}:{{var this.getData(template_id)}} Text</data> + <data key="expectedTemplate">Template : Text</data> + </entity> </entities> diff --git a/app/code/Magento/Email/Test/Mftf/Test/AdminEmailTemplatePreviewAsDraftWithDirectivesTest.xml b/app/code/Magento/Email/Test/Mftf/Test/AdminEmailTemplatePreviewAsDraftWithDirectivesTest.xml new file mode 100644 index 0000000000000..f61a0bba91046 --- /dev/null +++ b/app/code/Magento/Email/Test/Mftf/Test/AdminEmailTemplatePreviewAsDraftWithDirectivesTest.xml @@ -0,0 +1,47 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminEmailTemplatePreviewAsDraftWithDirectivesTest"> + <annotations> + <features value="Email"/> + <stories value="Create email template with directives and preview as draft"/> + <title value="Check email template preview with directives and preview as draft"/> + <description value="Check if email template preview works correctly with directives in draft mode"/> + <severity value="CRITICAL"/> + <useCaseId value="MC-23058"/> + <group value="email"/> + <group value="WYSIWYGDisabled"/> + <stories value="Email Template Preview"/> + </annotations> + + <before> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginToAdminArea"/> + </before> + + <after> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logoutOfAdmin"/> + </after> + + <actionGroup ref="PrepareDraftCustomTemplateActionGroup" stepKey="createDraftTemplate"> + <argument name="template" value="EmailTemplateWithDirectives"/> + </actionGroup> + + <click selector="{{AdminEmailTemplateEditSection.previewTemplateButton}}" stepKey="clickPreviewTemplate"/> + <switchToNextTab stepKey="switchToPreviewTab"/> + <seeInCurrentUrl url="{{AdminEmailTemplatePreviewPage.url}}" stepKey="seePreviewInUrl"/> + <seeElement selector="{{AdminEmailTemplatePreviewSection.iframe}}" stepKey="seeIframeOnPage"/> + <switchToIFrame userInput="preview_iframe" stepKey="switchToIframe"/> + <waitForPageLoad stepKey="waitForPreviewLoaded"/> + + <actionGroup ref="AssertEmailTemplateContentActionGroup" stepKey="assertContent"> + <argument name="expectedContent" value="{{EmailTemplateWithDirectives.expectedTemplate}}"/> + </actionGroup> + </test> +</tests> diff --git a/app/code/Magento/Email/Test/Mftf/Test/AdminEmailTemplatePreviewTest.xml b/app/code/Magento/Email/Test/Mftf/Test/AdminEmailTemplatePreviewTest.xml index 0d9ca6a2c195a..22e70b72e5401 100644 --- a/app/code/Magento/Email/Test/Mftf/Test/AdminEmailTemplatePreviewTest.xml +++ b/app/code/Magento/Email/Test/Mftf/Test/AdminEmailTemplatePreviewTest.xml @@ -18,6 +18,7 @@ <testCaseId value="MC-15794"/> <useCaseId value="MC-11050"/> <group value="email"/> + <group value="WYSIWYGDisabled"/> </annotations> <before> diff --git a/app/code/Magento/Email/Test/Mftf/Test/AdminEmailTemplatePreviewWithDirectivesTest.xml b/app/code/Magento/Email/Test/Mftf/Test/AdminEmailTemplatePreviewWithDirectivesTest.xml new file mode 100644 index 0000000000000..7683591e8f37f --- /dev/null +++ b/app/code/Magento/Email/Test/Mftf/Test/AdminEmailTemplatePreviewWithDirectivesTest.xml @@ -0,0 +1,45 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminEmailTemplatePreviewWithDirectivesTest"> + <annotations> + <features value="Email"/> + <stories value="Create email template with directives"/> + <title value="Check email template preview with directives"/> + <description value="Check if email template preview works correctly with directives"/> + <severity value="CRITICAL"/> + <useCaseId value="MC-23058"/> + <group value="email"/> + <group value="WYSIWYGDisabled"/> + <stories value="Email Template Preview"/> + </annotations> + + <before> + <!--Login to Admin Area--> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginToAdminArea"/> + </before> + + <after> + <!--Delete created Template--> + <actionGroup ref="DeleteEmailTemplateActionGroup" stepKey="deleteTemplate"/> + <click selector="{{AdminDataGridHeaderSection.clearFilters}}" stepKey="clearFilters"/> + <!--Logout from Admin Area--> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logoutOfAdmin"/> + </after> + + <actionGroup ref="CreateCustomTemplateActionGroup" stepKey="createTemplate"> + <argument name="template" value="EmailTemplateWithDirectives"/> + </actionGroup> + <actionGroup ref="PreviewEmailTemplateActionGroup" stepKey="previewTemplate"/> + <actionGroup ref="AssertEmailTemplateContentActionGroup" stepKey="assertContent"> + <argument name="expectedContent" value="{{EmailTemplateWithDirectives.expectedTemplate}}"/> + </actionGroup> + </test> +</tests> diff --git a/app/code/Magento/Email/Test/Unit/Model/Template/Css/ProcessorTest.php b/app/code/Magento/Email/Test/Unit/Model/Template/Css/ProcessorTest.php index 2d0018ff81ee5..816565ff7a905 100644 --- a/app/code/Magento/Email/Test/Unit/Model/Template/Css/ProcessorTest.php +++ b/app/code/Magento/Email/Test/Unit/Model/Template/Css/ProcessorTest.php @@ -44,7 +44,7 @@ protected function setUp(): void public function testProcess() { - $url = 'http://magento.local/pub/static/'; + $url = 'http://magento.local/static/'; $locale = 'en_US'; $css = '@import url("{{base_url_path}}frontend/_view/{{locale}}/css/email.css");'; $expectedCss = '@import url("' . $url . 'frontend/_view/' . $locale . '/css/email.css");'; diff --git a/app/code/Magento/GiftMessageGraphQl/Model/Resolver/Order/GiftMessage.php b/app/code/Magento/GiftMessageGraphQl/Model/Resolver/Order/GiftMessage.php index aae0e3709d87f..f7dd0ca9c62f5 100644 --- a/app/code/Magento/GiftMessageGraphQl/Model/Resolver/Order/GiftMessage.php +++ b/app/code/Magento/GiftMessageGraphQl/Model/Resolver/Order/GiftMessage.php @@ -58,17 +58,14 @@ public function resolve( if (!isset($value['id'])) { throw new GraphQlInputException(__('"id" value should be specified')); } - + // phpcs:ignore Magento2.Functions.DiscouragedFunction + $orderId = (int)base64_decode($value['id']) ?: (int)$value['id']; try { - $orderGiftMessage = $this->orderRepository->get($value['id']); + $orderGiftMessage = $this->orderRepository->get($orderId); } catch (LocalizedException $e) { throw new GraphQlInputException(__('Can\'t load gift message for order')); } - if (!isset($orderGiftMessage)) { - return null; - } - return [ 'to' => $orderGiftMessage->getRecipient() ?? '', 'from' => $orderGiftMessage->getSender() ?? '', diff --git a/app/code/Magento/GraphQl/Controller/HttpResponse/Cors/CorsAllowCredentialsHeaderProvider.php b/app/code/Magento/GraphQl/Controller/HttpResponse/Cors/CorsAllowCredentialsHeaderProvider.php deleted file mode 100644 index ba2e995d4f704..0000000000000 --- a/app/code/Magento/GraphQl/Controller/HttpResponse/Cors/CorsAllowCredentialsHeaderProvider.php +++ /dev/null @@ -1,71 +0,0 @@ -<?php -/** - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. - */ -declare(strict_types=1); - -namespace Magento\GraphQl\Controller\HttpResponse\Cors; - -use Magento\Framework\App\Response\HeaderProvider\HeaderProviderInterface; -use Magento\GraphQl\Model\Cors\ConfigurationInterface; - -/** - * Provides value for Access-Control-Allow-Credentials header if CORS is enabled - */ -class CorsAllowCredentialsHeaderProvider implements HeaderProviderInterface -{ - /** - * @var string - */ - private $headerName; - - /** - * CORS configuration provider - * - * @var \Magento\GraphQl\Model\Cors\ConfigurationInterface - */ - private $corsConfiguration; - - /** - * @param ConfigurationInterface $corsConfiguration - * @param string $headerName - */ - public function __construct( - ConfigurationInterface $corsConfiguration, - string $headerName - ) { - $this->corsConfiguration = $corsConfiguration; - $this->headerName = $headerName; - } - - /** - * Get name of header - * - * @return string - */ - public function getName(): string - { - return $this->headerName; - } - - /** - * Get value for header - * - * @return string - */ - public function getValue(): string - { - return "1"; - } - - /** - * Check if header can be applied - * - * @return bool - */ - public function canApply(): bool - { - return $this->corsConfiguration->isEnabled() && $this->corsConfiguration->isCredentialsAllowed(); - } -} diff --git a/app/code/Magento/GraphQl/Controller/HttpResponse/Cors/CorsAllowHeadersHeaderProvider.php b/app/code/Magento/GraphQl/Controller/HttpResponse/Cors/CorsAllowHeadersHeaderProvider.php deleted file mode 100644 index 68760de543daa..0000000000000 --- a/app/code/Magento/GraphQl/Controller/HttpResponse/Cors/CorsAllowHeadersHeaderProvider.php +++ /dev/null @@ -1,71 +0,0 @@ -<?php -/** - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. - */ -declare(strict_types=1); - -namespace Magento\GraphQl\Controller\HttpResponse\Cors; - -use Magento\Framework\App\Response\HeaderProvider\HeaderProviderInterface; -use Magento\GraphQl\Model\Cors\ConfigurationInterface; - -/** - * Provides value for Access-Control-Allow-Headers header if CORS is enabled - */ -class CorsAllowHeadersHeaderProvider implements HeaderProviderInterface -{ - /** - * @var string - */ - private $headerName; - - /** - * CORS configuration provider - * - * @var \Magento\GraphQl\Model\Cors\ConfigurationInterface - */ - private $corsConfiguration; - - /** - * @param ConfigurationInterface $corsConfiguration - * @param string $headerName - */ - public function __construct( - ConfigurationInterface $corsConfiguration, - string $headerName - ) { - $this->corsConfiguration = $corsConfiguration; - $this->headerName = $headerName; - } - - /** - * Get name of header - * - * @return string - */ - public function getName(): string - { - return $this->headerName; - } - - /** - * Check if header can be applied - * - * @return bool - */ - public function canApply(): bool - { - return $this->corsConfiguration->isEnabled() && $this->getValue(); - } - - /** - * Get value for header - * - * @return string|null - */ - public function getValue(): ?string - { - return $this->corsConfiguration->getAllowedHeaders(); - } -} diff --git a/app/code/Magento/GraphQl/Controller/HttpResponse/Cors/CorsAllowMethodsHeaderProvider.php b/app/code/Magento/GraphQl/Controller/HttpResponse/Cors/CorsAllowMethodsHeaderProvider.php deleted file mode 100644 index 233839b9deb74..0000000000000 --- a/app/code/Magento/GraphQl/Controller/HttpResponse/Cors/CorsAllowMethodsHeaderProvider.php +++ /dev/null @@ -1,71 +0,0 @@ -<?php -/** - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. - */ -declare(strict_types=1); - -namespace Magento\GraphQl\Controller\HttpResponse\Cors; - -use Magento\Framework\App\Response\HeaderProvider\HeaderProviderInterface; -use Magento\GraphQl\Model\Cors\ConfigurationInterface; - -/** - * Provides value for Access-Control-Allow-Methods header if CORS is enabled - */ -class CorsAllowMethodsHeaderProvider implements HeaderProviderInterface -{ - /** - * @var string - */ - private $headerName; - - /** - * CORS configuration provider - * - * @var \Magento\GraphQl\Model\Cors\ConfigurationInterface - */ - private $corsConfiguration; - - /** - * @param ConfigurationInterface $corsConfiguration - * @param string $headerName - */ - public function __construct( - ConfigurationInterface $corsConfiguration, - string $headerName - ) { - $this->corsConfiguration = $corsConfiguration; - $this->headerName = $headerName; - } - - /** - * Get name of header - * - * @return string - */ - public function getName(): string - { - return $this->headerName; - } - - /** - * Check if header can be applied - * - * @return bool - */ - public function canApply(): bool - { - return $this->corsConfiguration->isEnabled() && $this->getValue(); - } - - /** - * Get value for header - * - * @return string|null - */ - public function getValue(): ?string - { - return $this->corsConfiguration->getAllowedMethods(); - } -} diff --git a/app/code/Magento/GraphQl/Controller/HttpResponse/Cors/CorsAllowOriginHeaderProvider.php b/app/code/Magento/GraphQl/Controller/HttpResponse/Cors/CorsAllowOriginHeaderProvider.php deleted file mode 100644 index 21850f18db1f2..0000000000000 --- a/app/code/Magento/GraphQl/Controller/HttpResponse/Cors/CorsAllowOriginHeaderProvider.php +++ /dev/null @@ -1,71 +0,0 @@ -<?php -/** - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. - */ -declare(strict_types=1); - -namespace Magento\GraphQl\Controller\HttpResponse\Cors; - -use Magento\Framework\App\Response\HeaderProvider\HeaderProviderInterface; -use Magento\GraphQl\Model\Cors\ConfigurationInterface; - -/** - * Provides value for Access-Control-Allow-Origin header if CORS is enabled - */ -class CorsAllowOriginHeaderProvider implements HeaderProviderInterface -{ - /** - * @var string - */ - private $headerName; - - /** - * CORS configuration provider - * - * @var \Magento\GraphQl\Model\Cors\ConfigurationInterface - */ - private $corsConfiguration; - - /** - * @param ConfigurationInterface $corsConfiguration - * @param string $headerName - */ - public function __construct( - ConfigurationInterface $corsConfiguration, - string $headerName - ) { - $this->corsConfiguration = $corsConfiguration; - $this->headerName = $headerName; - } - - /** - * Get name of header - * - * @return string - */ - public function getName(): string - { - return $this->headerName; - } - - /** - * Check if header can be applied - * - * @return bool - */ - public function canApply(): bool - { - return $this->corsConfiguration->isEnabled() && $this->getValue(); - } - - /** - * Get value for header - * - * @return string|null - */ - public function getValue(): ?string - { - return $this->corsConfiguration->getAllowedOrigins(); - } -} diff --git a/app/code/Magento/GraphQl/Controller/HttpResponse/Cors/CorsMaxAgeHeaderProvider.php b/app/code/Magento/GraphQl/Controller/HttpResponse/Cors/CorsMaxAgeHeaderProvider.php deleted file mode 100644 index e30209ae25e68..0000000000000 --- a/app/code/Magento/GraphQl/Controller/HttpResponse/Cors/CorsMaxAgeHeaderProvider.php +++ /dev/null @@ -1,71 +0,0 @@ -<?php -/** - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. - */ -declare(strict_types=1); - -namespace Magento\GraphQl\Controller\HttpResponse\Cors; - -use Magento\Framework\App\Response\HeaderProvider\HeaderProviderInterface; -use Magento\GraphQl\Model\Cors\ConfigurationInterface; - -/** - * Provides value for Access-Control-Max-Age header if CORS is enabled - */ -class CorsMaxAgeHeaderProvider implements HeaderProviderInterface -{ - /** - * @var string - */ - private $headerName; - - /** - * CORS configuration provider - * - * @var \Magento\GraphQl\Model\Cors\ConfigurationInterface - */ - private $corsConfiguration; - - /** - * @param ConfigurationInterface $corsConfiguration - * @param string $headerName - */ - public function __construct( - ConfigurationInterface $corsConfiguration, - string $headerName - ) { - $this->corsConfiguration = $corsConfiguration; - $this->headerName = $headerName; - } - - /** - * Get name of header - * - * @return string - */ - public function getName(): string - { - return $this->headerName; - } - - /** - * Check if header can be applied - * - * @return bool - */ - public function canApply(): bool - { - return $this->corsConfiguration->isEnabled() && $this->getValue(); - } - - /** - * Get value for header - * - * @return string|null - */ - public function getValue(): ?string - { - return (string) $this->corsConfiguration->getMaxAge(); - } -} diff --git a/app/code/Magento/GraphQl/Model/Cors/Configuration.php b/app/code/Magento/GraphQl/Model/Cors/Configuration.php deleted file mode 100644 index dd5a0b426e22d..0000000000000 --- a/app/code/Magento/GraphQl/Model/Cors/Configuration.php +++ /dev/null @@ -1,96 +0,0 @@ -<?php -/** - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. - */ -declare(strict_types=1); - -namespace Magento\GraphQl\Model\Cors; - -use Magento\Framework\App\Config\ScopeConfigInterface; - -/** - * Configuration provider for GraphQL CORS settings - */ -class Configuration implements ConfigurationInterface -{ - public const XML_PATH_CORS_HEADERS_ENABLED = 'graphql/cors/enabled'; - public const XML_PATH_CORS_ALLOWED_ORIGINS = 'graphql/cors/allowed_origins'; - public const XML_PATH_CORS_ALLOWED_HEADERS = 'graphql/cors/allowed_headers'; - public const XML_PATH_CORS_ALLOWED_METHODS = 'graphql/cors/allowed_methods'; - public const XML_PATH_CORS_MAX_AGE = 'graphql/cors/max_age'; - public const XML_PATH_CORS_ALLOW_CREDENTIALS = 'graphql/cors/allow_credentials'; - - /** - * @var ScopeConfigInterface - */ - private $scopeConfig; - - /** - * @param ScopeConfigInterface $scopeConfig - */ - public function __construct(ScopeConfigInterface $scopeConfig) - { - $this->scopeConfig = $scopeConfig; - } - - /** - * Are CORS headers enabled - * - * @return bool - */ - public function isEnabled(): bool - { - return $this->scopeConfig->isSetFlag(self::XML_PATH_CORS_HEADERS_ENABLED); - } - - /** - * Get allowed origins or null if stored configuration is empty - * - * @return string|null - */ - public function getAllowedOrigins(): ?string - { - return $this->scopeConfig->getValue(self::XML_PATH_CORS_ALLOWED_ORIGINS); - } - - /** - * Get allowed headers or null if stored configuration is empty - * - * @return string|null - */ - public function getAllowedHeaders(): ?string - { - return $this->scopeConfig->getValue(self::XML_PATH_CORS_ALLOWED_HEADERS); - } - - /** - * Get allowed methods or null if stored configuration is empty - * - * @return string|null - */ - public function getAllowedMethods(): ?string - { - return $this->scopeConfig->getValue(self::XML_PATH_CORS_ALLOWED_METHODS); - } - - /** - * Get max age header value - * - * @return int - */ - public function getMaxAge(): int - { - return (int) $this->scopeConfig->getValue(self::XML_PATH_CORS_MAX_AGE); - } - - /** - * Are credentials allowed - * - * @return bool - */ - public function isCredentialsAllowed(): bool - { - return $this->scopeConfig->isSetFlag(self::XML_PATH_CORS_ALLOW_CREDENTIALS); - } -} diff --git a/app/code/Magento/GraphQl/Model/Cors/ConfigurationInterface.php b/app/code/Magento/GraphQl/Model/Cors/ConfigurationInterface.php deleted file mode 100644 index b40b64f48e51f..0000000000000 --- a/app/code/Magento/GraphQl/Model/Cors/ConfigurationInterface.php +++ /dev/null @@ -1,56 +0,0 @@ -<?php -/** - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. - */ -declare(strict_types=1); - -namespace Magento\GraphQl\Model\Cors; - -/** - * Interface for configuration provider for GraphQL CORS settings - */ -interface ConfigurationInterface -{ - /** - * Are CORS headers enabled - * - * @return bool - */ - public function isEnabled(): bool; - - /** - * Get allowed origins or null if stored configuration is empty - * - * @return string|null - */ - public function getAllowedOrigins(): ?string; - - /** - * Get allowed headers or null if stored configuration is empty - * - * @return string|null - */ - public function getAllowedHeaders(): ?string; - - /** - * Get allowed methods or null if stored configuration is empty - * - * @return string|null - */ - public function getAllowedMethods(): ?string; - - /** - * Get max age header value - * - * @return int - */ - public function getMaxAge(): int; - - /** - * Are credentials allowed - * - * @return bool - */ - public function isCredentialsAllowed() : bool; -} diff --git a/app/code/Magento/GraphQl/etc/adminhtml/system.xml b/app/code/Magento/GraphQl/etc/adminhtml/system.xml deleted file mode 100644 index ddee7596eca3e..0000000000000 --- a/app/code/Magento/GraphQl/etc/adminhtml/system.xml +++ /dev/null @@ -1,65 +0,0 @@ -<?xml version="1.0"?> -<!-- -/** - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. - */ ---> -<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Config:etc/system_file.xsd"> - <system> - <section id="graphql" translate="label" type="text" sortOrder="300" showInDefault="1" showInWebsite="1" showInStore="1"> - <label>GraphQL</label> - <tab>service</tab> - <resource>Magento_Integration::config_oauth</resource> - <group id="cors" translate="label" type="text" sortOrder="60" showInDefault="1" showInWebsite="1"> - <label>CORS Settings</label> - <field id="enabled" translate="label" type="select" sortOrder="1" showInDefault="1" canRestore="1"> - <label>CORS Headers Enabled</label> - <source_model>Magento\Config\Model\Config\Source\Yesno</source_model> - </field> - - <field id="allowed_origins" translate="label" type="text" sortOrder="10" showInDefault="1" canRestore="1"> - <label>Allowed origins</label> - <comment>The Access-Control-Allow-Origin response header indicates whether the response can be shared with requesting code from the given origin. Fill this field with one or more origins (comma separated) or use '*' to allow access from all origins.</comment> - <depends> - <field id="graphql/cors/enabled">1</field> - </depends> - </field> - - <field id="allowed_methods" translate="label" type="text" sortOrder="20" showInDefault="1" canRestore="1"> - <label>Allowed methods</label> - <comment>The Access-Control-Allow-Methods response header specifies the method or methods allowed when accessing the resource in response to a preflight request. Use comma separated methods (e.g. GET,POST)</comment> - <depends> - <field id="graphql/cors/enabled">1</field> - </depends> - </field> - - <field id="allowed_headers" translate="label" type="text" sortOrder="30" showInDefault="1" canRestore="1"> - <label>Allowed headers</label> - <comment>The Access-Control-Allow-Headers response header is used in response to a preflight request which includes the Access-Control-Request-Headers to indicate which HTTP headers can be used during the actual request. Use comma separated headers.</comment> - <depends> - <field id="graphql/cors/enabled">1</field> - </depends> - </field> - - <field id="max_age" translate="label" type="text" sortOrder="40" showInDefault="1" canRestore="1"> - <label>Max Age</label> - <validate>validate-digits</validate> - <comment>The Access-Control-Max-Age response header indicates how long the results of a preflight request (that is the information contained in the Access-Control-Allow-Methods and Access-Control-Allow-Headers headers) can be cached.</comment> - <depends> - <field id="graphql/cors/enabled">1</field> - </depends> - </field> - - <field id="allow_credentials" translate="label" type="select" sortOrder="50" showInDefault="1" canRestore="1"> - <label>Credentials Allowed</label> - <source_model>Magento\Config\Model\Config\Source\Yesno</source_model> - <comment>The Access-Control-Allow-Credentials response header tells browsers whether to expose the response to frontend code when the request's credentials mode is include.</comment> - <depends> - <field id="graphql/cors/enabled">1</field> - </depends> - </field> - </group> - </section> - </system> -</config> diff --git a/app/code/Magento/GraphQl/etc/config.xml b/app/code/Magento/GraphQl/etc/config.xml deleted file mode 100644 index 39caacbec42d2..0000000000000 --- a/app/code/Magento/GraphQl/etc/config.xml +++ /dev/null @@ -1,22 +0,0 @@ -<?xml version="1.0"?> -<!-- -/** - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. - */ ---> -<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Store:etc/config.xsd"> - <default> - <graphql> - <cors> - <enabled>0</enabled> - <allowed_origins></allowed_origins> - <allowed_methods></allowed_methods> - <allowed_headers></allowed_headers> - <max_age>86400</max_age> - <allow_credentials>0</allow_credentials> - </cors> - </graphql> - </default> -</config> diff --git a/app/code/Magento/GraphQl/etc/di.xml b/app/code/Magento/GraphQl/etc/di.xml index d6168cdc37600..7195c05c0877b 100644 --- a/app/code/Magento/GraphQl/etc/di.xml +++ b/app/code/Magento/GraphQl/etc/di.xml @@ -102,31 +102,4 @@ <argument name="queryComplexity" xsi:type="number">300</argument> </arguments> </type> - - <preference for="Magento\GraphQl\Model\Cors\ConfigurationInterface" type="Magento\GraphQl\Model\Cors\Configuration" /> - <type name="Magento\GraphQl\Controller\HttpResponse\Cors\CorsMaxAgeHeaderProvider"> - <arguments> - <argument name="headerName" xsi:type="string">Access-Control-Max-Age</argument> - </arguments> - </type> - <type name="Magento\GraphQl\Controller\HttpResponse\Cors\CorsAllowCredentialsHeaderProvider"> - <arguments> - <argument name="headerName" xsi:type="string">Access-Control-Allow-Credentials</argument> - </arguments> - </type> - <type name="Magento\GraphQl\Controller\HttpResponse\Cors\CorsAllowHeadersHeaderProvider"> - <arguments> - <argument name="headerName" xsi:type="string">Access-Control-Allow-Headers</argument> - </arguments> - </type> - <type name="Magento\GraphQl\Controller\HttpResponse\Cors\CorsAllowMethodsHeaderProvider"> - <arguments> - <argument name="headerName" xsi:type="string">Access-Control-Allow-Methods</argument> - </arguments> - </type> - <type name="Magento\GraphQl\Controller\HttpResponse\Cors\CorsAllowOriginHeaderProvider"> - <arguments> - <argument name="headerName" xsi:type="string">Access-Control-Allow-Origin</argument> - </arguments> - </type> </config> diff --git a/app/code/Magento/GraphQl/etc/graphql/di.xml b/app/code/Magento/GraphQl/etc/graphql/di.xml index 23d49124d1a02..77fce336374dd 100644 --- a/app/code/Magento/GraphQl/etc/graphql/di.xml +++ b/app/code/Magento/GraphQl/etc/graphql/di.xml @@ -30,15 +30,4 @@ </argument> </arguments> </type> - <type name="Magento\Framework\App\Response\HeaderManager"> - <arguments> - <argument name="headerProviderList" xsi:type="array"> - <item name="CorsAllowOrigins" xsi:type="object">Magento\GraphQl\Controller\HttpResponse\Cors\CorsAllowOriginHeaderProvider</item> - <item name="CorsAllowHeaders" xsi:type="object">Magento\GraphQl\Controller\HttpResponse\Cors\CorsAllowHeadersHeaderProvider</item> - <item name="CorsAllowMethods" xsi:type="object">Magento\GraphQl\Controller\HttpResponse\Cors\CorsAllowMethodsHeaderProvider</item> - <item name="CorsAllowCredentials" xsi:type="object">Magento\GraphQl\Controller\HttpResponse\Cors\CorsAllowCredentialsHeaderProvider</item> - <item name="CorsMaxAge" xsi:type="object">Magento\GraphQl\Controller\HttpResponse\Cors\CorsMaxAgeHeaderProvider</item> - </argument> - </arguments> - </type> </config> diff --git a/app/code/Magento/GroupedProduct/Block/Cart/Item/Renderer/Grouped.php b/app/code/Magento/GroupedProduct/Block/Cart/Item/Renderer/Grouped.php index 197be38fb7f5f..8dc153f28c162 100644 --- a/app/code/Magento/GroupedProduct/Block/Cart/Item/Renderer/Grouped.php +++ b/app/code/Magento/GroupedProduct/Block/Cart/Item/Renderer/Grouped.php @@ -49,6 +49,6 @@ public function getIdentities() if ($this->getItem()) { $identities[] = $this->getGroupedProduct()->getIdentities(); } - return array_merge(...$identities); + return array_merge([], ...$identities); } } diff --git a/app/code/Magento/GroupedProduct/Block/Stockqty/Type/Grouped.php b/app/code/Magento/GroupedProduct/Block/Stockqty/Type/Grouped.php index 97dc90ec93493..78ae4047c0aad 100644 --- a/app/code/Magento/GroupedProduct/Block/Stockqty/Type/Grouped.php +++ b/app/code/Magento/GroupedProduct/Block/Stockqty/Type/Grouped.php @@ -32,10 +32,10 @@ protected function _getChildProducts() */ public function getIdentities() { - $identities = [[]]; + $identities = []; foreach ($this->getChildProducts() as $item) { $identities[] = $item->getIdentities(); } - return array_merge(...$identities); + return array_merge([], ...$identities); } } diff --git a/app/code/Magento/GroupedProduct/Model/Inventory/ChangeParentStockStatus.php b/app/code/Magento/GroupedProduct/Model/Inventory/ChangeParentStockStatus.php new file mode 100644 index 0000000000000..bf1f6c1a5cf1a --- /dev/null +++ b/app/code/Magento/GroupedProduct/Model/Inventory/ChangeParentStockStatus.php @@ -0,0 +1,171 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\GroupedProduct\Model\Inventory; + +use Magento\Catalog\Api\Data\ProductInterface; +use Magento\CatalogInventory\Api\Data\StockItemInterface; +use Magento\CatalogInventory\Api\StockConfigurationInterface; +use Magento\CatalogInventory\Api\StockItemCriteriaInterfaceFactory; +use Magento\CatalogInventory\Api\StockItemRepositoryInterface; +use Magento\Framework\App\ResourceConnection; +use Magento\Framework\EntityManager\MetadataPool; +use Magento\GroupedProduct\Model\Product\Type\Grouped; +use Magento\GroupedProduct\Model\ResourceModel\Product\Link; + +/** + * Change stock status of grouped product by child product id + */ +class ChangeParentStockStatus +{ + /** + * @var Grouped + */ + private $groupedType; + + /** + * @var StockItemRepositoryInterface + */ + private $stockItemRepository; + + /** + * @var StockConfigurationInterface + */ + private $stockConfiguration; + + /** + * @var StockItemCriteriaInterfaceFactory + */ + private $criteriaInterfaceFactory; + + /** + * @var ResourceConnection + */ + private $resource; + + /** + * Product metadata pool + * + * @var MetadataPool + */ + private $metadataPool; + + /** + * @param Grouped $groupedType + * @param StockItemCriteriaInterfaceFactory $criteriaInterfaceFactory + * @param StockItemRepositoryInterface $stockItemRepository + * @param StockConfigurationInterface $stockConfiguration + * @param ResourceConnection $resource + * @param MetadataPool $metadataPool + */ + public function __construct( + Grouped $groupedType, + StockItemCriteriaInterfaceFactory $criteriaInterfaceFactory, + StockItemRepositoryInterface $stockItemRepository, + StockConfigurationInterface $stockConfiguration, + ResourceConnection $resource, + MetadataPool $metadataPool + ) { + $this->groupedType = $groupedType; + $this->criteriaInterfaceFactory = $criteriaInterfaceFactory; + $this->stockConfiguration = $stockConfiguration; + $this->stockItemRepository = $stockItemRepository; + $this->resource = $resource; + $this->metadataPool = $metadataPool; + } + + /** + * Change stock item for parent product depending on children stock items + * + * @param int $productId + * @return void + */ + public function execute(int $productId): void + { + $parentIds = $this->getParentEntityIdsByChild($productId); + foreach ($parentIds as $productId) { + $this->changeParentStockStatus((int)$productId); + } + } + + /** + * Change stock status of grouped product + * + * @param int $productId + * @return void + */ + private function changeParentStockStatus(int $productId): void + { + $criteria = $this->criteriaInterfaceFactory->create(); + $criteria->setScopeFilter($this->stockConfiguration->getDefaultScopeId()); + $criteria->setProductsFilter($productId); + $stockItemCollection = $this->stockItemRepository->getList($criteria); + $allItems = $stockItemCollection->getItems(); + if (empty($allItems)) { + return; + } + $parentStockItem = array_shift($allItems); + $groupedChildrenIds = $this->groupedType->getChildrenIds($productId); + $criteria->setProductsFilter($groupedChildrenIds); + $stockItemCollection = $this->stockItemRepository->getList($criteria); + $allItems = $stockItemCollection->getItems(); + + $groupedChildrenIsInStock = false; + + foreach ($allItems as $childItem) { + if ($childItem->getIsInStock() === true) { + $groupedChildrenIsInStock = true; + break; + } + } + + if ($this->isNeedToUpdateParent($parentStockItem, $groupedChildrenIsInStock)) { + $parentStockItem->setIsInStock($groupedChildrenIsInStock); + $parentStockItem->setStockStatusChangedAuto(1); + $this->stockItemRepository->save($parentStockItem); + } + } + + /** + * Check is parent item should be updated + * + * @param StockItemInterface $parentStockItem + * @param bool $childrenIsInStock + * @return bool + */ + private function isNeedToUpdateParent(StockItemInterface $parentStockItem, bool $childrenIsInStock): bool + { + return $parentStockItem->getIsInStock() !== $childrenIsInStock && + ($childrenIsInStock === false || $parentStockItem->getStockStatusChangedAuto()); + } + + /** + * Retrieve parent ids array by child id + * + * @param int $childId + * @return array + */ + private function getParentEntityIdsByChild(int $childId): array + { + $select = $this->resource->getConnection() + ->select() + ->from(['l' => $this->resource->getTableName('catalog_product_link')], []) + ->join( + ['e' => $this->resource->getTableName('catalog_product_entity')], + 'e.' . + $this->metadataPool->getMetadata(ProductInterface::class)->getLinkField() . ' = l.product_id', + ['e.entity_id'] + ) + ->where('l.linked_product_id = ?', $childId) + ->where( + 'link_type_id = ?', + Link::LINK_TYPE_GROUPED + ); + + return $this->resource->getConnection()->fetchCol($select); + } +} diff --git a/app/code/Magento/GroupedProduct/Model/Inventory/ParentItemProcessor.php b/app/code/Magento/GroupedProduct/Model/Inventory/ParentItemProcessor.php new file mode 100644 index 0000000000000..2d5113edd082e --- /dev/null +++ b/app/code/Magento/GroupedProduct/Model/Inventory/ParentItemProcessor.php @@ -0,0 +1,42 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\GroupedProduct\Model\Inventory; + +use Magento\Catalog\Api\Data\ProductInterface as Product; +use Magento\CatalogInventory\Observer\ParentItemProcessorInterface; + +/** + * Process parent stock item for grouped product + */ +class ParentItemProcessor implements ParentItemProcessorInterface +{ + /** + * @var ChangeParentStockStatus + */ + private $changeParentStockStatus; + + /** + * @param ChangeParentStockStatus $changeParentStockStatus + */ + public function __construct( + ChangeParentStockStatus $changeParentStockStatus + ) { + $this->changeParentStockStatus = $changeParentStockStatus; + } + + /** + * Process parent products + * + * @param Product $product + * @return void + */ + public function process(Product $product) + { + $this->changeParentStockStatus->execute((int)$product->getId()); + } +} diff --git a/app/code/Magento/GroupedProduct/Model/Product/Type/Grouped.php b/app/code/Magento/GroupedProduct/Model/Product/Type/Grouped.php index 8eac8d0b0e163..b56e8657df722 100644 --- a/app/code/Magento/GroupedProduct/Model/Product/Type/Grouped.php +++ b/app/code/Magento/GroupedProduct/Model/Product/Type/Grouped.php @@ -8,6 +8,7 @@ namespace Magento\GroupedProduct\Model\Product\Type; use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Framework\File\UploaderFactory; /** * Grouped product type model @@ -102,6 +103,7 @@ class Grouped extends \Magento\Catalog\Model\Product\Type\AbstractType * @param \Magento\Framework\App\State $appState * @param \Magento\Msrp\Helper\Data $msrpData * @param \Magento\Framework\Serialize\Serializer\Json|null $serializer + * @param UploaderFactory|null $uploaderFactory * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -119,7 +121,8 @@ public function __construct( \Magento\Catalog\Model\Product\Attribute\Source\Status $catalogProductStatus, \Magento\Framework\App\State $appState, \Magento\Msrp\Helper\Data $msrpData, - \Magento\Framework\Serialize\Serializer\Json $serializer = null + \Magento\Framework\Serialize\Serializer\Json $serializer = null, + UploaderFactory $uploaderFactory = null ) { $this->productLinks = $catalogProductLink; $this->_storeManager = $storeManager; @@ -136,7 +139,8 @@ public function __construct( $coreRegistry, $logger, $productRepository, - $serializer + $serializer, + $uploaderFactory ); } diff --git a/app/code/Magento/GroupedProduct/Test/Mftf/Test/UpdateStockStatusGroupedProductTest.xml b/app/code/Magento/GroupedProduct/Test/Mftf/Test/UpdateStockStatusGroupedProductTest.xml new file mode 100644 index 0000000000000..f39e18373893d --- /dev/null +++ b/app/code/Magento/GroupedProduct/Test/Mftf/Test/UpdateStockStatusGroupedProductTest.xml @@ -0,0 +1,63 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="UpdateStockStatusGroupedProductTest"> + <annotations> + <features value="GroupedProduct"/> + <stories value="Create/Edit grouped product in Admin"/> + <title value="Stock status of grouped product after changing quantity of child product should be changed"/> + <description value="Change stock of grouped product after changing quantity of child product"/> + <severity value="MAJOR"/> + <testCaseId value="MC-38057"/> + <useCaseId value="MC-37718"/> + <group value="GroupedProduct"/> + </annotations> + <before> + <!--Create simple and grouped product--> + <createData entity="SimpleProduct2" stepKey="createFirstSimpleProduct"/> + <createData entity="ApiGroupedProduct" stepKey="createGroupedProduct"/> + <createData entity="OneSimpleProductLink" stepKey="addProductOne"> + <requiredEntity createDataKey="createGroupedProduct"/> + <requiredEntity createDataKey="createFirstSimpleProduct"/> + </createData> + <magentoCron groups="index" stepKey="runCronIndex"/> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + </before> + <after> + <!--Delete created data--> + <deleteData createDataKey="createFirstSimpleProduct" stepKey="deleteFirstSimpleProduct"/> + <deleteData createDataKey="createGroupedProduct" stepKey="deleteGroupedProduct"/> + <!--Admin logout--> + <actionGroup ref="AdminLogoutActionGroup" stepKey="adminLogout"/> + </after> + <!--1.Open product grid page and choose "Update attributes" and set product stock status to "Out of Stock"--> + <actionGroup ref="AdminMassUpdateProductQtyAndStockStatusActionGroup" stepKey="setProductToOutOfStock"> + <argument name="attributes" value="UpdateAttributeQtyAndStockToOutOfStock"/> + <argument name="product" value="$$createFirstSimpleProduct$$"/> + </actionGroup> + <!--2.Run cron for updating stock status of parent product--> + <magentoCron groups="index" stepKey="runCronIndex"/> + <!--3.Check stock status of grouped product. Stock status should be "Out of Stock"--> + <actionGroup ref="AssertAdminProductStockStatusActionGroup" stepKey="checkProductOutOfStock"> + <argument name="productId" value="$$createGroupedProduct.id$$"/> + <argument name="stockStatus" value="Out of Stock"/> + </actionGroup> + <!--4.Open product grid page choose "Update attributes" and set product stock status to "In Stock"--> + <actionGroup ref="AdminMassUpdateProductQtyAndStockStatusActionGroup" stepKey="returnProductToInStock"> + <argument name="attributes" value="UpdateAttributeQtyAndStockToInStock"/> + <argument name="product" value="$$createFirstSimpleProduct$$"/> + </actionGroup> + <!--5.Check stock status of grouped product. Stock status should be "In Stock"--> + <actionGroup ref="AssertAdminProductStockStatusActionGroup" stepKey="checkProductInStock"> + <argument name="productId" value="$$createGroupedProduct.id$$"/> + <argument name="stockStatus" value="In Stock"/> + </actionGroup> + </test> +</tests> diff --git a/app/code/Magento/GroupedProduct/etc/di.xml b/app/code/Magento/GroupedProduct/etc/di.xml index 43678d0ad7a82..924d2d1fc9669 100644 --- a/app/code/Magento/GroupedProduct/etc/di.xml +++ b/app/code/Magento/GroupedProduct/etc/di.xml @@ -105,4 +105,18 @@ </argument> </arguments> </type> + <type name="Magento\CatalogInventory\Observer\SaveInventoryDataObserver"> + <arguments> + <argument name="parentItemProcessorPool" xsi:type="array"> + <item name="grouped" xsi:type="object"> Magento\GroupedProduct\Model\Inventory\ParentItemProcessor</item> + </argument> + </arguments> + </type> + <type name="Magento\CatalogInventory\Plugin\MassUpdateProductAttribute"> + <arguments> + <argument name="parentItemProcessorPool" xsi:type="array"> + <item name="grouped" xsi:type="object"> Magento\GroupedProduct\Model\Inventory\ParentItemProcessor</item> + </argument> + </arguments> + </type> </config> diff --git a/app/code/Magento/GroupedProduct/view/adminhtml/ui_component/grouped_product_listing.xml b/app/code/Magento/GroupedProduct/view/adminhtml/ui_component/grouped_product_listing.xml index 831dc5a765dfb..becd7ca8079da 100644 --- a/app/code/Magento/GroupedProduct/view/adminhtml/ui_component/grouped_product_listing.xml +++ b/app/code/Magento/GroupedProduct/view/adminhtml/ui_component/grouped_product_listing.xml @@ -58,7 +58,7 @@ <label translate="true">Status</label> <dataScope>status</dataScope> <imports> - <link name="visible">componentType = column, index = ${ $.index }:visible</link> + <link name="visible">ns = ${ $.ns }, index = ${ $.index }:visible</link> </imports> </settings> </filterSelect> diff --git a/app/code/Magento/ImportExport/Block/Adminhtml/Import/Edit/Form.php b/app/code/Magento/ImportExport/Block/Adminhtml/Import/Edit/Form.php index 55992c92226af..87cd4cf346288 100644 --- a/app/code/Magento/ImportExport/Block/Adminhtml/Import/Edit/Form.php +++ b/app/code/Magento/ImportExport/Block/Adminhtml/Import/Edit/Form.php @@ -226,6 +226,7 @@ protected function _prepareForm() 'title' => __('Select File to Import'), 'required' => true, 'class' => 'input-file', + 'onchange' => 'varienImport.refreshLoadedFileLastModified(this);', 'note' => __( 'File must be saved in UTF-8 encoding for proper import' ), @@ -282,7 +283,7 @@ protected function getDownloadSampleFileHtml() private function getImportBehaviorTooltip() { $html = '<div class="admin__field-tooltip tooltip"> - <a class="admin__field-tooltip-action action-help" target="_blank" title="What is this?" + <a class="admin__field-tooltip-action action-help" target="_blank" title="What is this?" href="https://docs.magento.com/m2/ce/user_guide/system/data-import.html"><span>' . __('What is this?') . '</span></a></div>'; diff --git a/app/code/Magento/ImportExport/Block/Adminhtml/Import/Frame/Result.php b/app/code/Magento/ImportExport/Block/Adminhtml/Import/Frame/Result.php index acca62b4cb72e..0b9857edc53eb 100644 --- a/app/code/Magento/ImportExport/Block/Adminhtml/Import/Frame/Result.php +++ b/app/code/Magento/ImportExport/Block/Adminhtml/Import/Frame/Result.php @@ -102,7 +102,7 @@ public function addError($message) $this->addError($row); } } else { - $this->_messages['error'][] = $message; + $this->_messages['error'][] = $this->escapeHtml($message); } return $this; } @@ -140,7 +140,8 @@ public function addSuccess($message, $appendImportButton = false) $this->addSuccess($row); } } else { - $this->_messages['success'][] = $message . ($appendImportButton ? $this->getImportButtonHtml() : ''); + $escapedMessage = $this->escapeHtml($message); + $this->_messages['success'][] = $escapedMessage . ($appendImportButton ? $this->getImportButtonHtml() : ''); } return $this; } diff --git a/app/code/Magento/ImportExport/Controller/Adminhtml/Export/File/Download.php b/app/code/Magento/ImportExport/Controller/Adminhtml/Export/File/Download.php index 4107e19860328..26ee257c42ff2 100644 --- a/app/code/Magento/ImportExport/Controller/Adminhtml/Export/File/Download.php +++ b/app/code/Magento/ImportExport/Controller/Adminhtml/Export/File/Download.php @@ -67,13 +67,12 @@ public function execute() return $resultRedirect; } try { - $path = 'export/' . $fileName; - $directory = $this->filesystem->getDirectoryRead(DirectoryList::VAR_DIR); - if ($directory->isFile($path)) { + $directory = $this->filesystem->getDirectoryRead(DirectoryList::VAR_EXPORT); + if ($directory->isFile($fileName)) { return $this->fileFactory->create( - $path, - $directory->readFile($path), - DirectoryList::VAR_DIR + $fileName, + $directory->readFile($fileName), + DirectoryList::VAR_EXPORT ); } $this->messageManager->addErrorMessage(__('%1 is not a valid file', $fileName)); diff --git a/app/code/Magento/ImportExport/Controller/Adminhtml/ImportResult.php b/app/code/Magento/ImportExport/Controller/Adminhtml/ImportResult.php index 47210dd9805e5..6da90efa4592c 100644 --- a/app/code/Magento/ImportExport/Controller/Adminhtml/ImportResult.php +++ b/app/code/Magento/ImportExport/Controller/Adminhtml/ImportResult.php @@ -56,6 +56,8 @@ public function __construct( } /** + * Add Error Messages for Import + * * @param \Magento\Framework\View\Element\AbstractBlock $resultBlock * @param ProcessingErrorAggregatorInterface $errorAggregator * @return $this @@ -68,7 +70,7 @@ protected function addErrorMessages( $message = ''; $counter = 0; foreach ($this->getErrorMessages($errorAggregator) as $error) { - $message .= ++$counter . '. ' . $error . '<br>'; + $message .= (++$counter) . '. ' . $error . '<br>'; if ($counter >= self::LIMIT_ERRORS_MESSAGE) { break; } @@ -88,7 +90,7 @@ protected function addErrorMessages( . '<a href="' . $this->createDownloadUrlImportHistoryFile($this->createErrorReport($errorAggregator)) . '">' . __('Download full report') . '</a><br>' - . '<div class="import-error-list">' . $message . '</div></div>' + . '<div class="import-error-list">' . $resultBlock->escapeHtml($message) . '</div></div>' ); } catch (\Exception $e) { foreach ($this->getErrorMessages($errorAggregator) as $errorMessage) { @@ -101,6 +103,8 @@ protected function addErrorMessages( } /** + * Get all Error Messages from Import Results + * * @param \Magento\ImportExport\Model\Import\ErrorProcessing\ProcessingErrorAggregatorInterface $errorAggregator * @return array */ @@ -115,6 +119,8 @@ protected function getErrorMessages(ProcessingErrorAggregatorInterface $errorAgg } /** + * Get System Generated Exception + * * @param ProcessingErrorAggregatorInterface $errorAggregator * @return \Magento\ImportExport\Model\Import\ErrorProcessing\ProcessingError[] */ @@ -124,6 +130,8 @@ protected function getSystemExceptions(ProcessingErrorAggregatorInterface $error } /** + * Generate Error Report File + * * @param ProcessingErrorAggregatorInterface $errorAggregator * @return string */ @@ -141,6 +149,8 @@ protected function createErrorReport(ProcessingErrorAggregatorInterface $errorAg } /** + * Get Import History Url + * * @param string $fileName * @return string */ diff --git a/app/code/Magento/ImportExport/Model/Export/Consumer.php b/app/code/Magento/ImportExport/Model/Export/Consumer.php index 27019780269c4..955f96fe3de2e 100644 --- a/app/code/Magento/ImportExport/Model/Export/Consumer.php +++ b/app/code/Magento/ImportExport/Model/Export/Consumer.php @@ -70,8 +70,8 @@ public function process(ExportInfoInterface $exportInfo) try { $data = $this->exportManager->export($exportInfo); $fileName = $exportInfo->getFileName(); - $directory = $this->filesystem->getDirectoryWrite(DirectoryList::VAR_DIR); - $directory->writeFile('export/' . $fileName, $data); + $directory = $this->filesystem->getDirectoryWrite(DirectoryList::VAR_EXPORT); + $directory->writeFile($fileName, $data); $this->notifier->addMajor( __('Your export file is ready'), diff --git a/app/code/Magento/ImportExport/Model/Import/ErrorProcessing/ProcessingErrorAggregator.php b/app/code/Magento/ImportExport/Model/Import/ErrorProcessing/ProcessingErrorAggregator.php index 5ea6227231543..2f8bfdcf70a5e 100644 --- a/app/code/Magento/ImportExport/Model/Import/ErrorProcessing/ProcessingErrorAggregator.php +++ b/app/code/Magento/ImportExport/Model/Import/ErrorProcessing/ProcessingErrorAggregator.php @@ -242,7 +242,7 @@ public function getAllErrors() } $errors = array_values($this->items['rows']); - return array_merge(...$errors); + return array_merge([], ...$errors); } /** @@ -253,14 +253,14 @@ public function getAllErrors() */ public function getErrorsByCode(array $codes) { - $result = [[]]; + $result = []; foreach ($codes as $code) { if (isset($this->items['codes'][$code])) { $result[] = $this->items['codes'][$code]; } } - return array_merge(...$result); + return array_merge([], ...$result); } /** diff --git a/app/code/Magento/ImportExport/Test/Unit/Block/Adminhtml/Import/Frame/ResultTest.php b/app/code/Magento/ImportExport/Test/Unit/Block/Adminhtml/Import/Frame/ResultTest.php new file mode 100644 index 0000000000000..218babc2b48f0 --- /dev/null +++ b/app/code/Magento/ImportExport/Test/Unit/Block/Adminhtml/Import/Frame/ResultTest.php @@ -0,0 +1,110 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\ImportExport\Test\Unit\Block\Adminhtml\Import\Frame; + +use Magento\Framework\Escaper; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Magento\ImportExport\Block\Adminhtml\Import\Frame\Result; +use Magento\Backend\Block\Template\Context; +use Magento\Framework\Json\EncoderInterface; + +/** + * Unit test for Magento\ImportExport\Block\Adminhtml\Import\Frame\Result + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ +class ResultTest extends TestCase +{ + /** + * @var Result + */ + private $result; + + /** + * @var EncoderInterface|MockObject + */ + private $encoderMock; + + /** + * @var Context|MockObject + */ + private $contextMock; + + /** + * @var Escaper|MockObject + */ + private $escaperMock; + + /** + * Initialize Class Dependencies + * + * @inheritDoc + */ + protected function setUp(): void + { + $this->contextMock = $this->createMock(Context::class); + $this->encoderMock = $this->getMockBuilder(EncoderInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + + $this->escaperMock = $this->createPartialMock(Escaper::class, ['escapeHtml']); + $this->contextMock->expects($this->once())->method('getEscaper')->willReturn($this->escaperMock); + $this->result = new Result( + $this->contextMock, + $this->encoderMock + ); + } + + /** + * Test error message + * + * @return void + */ + public function testAddError(): void + { + $errors = ['first error', 'second error','third error']; + $this->escaperMock + ->expects($this->exactly(count($errors))) + ->method('escapeHtml') + ->willReturnOnConsecutiveCalls(...array_values($errors)); + + $this->result->addError($errors); + $this->assertEquals(count($errors), count($this->result->getMessages()['error'])); + } + + /** + * Test success message + * + * @return void + */ + public function testAddSuccess(): void + { + $success = ['first message', 'second message','third message']; + $this->escaperMock + ->expects($this->exactly(count($success))) + ->method('escapeHtml') + ->willReturnOnConsecutiveCalls(...array_values($success)); + + $this->result->addSuccess($success); + $this->assertEquals(count($success), count($this->result->getMessages()['success'])); + } + + /** + * Test Add Notice message + * + * @return void + */ + public function testAddNotice(): void + { + $notice = ['notice 1', 'notice 2','notice 3']; + + $this->result->addNotice($notice); + $this->assertEquals(count($notice), count($this->result->getMessages()['notice'])); + } +} diff --git a/app/code/Magento/ImportExport/Ui/DataProvider/ExportFileDataProvider.php b/app/code/Magento/ImportExport/Ui/DataProvider/ExportFileDataProvider.php index 2b5af6ab5ca8d..71614bafd138e 100644 --- a/app/code/Magento/ImportExport/Ui/DataProvider/ExportFileDataProvider.php +++ b/app/code/Magento/ImportExport/Ui/DataProvider/ExportFileDataProvider.php @@ -13,6 +13,7 @@ use Magento\Framework\App\Filesystem\DirectoryList; use Magento\Framework\Filesystem; use Magento\Framework\Filesystem\Io\File; +use Magento\Framework\Filesystem\Directory\WriteInterface; /** * Data provider for export grid. @@ -29,6 +30,11 @@ class ExportFileDataProvider extends DataProvider */ private $file; + /** + * @var WriteInterface + */ + private $directory; + /** * @var Filesystem */ @@ -48,6 +54,7 @@ class ExportFileDataProvider extends DataProvider * @param array $meta * @param array $data * @SuppressWarnings(PHPMD.ExcessiveParameterList) + * @throws \Magento\Framework\Exception\FileSystemException */ public function __construct( string $name, @@ -78,6 +85,7 @@ public function __construct( ); $this->fileIO = $fileIO ?: ObjectManager::getInstance()->get(File::class); + $this->directory = $filesystem->getDirectoryWrite(DirectoryList::VAR_EXPORT); } /** @@ -88,13 +96,12 @@ public function __construct( */ public function getData() { - $directory = $this->fileSystem->getDirectoryRead(DirectoryList::VAR_DIR); $emptyResponse = ['items' => [], 'totalRecords' => 0]; - if (!$this->file->isExists($directory->getAbsolutePath() . 'export/')) { + if (!$this->directory->isExist($this->directory->getAbsolutePath())) { return $emptyResponse; } - $files = $this->getExportFiles($directory->getAbsolutePath() . 'export/'); + $files = $this->getExportFiles($this->directory->getAbsolutePath()); if (empty($files)) { return $emptyResponse; } @@ -121,12 +128,15 @@ public function getData() */ private function getPathToExportFile($file): string { - $directory = $this->fileSystem->getDirectoryRead(DirectoryList::VAR_DIR); + $directory = $this->fileSystem->getDirectoryRead(DirectoryList::VAR_EXPORT); $delimiter = '/'; $cutPath = explode( $delimiter, - $directory->getAbsolutePath() . 'export' + $directory->getAbsolutePath() ); + // remove . from dirname if file path is not absolute in the file system but just a file name + $file['dirname'] = $file['dirname'] !== '.' ? $file['dirname'] : ''; + $filePath = explode( $delimiter, $file['dirname'] @@ -148,14 +158,14 @@ private function getPathToExportFile($file): string private function getExportFiles(string $directoryPath): array { $sortedFiles = []; - $files = $this->file->readDirectoryRecursively($directoryPath); + $files = $this->directory->getDriver()->readDirectoryRecursively($directoryPath); if (empty($files)) { return []; } foreach ($files as $filePath) { - if ($this->file->isFile($filePath)) { - //phpcs:ignore Magento2.Functions.DiscouragedFunction - $sortedFiles[filemtime($filePath)] = $filePath; + if ($this->directory->isFile($filePath)) { + $fileModificationTime = $this->directory->stat($filePath)['mtime']; + $sortedFiles[$fileModificationTime] = $filePath; } } //sort array elements using key value diff --git a/app/code/Magento/ImportExport/etc/adminhtml/di.xml b/app/code/Magento/ImportExport/etc/adminhtml/di.xml index 04ee726349123..7b124957d5f57 100644 --- a/app/code/Magento/ImportExport/etc/adminhtml/di.xml +++ b/app/code/Magento/ImportExport/etc/adminhtml/di.xml @@ -16,11 +16,13 @@ <argument name="serializer" xsi:type="object">Magento\Framework\Serialize\Serializer\Json</argument> </arguments> </type> + <!-- deprecated as file argument is not used anymore. Can be deleted in major release to avoid BIC.--> <type name="Magento\ImportExport\Controller\Adminhtml\Export\File\Delete"> <arguments> <argument name="file" xsi:type="object">Magento\Framework\Filesystem\Driver\File</argument> </arguments> </type> + <!-- deprecated as file argument is not used anymore. Can be deleted in major release to avoid BIC.--> <type name="Magento\ImportExport\Ui\DataProvider\ExportFileDataProvider"> <arguments> <argument name="file" xsi:type="object">Magento\Framework\Filesystem\Driver\File</argument> diff --git a/app/code/Magento/ImportExport/i18n/en_US.csv b/app/code/Magento/ImportExport/i18n/en_US.csv index a4943fe72826f..a91a76612fd9f 100644 --- a/app/code/Magento/ImportExport/i18n/en_US.csv +++ b/app/code/Magento/ImportExport/i18n/en_US.csv @@ -127,3 +127,4 @@ Summary,Summary "File %1 deleted","File %1 deleted" "Please provide valid export file name","Please provide valid export file name" "%1 is not a valid file","%1 is not a valid file" +"Content of uploaded file was changed, please re-upload the file","Content of uploaded file was changed, please re-upload the file" diff --git a/app/code/Magento/ImportExport/view/adminhtml/templates/import/form/before.phtml b/app/code/Magento/ImportExport/view/adminhtml/templates/import/form/before.phtml index 69779baba381d..d512ce8182ede 100644 --- a/app/code/Magento/ImportExport/view/adminhtml/templates/import/form/before.phtml +++ b/app/code/Magento/ImportExport/view/adminhtml/templates/import/form/before.phtml @@ -7,6 +7,10 @@ <?php /** @var $block \Magento\ImportExport\Block\Adminhtml\Import\Edit\Before */ /** @var \Magento\Framework\View\Helper\SecureHtmlRenderer $secureRenderer */ +$fieldNameSourceFile = \Magento\ImportExport\Model\Import::FIELD_NAME_SOURCE_FILE; +$uploaderErrorMessage = $block->escapeHtml( + __('Content of uploaded file was changed, please re-upload the file') +); ?> <?php $scriptString = <<<script @@ -49,6 +53,12 @@ require([ */ sampleFilesBaseUrl: '{$block->escapeJs($block->getUrl('*/*/download/', ['filename' => 'entity-name']))}', + /** + * Loaded file last modified + * @type {int|null} + */ + loadedFileLastModified: null, + /** * Reset selected index * @param {string} elementId @@ -162,11 +172,50 @@ require([ } }, + /** + * Refresh loaded file last modified + */ + refreshLoadedFileLastModified: function(e) { + if (jQuery(e)[0].files.length > 0) { + this.loadedFileLastModified = jQuery(e)[0].files[0].lastModified; + } else { + this.loadedFileLastModified = null; + } + }, + /** * Post form data to dynamic iframe. * @param {string} newActionUrl OPTIONAL Change form action to this if specified */ postToFrame: function(newActionUrl) { + var fileUploader = document.getElementById('{$fieldNameSourceFile}'); + + if (fileUploader.files.length > 0) { + var file = fileUploader.files[0], + ifrElName = this.ifrElemName, + reader = new FileReader(); + + reader.readAsText(file, "UTF-8"); + + reader.onerror = function () { + jQuery('body').loader('hide'); + alert({ + content: '{$uploaderErrorMessage}' + }); + fileUploader.value = null; + jQuery('iframe#' + ifrElName).remove(); + return; + } + + if (file.lastModified !== this.loadedFileLastModified) { + alert({ + content: '{$uploaderErrorMessage}' + }); + fileUploader.value = null; + return; + } + } + if (!jQuery('[name="' + this.ifrElemName + '"]').length) { jQuery('body').append('<iframe name="' + this.ifrElemName + '" id="' + this.ifrElemName + '"/>'); jQuery('iframe#' + this.ifrElemName).attr('display', 'none'); diff --git a/app/code/Magento/Indexer/Console/Command/IndexerReindexCommand.php b/app/code/Magento/Indexer/Console/Command/IndexerReindexCommand.php index 775f585519947..d6e6c2f1fc2cb 100644 --- a/app/code/Magento/Indexer/Console/Command/IndexerReindexCommand.php +++ b/app/code/Magento/Indexer/Console/Command/IndexerReindexCommand.php @@ -6,6 +6,7 @@ namespace Magento\Indexer\Console\Command; +use Magento\Framework\App\ObjectManager; use Magento\Framework\App\ObjectManagerFactory; use Magento\Framework\Console\Cli; use Magento\Framework\Exception\LocalizedException; @@ -14,11 +15,13 @@ use Magento\Framework\Indexer\IndexerInterface; use Magento\Framework\Indexer\IndexerRegistry; use Magento\Framework\Indexer\StateInterface; +use Magento\Indexer\Model\Processor\MakeSharedIndexValid; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; /** * Command to run indexers + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class IndexerReindexCommand extends AbstractIndexerManageCommand { @@ -42,18 +45,26 @@ class IndexerReindexCommand extends AbstractIndexerManageCommand */ private $dependencyInfoProvider; + /** + * @var MakeSharedIndexValid|null + */ + private $makeSharedValid; + /** * @param ObjectManagerFactory $objectManagerFactory * @param IndexerRegistry|null $indexerRegistry * @param DependencyInfoProvider|null $dependencyInfoProvider + * @param MakeSharedIndexValid|null $makeSharedValid */ public function __construct( ObjectManagerFactory $objectManagerFactory, IndexerRegistry $indexerRegistry = null, - DependencyInfoProvider $dependencyInfoProvider = null + DependencyInfoProvider $dependencyInfoProvider = null, + MakeSharedIndexValid $makeSharedValid = null ) { $this->indexerRegistry = $indexerRegistry; $this->dependencyInfoProvider = $dependencyInfoProvider; + $this->makeSharedValid = $makeSharedValid; parent::__construct($objectManagerFactory); } @@ -74,7 +85,7 @@ protected function configure() */ protected function execute(InputInterface $input, OutputInterface $output) { - $returnValue = Cli::RETURN_FAILURE; + $returnValue = Cli::RETURN_SUCCESS; foreach ($this->getIndexers($input) as $indexer) { try { $this->validateIndexerStatus($indexer); @@ -88,8 +99,8 @@ protected function execute(InputInterface $input, OutputInterface $output) // Skip indexers having shared index that was already complete if (!in_array($sharedIndex, $this->sharedIndexesComplete)) { $indexer->reindexAll(); - if ($sharedIndex) { - $this->validateSharedIndex($sharedIndex); + if (!empty($sharedIndex) && $this->getMakeSharedValid()->execute($sharedIndex)) { + $this->sharedIndexesComplete[] = $sharedIndex; } } $resultTime = microtime(true) - $startTime; @@ -97,14 +108,15 @@ protected function execute(InputInterface $input, OutputInterface $output) $output->writeln( __('has been rebuilt successfully in %time', ['time' => gmdate('H:i:s', $resultTime)]) ); - $returnValue = Cli::RETURN_SUCCESS; } catch (LocalizedException $e) { $output->writeln(__('exception: %message', ['message' => $e->getMessage()])); + $returnValue = Cli::RETURN_FAILURE; } catch (\Exception $e) { $output->writeln('process unknown error:'); $output->writeln($e->getMessage()); $output->writeln($e->getTraceAsString(), OutputInterface::VERBOSITY_DEBUG); + $returnValue = Cli::RETURN_FAILURE; } } @@ -124,16 +136,16 @@ protected function getIndexers(InputInterface $input) return $indexers; } - $relatedIndexers = [[]]; - $dependentIndexers = [[]]; + $relatedIndexers = []; + $dependentIndexers = []; foreach ($indexers as $indexer) { $relatedIndexers[] = $this->getRelatedIndexerIds($indexer->getId()); $dependentIndexers[] = $this->getDependentIndexerIds($indexer->getId()); } - $relatedIndexers = $relatedIndexers ? array_unique(array_merge(...$relatedIndexers)) : []; - $dependentIndexers = $dependentIndexers ? array_merge(...$dependentIndexers) : []; + $relatedIndexers = array_unique(array_merge([], ...$relatedIndexers)); + $dependentIndexers = array_merge([], ...$dependentIndexers); $invalidRelatedIndexers = []; foreach ($relatedIndexers as $relatedIndexer) { @@ -164,12 +176,12 @@ protected function getIndexers(InputInterface $input) */ private function getRelatedIndexerIds(string $indexerId): array { - $relatedIndexerIds = [[]]; + $relatedIndexerIds = []; foreach ($this->getDependencyInfoProvider()->getIndexerIdsToRunBefore($indexerId) as $relatedIndexerId) { $relatedIndexerIds[] = [$relatedIndexerId]; $relatedIndexerIds[] = $this->getRelatedIndexerIds($relatedIndexerId); } - $relatedIndexerIds = $relatedIndexerIds ? array_unique(array_merge(...$relatedIndexerIds)) : []; + $relatedIndexerIds = array_unique(array_merge([], ...$relatedIndexerIds)); return $relatedIndexerIds; } @@ -182,7 +194,7 @@ private function getRelatedIndexerIds(string $indexerId): array */ private function getDependentIndexerIds(string $indexerId): array { - $dependentIndexerIds = [[]]; + $dependentIndexerIds = []; foreach (array_keys($this->getConfig()->getIndexers()) as $id) { $dependencies = $this->getDependencyInfoProvider()->getIndexerIdsToRunBefore($id); if (array_search($indexerId, $dependencies) !== false) { @@ -190,7 +202,7 @@ private function getDependentIndexerIds(string $indexerId): array $dependentIndexerIds[] = $this->getDependentIndexerIds($id); } } - $dependentIndexerIds = $dependentIndexerIds ? array_unique(array_merge(...$dependentIndexerIds)) : []; + $dependentIndexerIds = array_unique(array_merge([], ...$dependentIndexerIds)); return $dependentIndexerIds; } @@ -214,54 +226,6 @@ private function validateIndexerStatus(IndexerInterface $indexer) } } - /** - * Get indexer ids that have common shared index - * - * @param string $sharedIndex - * @return array - */ - private function getIndexerIdsBySharedIndex($sharedIndex) - { - $indexers = $this->getConfig()->getIndexers(); - $result = []; - foreach ($indexers as $indexerConfig) { - if ($indexerConfig['shared_index'] == $sharedIndex) { - $result[] = $indexerConfig['indexer_id']; - } - } - return $result; - } - - /** - * Validate indexers by shared index ID - * - * @param string $sharedIndex - * @return $this - */ - private function validateSharedIndex($sharedIndex) - { - if (empty($sharedIndex)) { - throw new \InvalidArgumentException( - 'The sharedIndex is an invalid shared index identifier. Verify the identifier and try again.' - ); - } - $indexerIds = $this->getIndexerIdsBySharedIndex($sharedIndex); - if (empty($indexerIds)) { - return $this; - } - foreach ($indexerIds as $indexerId) { - $indexer = $this->getIndexerRegistry()->get($indexerId); - /** @var \Magento\Indexer\Model\Indexer\State $state */ - $state = $indexer->getState(); - $state->setStatus(StateInterface::STATUS_WORKING); - $state->save(); - $state->setStatus(StateInterface::STATUS_VALID); - $state->save(); - } - $this->sharedIndexesComplete[] = $sharedIndex; - return $this; - } - /** * Get config * @@ -277,30 +241,30 @@ private function getConfig() } /** - * Get indexer registry + * Get dependency info provider * - * @return IndexerRegistry + * @return DependencyInfoProvider * @deprecated 100.2.0 */ - private function getIndexerRegistry() + private function getDependencyInfoProvider() { - if (!$this->indexerRegistry) { - $this->indexerRegistry = $this->getObjectManager()->get(IndexerRegistry::class); + if (!$this->dependencyInfoProvider) { + $this->dependencyInfoProvider = $this->getObjectManager()->get(DependencyInfoProvider::class); } - return $this->indexerRegistry; + return $this->dependencyInfoProvider; } /** - * Get dependency info provider + * Get MakeSharedIndexValid processor. * - * @return DependencyInfoProvider - * @deprecated 100.2.0 + * @return MakeSharedIndexValid */ - private function getDependencyInfoProvider() + private function getMakeSharedValid(): MakeSharedIndexValid { - if (!$this->dependencyInfoProvider) { - $this->dependencyInfoProvider = $this->getObjectManager()->get(DependencyInfoProvider::class); + if (!$this->makeSharedValid) { + $this->makeSharedValid = $this->getObjectManager()->get(MakeSharedIndexValid::class); } - return $this->dependencyInfoProvider; + + return $this->makeSharedValid; } } diff --git a/app/code/Magento/Indexer/Model/Indexer.php b/app/code/Magento/Indexer/Model/Indexer.php index 2821a46f29416..ac8b9590e58f4 100644 --- a/app/code/Magento/Indexer/Model/Indexer.php +++ b/app/code/Magento/Indexer/Model/Indexer.php @@ -13,6 +13,7 @@ use Magento\Framework\Indexer\IndexStructureInterface; use Magento\Framework\Indexer\StateInterface; use Magento\Framework\Indexer\StructureFactory; +use Magento\Framework\Indexer\IndexerInterfaceFactory; /** * Indexer model. @@ -61,6 +62,16 @@ class Indexer extends \Magento\Framework\DataObject implements IndexerInterface */ protected $indexersFactory; + /** + * @var WorkingStateProvider + */ + private $workingStateProvider; + + /** + * @var IndexerInterfaceFactory + */ + private $indexerFactory; + /** * @param ConfigInterface $config * @param ActionFactory $actionFactory @@ -68,6 +79,8 @@ class Indexer extends \Magento\Framework\DataObject implements IndexerInterface * @param \Magento\Framework\Mview\ViewInterface $view * @param Indexer\StateFactory $stateFactory * @param Indexer\CollectionFactory $indexersFactory + * @param WorkingStateProvider $workingStateProvider + * @param IndexerInterfaceFactory $indexerFactory * @param array $data */ public function __construct( @@ -77,6 +90,8 @@ public function __construct( \Magento\Framework\Mview\ViewInterface $view, Indexer\StateFactory $stateFactory, Indexer\CollectionFactory $indexersFactory, + WorkingStateProvider $workingStateProvider, + IndexerInterfaceFactory $indexerFactory, array $data = [] ) { $this->config = $config; @@ -85,6 +100,8 @@ public function __construct( $this->view = $view; $this->stateFactory = $stateFactory; $this->indexersFactory = $indexersFactory; + $this->workingStateProvider = $workingStateProvider; + $this->indexerFactory = $indexerFactory; parent::__construct($data); } @@ -405,10 +422,20 @@ protected function getStructureInstance() */ public function reindexAll() { - if ($this->getState()->getStatus() != StateInterface::STATUS_WORKING) { + if (!$this->workingStateProvider->isWorking($this->getId())) { $state = $this->getState(); $state->setStatus(StateInterface::STATUS_WORKING); $state->save(); + + $sharedIndexers = []; + $indexerConfig = $this->config->getIndexer($this->getId()); + if ($indexerConfig['shared_index'] !== null) { + $sharedIndexers = $this->getSharedIndexers($indexerConfig['shared_index']); + } + if (!empty($sharedIndexers)) { + $this->suspendSharedViews($sharedIndexers); + } + if ($this->getView()->isEnabled()) { $this->getView()->suspend(); } @@ -416,16 +443,73 @@ public function reindexAll() $this->getActionInstance()->executeFull(); $state->setStatus(StateInterface::STATUS_VALID); $state->save(); + if (!empty($sharedIndexers)) { + $this->resumeSharedViews($sharedIndexers); + } $this->getView()->resume(); } catch (\Throwable $exception) { $state->setStatus(StateInterface::STATUS_INVALID); $state->save(); + if (!empty($sharedIndexers)) { + $this->resumeSharedViews($sharedIndexers); + } $this->getView()->resume(); throw $exception; } } } + /** + * Get indexer ids that uses same index + * + * @param string $sharedIndex + * @return array + */ + private function getSharedIndexers(string $sharedIndex) : array + { + $result = []; + foreach (array_keys($this->config->getIndexers()) as $indexerId) { + if ($indexerId === $this->getId()) { + continue; + } + $indexerConfig = $this->config->getIndexer($indexerId); + if ($indexerConfig['shared_index'] === $sharedIndex) { + $indexer = $this->indexerFactory->create(); + $indexer->load($indexerId); + $result[] = $indexer; + } + } + return $result; + } + + /** + * Suspend views of shared indexers + * + * @param array $sharedIndexers + * @return void + */ + private function suspendSharedViews(array $sharedIndexers) : void + { + foreach ($sharedIndexers as $indexer) { + if ($indexer->getView()->isEnabled()) { + $indexer->getView()->suspend(); + } + } + } + + /** + * Suspend views of shared indexers + * + * @param array $sharedIndexers + * @return void + */ + private function resumeSharedViews(array $sharedIndexers) : void + { + foreach ($sharedIndexers as $indexer) { + $indexer->getView()->resume(); + } + } + /** * Regenerate one row in index by ID * diff --git a/app/code/Magento/Indexer/Model/Indexer/CacheCleaner.php b/app/code/Magento/Indexer/Model/Indexer/CacheCleaner.php index c75a3541ba9c3..1cf7142e07cac 100644 --- a/app/code/Magento/Indexer/Model/Indexer/CacheCleaner.php +++ b/app/code/Magento/Indexer/Model/Indexer/CacheCleaner.php @@ -95,6 +95,7 @@ private function cleanCache() $identities = $this->cacheContext->getIdentities(); if (!empty($identities)) { $this->appCache->clean($identities); + $this->cacheContext->flush(); } } } diff --git a/app/code/Magento/Indexer/Model/Processor.php b/app/code/Magento/Indexer/Model/Processor.php index 534ea805bb8fc..78b8fa070b155 100644 --- a/app/code/Magento/Indexer/Model/Processor.php +++ b/app/code/Magento/Indexer/Model/Processor.php @@ -5,16 +5,23 @@ */ namespace Magento\Indexer\Model; +use Magento\Framework\App\ObjectManager; use Magento\Framework\Indexer\ConfigInterface; use Magento\Framework\Indexer\IndexerInterface; use Magento\Framework\Indexer\IndexerInterfaceFactory; -use Magento\Framework\Indexer\StateInterface; +use Magento\Framework\Mview\ProcessorInterface; +use Magento\Indexer\Model\Processor\MakeSharedIndexValid; /** * Indexer processor */ class Processor { + /** + * @var array + */ + private $sharedIndexesComplete = []; + /** * @var ConfigInterface */ @@ -31,26 +38,34 @@ class Processor protected $indexersFactory; /** - * @var \Magento\Framework\Mview\ProcessorInterface + * @var ProcessorInterface */ protected $mviewProcessor; + /** + * @var MakeSharedIndexValid + */ + protected $makeSharedValid; + /** * @param ConfigInterface $config * @param IndexerInterfaceFactory $indexerFactory * @param Indexer\CollectionFactory $indexersFactory - * @param \Magento\Framework\Mview\ProcessorInterface $mviewProcessor + * @param ProcessorInterface $mviewProcessor + * @param MakeSharedIndexValid|null $makeSharedValid */ public function __construct( ConfigInterface $config, IndexerInterfaceFactory $indexerFactory, Indexer\CollectionFactory $indexersFactory, - \Magento\Framework\Mview\ProcessorInterface $mviewProcessor + ProcessorInterface $mviewProcessor, + MakeSharedIndexValid $makeSharedValid = null ) { $this->config = $config; $this->indexerFactory = $indexerFactory; $this->indexersFactory = $indexersFactory; $this->mviewProcessor = $mviewProcessor; + $this->makeSharedValid = $makeSharedValid ?: ObjectManager::getInstance()->get(MakeSharedIndexValid::class); } /** @@ -60,27 +75,21 @@ public function __construct( */ public function reindexAllInvalid() { - $sharedIndexesComplete = []; foreach (array_keys($this->config->getIndexers()) as $indexerId) { /** @var Indexer $indexer */ $indexer = $this->indexerFactory->create(); $indexer->load($indexerId); $indexerConfig = $this->config->getIndexer($indexerId); + if ($indexer->isInvalid()) { // Skip indexers having shared index that was already complete $sharedIndex = $indexerConfig['shared_index'] ?? null; - if (!in_array($sharedIndex, $sharedIndexesComplete)) { + if (!in_array($sharedIndex, $this->sharedIndexesComplete)) { $indexer->reindexAll(); - } else { - /** @var \Magento\Indexer\Model\Indexer\State $state */ - $state = $indexer->getState(); - $state->setStatus(StateInterface::STATUS_WORKING); - $state->save(); - $state->setStatus(StateInterface::STATUS_VALID); - $state->save(); - } - if ($sharedIndex) { - $sharedIndexesComplete[] = $sharedIndex; + + if (!empty($sharedIndex) && $this->makeSharedValid->execute($sharedIndex)) { + $this->sharedIndexesComplete[] = $sharedIndex; + } } } } diff --git a/app/code/Magento/Indexer/Model/Processor/CleanCache.php b/app/code/Magento/Indexer/Model/Processor/CleanCache.php index 344fef6ef04ff..d7663171c8a65 100644 --- a/app/code/Magento/Indexer/Model/Processor/CleanCache.php +++ b/app/code/Magento/Indexer/Model/Processor/CleanCache.php @@ -5,8 +5,11 @@ */ namespace Magento\Indexer\Model\Processor; -use \Magento\Framework\App\CacheInterface; +use Magento\Framework\App\CacheInterface; +/** + * Clear cache after reindex + */ class CleanCache { /** @@ -46,9 +49,7 @@ public function __construct( public function afterUpdateMview(\Magento\Indexer\Model\Processor $subject) { $this->eventManager->dispatch('clean_cache_after_reindex', ['object' => $this->context]); - if (!empty($this->context->getIdentities())) { - $this->getCache()->clean($this->context->getIdentities()); - } + $this->cleanCache(); } /** @@ -61,9 +62,7 @@ public function afterUpdateMview(\Magento\Indexer\Model\Processor $subject) public function afterReindexAllInvalid(\Magento\Indexer\Model\Processor $subject) { $this->eventManager->dispatch('clean_cache_by_tags', ['object' => $this->context]); - if (!empty($this->context->getIdentities())) { - $this->getCache()->clean($this->context->getIdentities()); - } + $this->cleanCache(); } /** @@ -79,4 +78,18 @@ private function getCache() } return $this->cache; } + + /** + * Clean cache. + * + * @return void + */ + private function cleanCache(): void + { + $identities = $this->context->getIdentities(); + if (!empty($identities)) { + $this->getCache()->clean($identities); + $this->context->flush(); + } + } } diff --git a/app/code/Magento/Indexer/Model/Processor/MakeSharedIndexValid.php b/app/code/Magento/Indexer/Model/Processor/MakeSharedIndexValid.php new file mode 100644 index 0000000000000..338891589bf33 --- /dev/null +++ b/app/code/Magento/Indexer/Model/Processor/MakeSharedIndexValid.php @@ -0,0 +1,95 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +declare(strict_types=1); + +namespace Magento\Indexer\Model\Processor; + +use Magento\Framework\Indexer\ConfigInterface; +use Magento\Framework\Indexer\IndexerRegistry; +use Magento\Framework\Indexer\StateInterface; +use Magento\Indexer\Model\Indexer\State; + +/** + * Class processor makes indexers valid by shared index ID + */ +class MakeSharedIndexValid +{ + /** + * @var ConfigInterface + */ + private $config; + + /** + * @var IndexerRegistry + */ + private $indexerRegistry; + + /** + * ValidateSharedIndex constructor. + * + * @param ConfigInterface $config + * @param IndexerRegistry $indexerRegistry + */ + public function __construct(ConfigInterface $config, IndexerRegistry $indexerRegistry) + { + $this->config = $config; + $this->indexerRegistry = $indexerRegistry; + } + + /** + * Validate indexers by shared index ID + * + * @param string $sharedIndex + * @return bool + * @throws \Exception + */ + public function execute(string $sharedIndex): bool + { + if (empty($sharedIndex)) { + throw new \InvalidArgumentException( + "The '{$sharedIndex}' is an invalid shared index identifier. Verify the identifier and try again.", + ); + } + + $indexerIds = $this->getIndexerIdsBySharedIndex($sharedIndex); + if (empty($indexerIds)) { + return false; + } + + foreach ($indexerIds as $indexerId) { + $indexer = $this->indexerRegistry->get($indexerId); + /** @var State $state */ + $state = $indexer->getState(); + $state->setStatus(StateInterface::STATUS_WORKING); + $state->save(); + $state->setStatus(StateInterface::STATUS_VALID); + $state->save(); + } + + return true; + } + + /** + * Get indexer ids that have common shared index + * + * @param string $sharedIndex + * @return array + */ + private function getIndexerIdsBySharedIndex(string $sharedIndex): array + { + $indexers = $this->config->getIndexers(); + + $result = []; + foreach ($indexers as $indexerConfig) { + if ($indexerConfig['shared_index'] == $sharedIndex) { + $result[] = $indexerConfig['indexer_id']; + } + } + + return $result; + } +} diff --git a/app/code/Magento/Indexer/Model/WorkingStateProvider.php b/app/code/Magento/Indexer/Model/WorkingStateProvider.php new file mode 100644 index 0000000000000..d77c1b67ecfd7 --- /dev/null +++ b/app/code/Magento/Indexer/Model/WorkingStateProvider.php @@ -0,0 +1,45 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Indexer\Model; + +use Magento\Indexer\Model\Indexer\StateFactory; +use Magento\Framework\Indexer\StateInterface; + +/** + * Provide actual working status of the indexer + */ +class WorkingStateProvider +{ + /** + * @var StateFactory + */ + private $stateFactory; + + /** + * @param StateFactory $stateFactory + */ + public function __construct( + StateFactory $stateFactory + ) { + $this->stateFactory = $stateFactory; + } + + /** + * Execute user functions + * + * @param string $indexerId + * @return bool + */ + public function isWorking(string $indexerId) : bool + { + $state = $this->stateFactory->create(); + $state->loadByIndexer($indexerId); + + return $state->getStatus() === StateInterface::STATUS_WORKING; + } +} diff --git a/app/code/Magento/Indexer/Test/Unit/Console/Command/IndexerReindexCommandTest.php b/app/code/Magento/Indexer/Test/Unit/Console/Command/IndexerReindexCommandTest.php index 6d96841bc3dab..8bdceb92b247b 100644 --- a/app/code/Magento/Indexer/Test/Unit/Console/Command/IndexerReindexCommandTest.php +++ b/app/code/Magento/Indexer/Test/Unit/Console/Command/IndexerReindexCommandTest.php @@ -18,6 +18,7 @@ use Magento\Framework\TestFramework\Unit\Helper\ObjectManager as ObjectManagerHelper; use Magento\Indexer\Console\Command\IndexerReindexCommand; use Magento\Indexer\Model\Config; +use Magento\Indexer\Model\Processor\MakeSharedIndexValid; use PHPUnit\Framework\MockObject\MockObject; use Symfony\Component\Console\Tester\CommandTester; @@ -49,6 +50,11 @@ class IndexerReindexCommandTest extends AbstractIndexerCommandCommonSetup */ private $dependencyInfoProviderMock; + /** + * @var MakeSharedIndexValid|MockObject + */ + private $makeSharedValidMock; + /** * @var ObjectManagerHelper */ @@ -64,12 +70,12 @@ protected function setUp(): void $this->indexerRegistryMock = $this->getMockBuilder(IndexerRegistry::class) ->disableOriginalConstructor() ->getMock(); - $this->dependencyInfoProviderMock = $this->objectManagerHelper->getObject( - DependencyInfoProvider::class, - [ - 'config' => $this->configMock, - ] - ); + $this->makeSharedValidMock = $this->getMockBuilder(MakeSharedIndexValid::class) + ->disableOriginalConstructor() + ->getMock(); + $this->dependencyInfoProviderMock = $this->objectManagerHelper->getObject(DependencyInfoProvider::class, [ + 'config' => $this->configMock, + ]); parent::setUp(); } @@ -174,11 +180,17 @@ public function testExecuteWithIndex( $emptyIndexer->method('getState') ->willReturn($this->getStateMock(['setStatus', 'save'])); + $this->makeSharedValidMock = $this->objectManagerHelper->getObject(MakeSharedIndexValid::class, [ + 'config' => $this->configMock, + 'indexerRegistry' => $this->indexerRegistryMock + ]); $this->configureAdminArea(); $this->command = new IndexerReindexCommand( $this->objectManagerFactory, - $this->indexerRegistryMock + $this->indexerRegistryMock, + $this->dependencyInfoProviderMock, + $this->makeSharedValidMock ); $commandTester = new CommandTester($this->command); diff --git a/app/code/Magento/Indexer/Test/Unit/Model/CacheContextTest.php b/app/code/Magento/Indexer/Test/Unit/Model/CacheContextTest.php index d0a2dbfe9e8e4..fda36271e5d44 100644 --- a/app/code/Magento/Indexer/Test/Unit/Model/CacheContextTest.php +++ b/app/code/Magento/Indexer/Test/Unit/Model/CacheContextTest.php @@ -10,6 +10,9 @@ use Magento\Framework\Indexer\CacheContext; use PHPUnit\Framework\TestCase; +/** + * Test indexer cache context + */ class CacheContextTest extends TestCase { /** @@ -38,20 +41,62 @@ public function testRegisterEntities() } /** - * test getIdentities + * Test getIdentities + * + * @param array $entities + * @param array $tags + * @param array $expected + * @dataProvider getIdentitiesDataProvider */ - public function testGetIdentities() + public function testGetIdentities(array $entities, array $tags = [], array $expected = []): void { - $expectedIdentities = [ - 'product_1', 'product_2', 'product_3', 'category_5', 'category_6', 'category_7', - ]; - $productTag = 'product'; - $categoryTag = 'category'; + foreach ($entities as $entity => $ids) { + $this->context->registerEntities($entity, $ids); + } + $this->context->registerTags($tags); + $this->assertEquals($expected, $this->context->getIdentities()); + } + + /** + * Test that flush() clears all data + */ + public function testFlush(): void + { + $productTag = 'cat_p'; + $categoryTag = 'cat_c'; + $additionalTags = ['cat_c_p']; $productIds = [1, 2, 3]; $categoryIds = [5, 6, 7]; $this->context->registerEntities($productTag, $productIds); $this->context->registerEntities($categoryTag, $categoryIds); - $actualIdentities = $this->context->getIdentities(); - $this->assertEquals($expectedIdentities, $actualIdentities); + $this->context->registerTags($additionalTags); + $this->assertNotEmpty($this->context->getIdentities()); + $this->context->flush(); + $this->assertEmpty($this->context->getIdentities()); + } + + /** + * @return array[] + */ + public function getIdentitiesDataProvider(): array + { + return [ + 'should return entities and tags' => [ + [ + 'cat_p' => [1, 2, 3], + 'cat_c' => [5, 6, 7] + ], + ['cat_c_p1', 'cat_c_p2'], + ['cat_p_1', 'cat_p_2', 'cat_p_3', 'cat_c_5', 'cat_c_6', 'cat_c_7', 'cat_c_p1', 'cat_c_p2'] + ], + 'should return unique values' => [ + [ + 'cat_p' => [1, 2, 3, 1, 3], + 'cat_c' => [5, 6, 7, 6] + ], + ['cat_c_p1', 'cat_c_p2'], + ['cat_p_1', 'cat_p_2', 'cat_p_3', 'cat_c_5', 'cat_c_6', 'cat_c_7', 'cat_c_p1', 'cat_c_p2'] + ] + ]; } } diff --git a/app/code/Magento/Indexer/Test/Unit/Model/Indexer/CacheCleanerTest.php b/app/code/Magento/Indexer/Test/Unit/Model/Indexer/CacheCleanerTest.php new file mode 100644 index 0000000000000..0839dbfb13373 --- /dev/null +++ b/app/code/Magento/Indexer/Test/Unit/Model/Indexer/CacheCleanerTest.php @@ -0,0 +1,129 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Indexer\Test\Unit\Model\Indexer; + +use Magento\Framework\App\CacheInterface; +use Magento\Framework\Event\Manager; +use Magento\Framework\Indexer\ActionInterface; +use Magento\Framework\Indexer\CacheContext; +use Magento\Indexer\Model\Indexer\CacheCleaner; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * Test cache cleaner plugin + */ +class CacheCleanerTest extends TestCase +{ + /** + * @var Manager|MockObject + */ + private $eventManager; + /** + * @var CacheContext|MockObject + */ + private $cacheContext; + /** + * @var CacheInterface|MockObject + */ + private $cache; + /** + * @var CacheCleaner + */ + private $model; + /** + * @var ActionInterface|MockObject + */ + private $action; + + /** + * @inheritDoc + */ + protected function setUp(): void + { + parent::setUp(); + $this->action = $this->getMockForAbstractClass(ActionInterface::class); + $this->cacheContext = $this->createMock(CacheContext::class); + $this->eventManager = $this->createMock(Manager::class); + $this->cache = $this->getMockForAbstractClass(CacheInterface::class); + $this->model = new CacheCleaner( + $this->eventManager, + $this->cacheContext, + $this->cache + ); + } + + /** + * @param array $tags + * @param bool $isCacheClean + * @dataProvider cacheTagsDataProvider + */ + public function testAfterExecuteFull(array $tags, bool $isCacheClean = true): void + { + $this->expectCacheClean($tags, $isCacheClean); + $this->model->afterExecuteFull($this->action); + } + + /** + * @param array $tags + * @param bool $isCacheClean + * @dataProvider cacheTagsDataProvider + */ + public function testAfterExecuteList(array $tags, bool $isCacheClean = true): void + { + $this->expectCacheClean($tags, $isCacheClean); + $this->model->afterExecuteList($this->action); + } + + /** + * @param array $tags + * @param bool $isCacheClean + * @dataProvider cacheTagsDataProvider + */ + public function testAfterExecuteRow(array $tags, bool $isCacheClean = true): void + { + $this->expectCacheClean($tags, $isCacheClean); + $this->model->afterExecuteRow($this->action); + } + + /** + * @return array[] + */ + public function cacheTagsDataProvider(): array + { + return [ + [[], false], + [['cat_c_1', 'cat_c_2'], true] + ]; + } + + /** + * @param array $tags + * @param bool $isCacheClean + */ + private function expectCacheClean(array $tags, bool $isCacheClean = true): void + { + $this->eventManager->expects($this->once()) + ->method('dispatch') + ->with( + 'clean_cache_by_tags', + ['object' => $this->cacheContext] + ); + + $this->cacheContext->expects($this->atLeastOnce()) + ->method('getIdentities') + ->willReturn($tags); + + $this->cache->expects($this->exactly($isCacheClean ? 1 : 0)) + ->method('clean') + ->with($tags); + + $this->cacheContext->expects($this->exactly($isCacheClean ? 1 : 0)) + ->method('flush'); + } +} diff --git a/app/code/Magento/Indexer/Test/Unit/Model/IndexerTest.php b/app/code/Magento/Indexer/Test/Unit/Model/IndexerTest.php index 662856e2187d5..bcdfbea78b0b3 100644 --- a/app/code/Magento/Indexer/Test/Unit/Model/IndexerTest.php +++ b/app/code/Magento/Indexer/Test/Unit/Model/IndexerTest.php @@ -12,11 +12,13 @@ use Magento\Framework\Indexer\ConfigInterface; use Magento\Framework\Indexer\StateInterface; use Magento\Framework\Indexer\StructureFactory; +use Magento\Framework\Indexer\IndexerInterfaceFactory; use Magento\Framework\Mview\ViewInterface; use Magento\Indexer\Model\Indexer; use Magento\Indexer\Model\Indexer\CollectionFactory; use Magento\Indexer\Model\Indexer\State; use Magento\Indexer\Model\Indexer\StateFactory; +use Magento\Indexer\Model\WorkingStateProvider; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -55,8 +57,21 @@ class IndexerTest extends TestCase */ protected $indexFactoryMock; + /** + * @var WorkingStateProvider|MockObject + */ + private $workingStateProvider; + + /** + * @var IndexerInterfaceFactory|MockObject + */ + private $indexerFactoryMock; + protected function setUp(): void { + $this->workingStateProvider = $this->getMockBuilder(WorkingStateProvider::class) + ->disableOriginalConstructor() + ->getMock(); $this->configMock = $this->getMockForAbstractClass( ConfigInterface::class, [], @@ -70,6 +85,10 @@ protected function setUp(): void ActionFactory::class, ['create'] ); + $this->indexerFactoryMock = $this->createPartialMock( + IndexerInterfaceFactory::class, + ['create'] + ); $this->viewMock = $this->getMockForAbstractClass( ViewInterface::class, [], @@ -99,7 +118,9 @@ protected function setUp(): void $structureFactory, $this->viewMock, $this->stateFactoryMock, - $this->indexFactoryMock + $this->indexFactoryMock, + $this->workingStateProvider, + $this->indexerFactoryMock ); } @@ -211,7 +232,7 @@ public function testReindexAll() $stateMock->expects($this->never())->method('setIndexerId'); $stateMock->expects($this->once())->method('getId')->willReturn(1); $stateMock->expects($this->exactly(2))->method('setStatus')->willReturnSelf(); - $stateMock->expects($this->once())->method('getStatus')->willReturn('idle'); + $stateMock->expects($this->any())->method('getStatus')->willReturn('idle'); $stateMock->expects($this->exactly(2))->method('save')->willReturnSelf(); $this->stateFactoryMock->expects($this->once())->method('create')->willReturn($stateMock); @@ -251,7 +272,7 @@ public function testReindexAllWithException() $stateMock->expects($this->never())->method('setIndexerId'); $stateMock->expects($this->once())->method('getId')->willReturn(1); $stateMock->expects($this->exactly(2))->method('setStatus')->willReturnSelf(); - $stateMock->expects($this->once())->method('getStatus')->willReturn('idle'); + $stateMock->expects($this->any())->method('getStatus')->willReturn('idle'); $stateMock->expects($this->exactly(2))->method('save')->willReturnSelf(); $this->stateFactoryMock->expects($this->once())->method('create')->willReturn($stateMock); @@ -296,7 +317,7 @@ public function testReindexAllWithError() $stateMock->expects($this->never())->method('setIndexerId'); $stateMock->expects($this->once())->method('getId')->willReturn(1); $stateMock->expects($this->exactly(2))->method('setStatus')->willReturnSelf(); - $stateMock->expects($this->once())->method('getStatus')->willReturn('idle'); + $stateMock->expects($this->any())->method('getStatus')->willReturn('idle'); $stateMock->expects($this->exactly(2))->method('save')->willReturnSelf(); $this->stateFactoryMock->expects($this->once())->method('create')->willReturn($stateMock); @@ -336,7 +357,8 @@ protected function getIndexerData() 'view_id' => 'view_test', 'action_class' => 'Some\Class\Name', 'title' => 'Indexer public name', - 'description' => 'Indexer public description' + 'description' => 'Indexer public description', + 'shared_index' => null ]; } @@ -346,7 +368,7 @@ protected function getIndexerData() protected function loadIndexer($indexId) { $this->configMock->expects( - $this->once() + $this->any() )->method( 'getIndexer' )->with( diff --git a/app/code/Magento/Indexer/Test/Unit/Model/Processor/CleanCacheTest.php b/app/code/Magento/Indexer/Test/Unit/Model/Processor/CleanCacheTest.php index a4b734f8ebc83..4c37f4c767fb9 100644 --- a/app/code/Magento/Indexer/Test/Unit/Model/Processor/CleanCacheTest.php +++ b/app/code/Magento/Indexer/Test/Unit/Model/Processor/CleanCacheTest.php @@ -17,6 +17,9 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +/** + * Test cache clean plugin + */ class CleanCacheTest extends TestCase { /** @@ -103,6 +106,9 @@ public function testAfterUpdateMview() ->method('clean') ->with($tags); + $this->contextMock->expects($this->once()) + ->method('flush'); + $this->plugin->afterUpdateMview($this->subjectMock); } } diff --git a/app/code/Magento/Indexer/Test/Unit/Model/ProcessorTest.php b/app/code/Magento/Indexer/Test/Unit/Model/ProcessorTest.php index 7a06fb745ba89..bbb74812d99a3 100644 --- a/app/code/Magento/Indexer/Test/Unit/Model/ProcessorTest.php +++ b/app/code/Magento/Indexer/Test/Unit/Model/ProcessorTest.php @@ -9,6 +9,7 @@ use Magento\Framework\Indexer\ConfigInterface; use Magento\Framework\Indexer\IndexerInterfaceFactory; +use Magento\Framework\Indexer\IndexerRegistry; use Magento\Framework\Indexer\StateInterface; use Magento\Framework\Mview\ProcessorInterface; use Magento\Indexer\Model\Indexer; @@ -16,6 +17,7 @@ use Magento\Indexer\Model\Indexer\CollectionFactory; use Magento\Indexer\Model\Indexer\State; use Magento\Indexer\Model\Processor; +use Magento\Indexer\Model\Processor\MakeSharedIndexValid; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -71,15 +73,26 @@ protected function setUp(): void '', false ); + + $indexerRegistryMock = $this->getIndexRegistryMock([]); + $makeSharedValidMock = new MakeSharedIndexValid( + $this->configMock, + $indexerRegistryMock + ); + $this->model = new Processor( $this->configMock, $this->indexerFactoryMock, $this->indexersFactoryMock, - $this->viewProcessorMock + $this->viewProcessorMock, + $makeSharedValidMock ); } - public function testReindexAllInvalid() + /** + * @return void + */ + public function testReindexAllInvalid(): void { $indexers = ['indexer1' => [], 'indexer2' => []]; @@ -121,7 +134,68 @@ public function testReindexAllInvalid() $this->model->reindexAllInvalid(); } - public function testReindexAll() + /** + * @dataProvider sharedIndexDataProvider + * @param array $indexers + * @param array $indexerStates + * @param array $expectedReindexAllCalls + * @param array $executedSharedIndexers + */ + public function testReindexAllInvalidWithSharedIndex( + array $indexers, + array $indexerStates, + array $expectedReindexAllCalls, + array $executedSharedIndexers + ): void { + $this->configMock->expects($this->any())->method('getIndexers')->willReturn($indexers); + $this->configMock + ->method('getIndexer') + ->willReturnMap( + array_map( + function ($elem) { + return [$elem['indexer_id'], $elem]; + }, + $indexers + ) + ); + $indexerMocks = []; + foreach ($indexers as $indexerData) { + $stateMock = $this->createPartialMock(State::class, ['getStatus', '__wakeup']); + $stateMock->expects($this->any()) + ->method('getStatus') + ->willReturn($indexerStates[$indexerData['indexer_id']]); + $indexerMock = $this->createPartialMock(Indexer::class, ['load', 'getState', 'reindexAll']); + $indexerMock->expects($this->any())->method('getState')->willReturn($stateMock); + $indexerMock->expects($expectedReindexAllCalls[$indexerData['indexer_id']])->method('reindexAll'); + + $this->indexerFactoryMock->expects($this->at(count($indexerMocks))) + ->method('create') + ->willReturn($indexerMock); + + $indexerMocks[] = $indexerMock; + } + $indexerRegistryMock = $this->getIndexRegistryMock($executedSharedIndexers); + + $makeSharedValidMock = new MakeSharedIndexValid( + $this->configMock, + $indexerRegistryMock + ); + $model = new Processor( + $this->configMock, + $this->indexerFactoryMock, + $this->indexersFactoryMock, + $this->viewProcessorMock, + $makeSharedValidMock + ); + $model->reindexAllInvalid(); + } + + /** + * Reindex all test + * + * return void + */ + public function testReindexAll(): void { $indexerMock = $this->createMock(Indexer::class); $indexerMock->expects($this->exactly(2))->method('reindexAll'); @@ -134,15 +208,136 @@ public function testReindexAll() $this->model->reindexAll(); } + /** + * Update mview test + * + * @return void + */ public function testUpdateMview() { $this->viewProcessorMock->expects($this->once())->method('update')->with('indexer')->willReturnSelf(); $this->model->updateMview(); } + /** + * Clear change log test + * + * @return void + */ public function testClearChangelog() { $this->viewProcessorMock->expects($this->once())->method('clearChangelog')->with('indexer')->willReturnSelf(); $this->model->clearChangelog(); } + + /** + * @return array + */ + public function sharedIndexDataProvider() + { + return [ + 'Without dependencies' => [ + 'indexers' => [ + 'indexer_1' => [ + 'indexer_id' => 'indexer_1', + 'title' => 'Title_indexer_1', + 'shared_index' => null, + 'dependencies' => [], + ], + 'indexer_2' => [ + 'indexer_id' => 'indexer_2', + 'title' => 'Title_indexer_2', + 'shared_index' => 'with_indexer_3', + 'dependencies' => [], + ], + 'indexer_3' => [ + 'indexer_id' => 'indexer_3', + 'title' => 'Title_indexer_3', + 'shared_index' => 'with_indexer_3', + 'dependencies' => [], + ], + ], + 'indexer_states' => [ + 'indexer_1' => StateInterface::STATUS_INVALID, + 'indexer_2' => StateInterface::STATUS_VALID, + 'indexer_3' => StateInterface::STATUS_VALID, + ], + 'expected_reindex_all_calls' => [ + 'indexer_1' => $this->once(), + 'indexer_2' => $this->never(), + 'indexer_3' => $this->never(), + ], + 'executed_shared_indexers' => [], + ], + 'With dependencies and some indexers is invalid' => [ + 'indexers' => [ + 'indexer_1' => [ + 'indexer_id' => 'indexer_1', + 'title' => 'Title_indexer_1', + 'shared_index' => null, + 'dependencies' => ['indexer_2', 'indexer_3'], + ], + 'indexer_2' => [ + 'indexer_id' => 'indexer_2', + 'title' => 'Title_indexer_2', + 'shared_index' => 'with_indexer_3', + 'dependencies' => [], + ], + 'indexer_3' => [ + 'indexer_id' => 'indexer_3', + 'title' => 'Title_indexer_3', + 'shared_index' => 'with_indexer_3', + 'dependencies' => [], + ], + 'indexer_4' => [ + 'indexer_id' => 'indexer_4', + 'title' => 'Title_indexer_4', + 'shared_index' => null, + 'dependencies' => ['indexer_1'], + ], + ], + 'indexer_states' => [ + 'indexer_1' => StateInterface::STATUS_INVALID, + 'indexer_2' => StateInterface::STATUS_VALID, + 'indexer_3' => StateInterface::STATUS_INVALID, + 'indexer_4' => StateInterface::STATUS_VALID, + ], + 'expected_reindex_all_calls' => [ + 'indexer_1' => $this->once(), + 'indexer_2' => $this->never(), + 'indexer_3' => $this->once(), + 'indexer_4' => $this->never(), + ], + 'executed_shared_indexers' => [['indexer_2'], ['indexer_3']], + ], + ]; + } + + /** + * @param array $executedSharedIndexers + * @return IndexerRegistry|MockObject + */ + private function getIndexRegistryMock(array $executedSharedIndexers) + { + /** @var MockObject|IndexerRegistry $indexerRegistryMock */ + $indexerRegistryMock = $this->getMockBuilder(IndexerRegistry::class) + ->disableOriginalConstructor() + ->getMock(); + $emptyIndexer = $this->createPartialMock(Indexer::class, ['load', 'getState', 'reindexAll']); + /** @var MockObject|StateInterface $state */ + $state = $this->getMockBuilder(StateInterface::class) + ->setMethods(['setStatus', 'save']) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $state->method('getStatus') + ->willReturn(StateInterface::STATUS_INVALID); + $emptyIndexer->method('getState')->willReturn($state); + $indexerRegistryMock + ->expects($this->exactly(count($executedSharedIndexers))) + ->method('get') + ->withConsecutive(...$executedSharedIndexers) + ->willReturn($emptyIndexer); + + return $indexerRegistryMock; + } } diff --git a/app/code/Magento/Integration/Block/Adminhtml/Integration/Activate/Permissions/Tab/Webapi.php b/app/code/Magento/Integration/Block/Adminhtml/Integration/Activate/Permissions/Tab/Webapi.php index 2d323fea34e7d..b6ea810666b9b 100644 --- a/app/code/Magento/Integration/Block/Adminhtml/Integration/Activate/Permissions/Tab/Webapi.php +++ b/app/code/Magento/Integration/Block/Adminhtml/Integration/Activate/Permissions/Tab/Webapi.php @@ -222,13 +222,13 @@ public function isTreeEmpty() */ protected function _getAllResourceIds(array $resources) { - $resourceIds = [[]]; + $resourceIds = []; foreach ($resources as $resource) { $resourceIds[] = [$resource['id']]; if (isset($resource['children'])) { $resourceIds[] = $this->_getAllResourceIds($resource['children']); } } - return array_merge(...$resourceIds); + return array_merge([], ...$resourceIds); } } diff --git a/app/code/Magento/Integration/Block/Adminhtml/Widget/Grid/Column/Renderer/Link.php b/app/code/Magento/Integration/Block/Adminhtml/Widget/Grid/Column/Renderer/Link.php index 9a406945f3b96..76667f6060853 100644 --- a/app/code/Magento/Integration/Block/Adminhtml/Widget/Grid/Column/Renderer/Link.php +++ b/app/code/Magento/Integration/Block/Adminhtml/Widget/Grid/Column/Renderer/Link.php @@ -81,8 +81,9 @@ public function isDisabled() } /** - * Return URL pattern for action associated with the link e.g. "(star)(slash)(star)(slash)activate" -> - * will be translated to http://.../admin/integration/activate/id/X + * Return URL pattern for action associated with the link e.g. "(star)(slash)(star)(slash)activate" + * + * Will be translated to http://.../admin/integration/activate/id/X * * @return string */ @@ -164,6 +165,6 @@ protected function _getDataAttributes() */ protected function _getUrl(DataObject $row) { - return $this->isDisabled($row) ? '#' : $this->getUrl($this->getUrlPattern(), ['id' => $row->getId()]); + return $this->isDisabled() ? '#' : $this->getUrl($this->getUrlPattern(), ['id' => $row->getId()]); } } diff --git a/app/code/Magento/Integration/Model/Oauth/Consumer.php b/app/code/Magento/Integration/Model/Oauth/Consumer.php index 4442be310b967..b436defcd0cfa 100644 --- a/app/code/Magento/Integration/Model/Oauth/Consumer.php +++ b/app/code/Magento/Integration/Model/Oauth/Consumer.php @@ -13,15 +13,15 @@ * @api * @author Magento Core Team <core@magentocommerce.com> * @method string getName() - * @method Consumer setName() setName(string $name) - * @method Consumer setKey() setKey(string $key) - * @method Consumer setSecret() setSecret(string $secret) - * @method Consumer setCallbackUrl() setCallbackUrl(string $url) - * @method Consumer setCreatedAt() setCreatedAt(string $date) + * @method Consumer setName(string $name) + * @method Consumer setKey(string $key) + * @method Consumer setSecret(string $secret) + * @method Consumer setCallbackUrl(string $url) + * @method Consumer setCreatedAt(string $date) * @method string getUpdatedAt() - * @method Consumer setUpdatedAt() setUpdatedAt(string $date) + * @method Consumer setUpdatedAt(string $date) * @method string getRejectedCallbackUrl() - * @method Consumer setRejectedCallbackUrl() setRejectedCallbackUrl(string $rejectedCallbackUrl) + * @method Consumer setRejectedCallbackUrl(string $rejectedCallbackUrl) * @since 100.0.2 */ class Consumer extends \Magento\Framework\Model\AbstractModel implements ConsumerInterface @@ -112,7 +112,7 @@ public function beforeSave() } /** - * {@inheritdoc} + * @inheritDoc */ public function validate() { @@ -158,7 +158,7 @@ public function loadByKey($key) } /** - * {@inheritdoc} + * @inheritDoc */ public function getKey() { @@ -166,7 +166,7 @@ public function getKey() } /** - * {@inheritdoc} + * @inheritDoc */ public function getSecret() { @@ -174,7 +174,7 @@ public function getSecret() } /** - * {@inheritdoc} + * @inheritDoc */ public function getCallbackUrl() { @@ -182,7 +182,7 @@ public function getCallbackUrl() } /** - * {@inheritdoc} + * @inheritDoc */ public function getCreatedAt() { @@ -190,7 +190,7 @@ public function getCreatedAt() } /** - * {@inheritdoc} + * @inheritDoc */ public function isValidForTokenExchange() { diff --git a/app/code/Magento/Integration/Model/Oauth/Token.php b/app/code/Magento/Integration/Model/Oauth/Token.php index 53d9ec300a862..7a3580b8bd402 100644 --- a/app/code/Magento/Integration/Model/Oauth/Token.php +++ b/app/code/Magento/Integration/Model/Oauth/Token.php @@ -15,27 +15,27 @@ * * @method string getName() Consumer name (joined from consumer table) * @method int getConsumerId() - * @method Token setConsumerId() setConsumerId(int $consumerId) + * @method Token setConsumerId(int $consumerId) * @method int getAdminId() - * @method Token setAdminId() setAdminId(int $adminId) + * @method Token setAdminId(int $adminId) * @method int getCustomerId() - * @method Token setCustomerId() setCustomerId(int $customerId) + * @method Token setCustomerId(int $customerId) * @method int getUserType() - * @method Token setUserType() setUserType(int $userType) + * @method Token setUserType(int $userType) * @method string getType() - * @method Token setType() setType(string $type) + * @method Token setType(string $type) * @method string getCallbackUrl() - * @method Token setCallbackUrl() setCallbackUrl(string $callbackUrl) + * @method Token setCallbackUrl(string $callbackUrl) * @method string getCreatedAt() - * @method Token setCreatedAt() setCreatedAt(string $createdAt) + * @method Token setCreatedAt(string $createdAt) * @method string getToken() - * @method Token setToken() setToken(string $token) + * @method Token setToken(string $token) * @method string getSecret() - * @method Token setSecret() setSecret(string $tokenSecret) + * @method Token setSecret(string $tokenSecret) * @method int getRevoked() - * @method Token setRevoked() setRevoked(int $revoked) + * @method Token setRevoked(int $revoked) * @method int getAuthorized() - * @method Token setAuthorized() setAuthorized(int $authorized) + * @method Token setAuthorized(int $authorized) * @SuppressWarnings(PHPMD.CouplingBetweenObjects) * @api * @since 100.0.2 @@ -200,7 +200,7 @@ public function createAdminToken($userId) public function createCustomerToken($userId) { $this->setCustomerId($userId); - return $this->saveAccessToken(UserContextInterface::USER_TYPE_CUSTOMER, $userId); + return $this->saveAccessToken(UserContextInterface::USER_TYPE_CUSTOMER); } /** diff --git a/app/code/Magento/Integration/Test/Mftf/Page/AdminConfigServicesOauthPage.xml b/app/code/Magento/Integration/Test/Mftf/Page/AdminConfigServicesOauthPage.xml new file mode 100644 index 0000000000000..85f20c3617e1d --- /dev/null +++ b/app/code/Magento/Integration/Test/Mftf/Page/AdminConfigServicesOauthPage.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<pages xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/PageObject.xsd"> + <page name="AdminConfigServicesOauthPage" url="admin/system_config/edit/section/oauth/" area="admin" module="Magento_Integration"> + <section name="AdminConfigAccessTokenExpirationSection"/> + </page> +</pages> diff --git a/app/code/Magento/Integration/Test/Mftf/Section/AdminConfigAccessTokenExpirationSection.xml b/app/code/Magento/Integration/Test/Mftf/Section/AdminConfigAccessTokenExpirationSection.xml new file mode 100644 index 0000000000000..0f18c1e75979e --- /dev/null +++ b/app/code/Magento/Integration/Test/Mftf/Section/AdminConfigAccessTokenExpirationSection.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="AdminConfigAccessTokenExpirationSection"> + <element name="tabAccessTokenLifetime" type="select" selector="#oauth_access_token_lifetime-head"/> + <element name="CheckIfTabExpand" type="button" selector="#oauth_access_token_lifetime-head:not(.open)"/> + <element name="valueForTokenLifetime" type="input" selector="#oauth_access_token_lifetime_customer"/> + <element name="systemValueForTokenLifetime" type="checkbox" selector="#oauth_access_token_lifetime_customer_inherit"/> + <element name="valueForTokenLifetimeAdmin" type="input" selector="#oauth_access_token_lifetime_admin"/> + <element name="systemValueForTokenLifetimeAdmin" type="checkbox" selector="#oauth_access_token_lifetime_admin_inherit"/> + </section> +</sections> diff --git a/app/code/Magento/Integration/Test/Mftf/Test/AdminConfigSaveEmptySettingsTest.xml b/app/code/Magento/Integration/Test/Mftf/Test/AdminConfigSaveEmptySettingsTest.xml new file mode 100644 index 0000000000000..89a0fb4c1f026 --- /dev/null +++ b/app/code/Magento/Integration/Test/Mftf/Test/AdminConfigSaveEmptySettingsTest.xml @@ -0,0 +1,36 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminConfigSaveEmptySettingsTest"> + <annotations> + <features value="Configuration"/> + <stories value="Save settings 'Access Token Expiration'."/> + <title value="Save settings 'Access Token Expiration' with empty values."/> + <description value="Save settings 'Customer Token Lifetime' and 'Admin Token Lifetime' with empty values without validations."/> + <severity value="AVERAGE"/> + <testCaseId value="MC-37382"/> + <group value="configuration"/> + </annotations> + <before> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + </before> + <after> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> + </after> + <amOnPage url="{{AdminConfigServicesOauthPage.url}}" stepKey="navigateToConfigurationPage"/> + <waitForPageLoad stepKey="waitForPageLoad"/> + <conditionalClick selector="{{AdminConfigAccessTokenExpirationSection.tabAccessTokenLifetime}}" dependentSelector="{{AdminConfigAccessTokenExpirationSection.CheckIfTabExpand}}" visible="true" stepKey="expandTab"/> + <waitForAjaxLoad stepKey="waitForAjax"/> + <uncheckOption selector="{{AdminConfigAccessTokenExpirationSection.systemValueForTokenLifetime}}" stepKey="uncheckUseSystemValue"/> + <fillField selector="{{AdminConfigAccessTokenExpirationSection.valueForTokenLifetime}}" userInput="" stepKey="valueForTokenLifetime"/> + <uncheckOption selector="{{AdminConfigAccessTokenExpirationSection.systemValueForTokenLifetimeAdmin}}" stepKey="uncheckUseSystemValueAdmin"/> + <fillField selector="{{AdminConfigAccessTokenExpirationSection.valueForTokenLifetimeAdmin}}" userInput="" stepKey="valueForTokenLifetimeAdmin"/> + <actionGroup ref="AdminSaveConfigActionGroup" stepKey="saveConfig"/> + </test> +</tests> diff --git a/app/code/Magento/Integration/etc/adminhtml/system.xml b/app/code/Magento/Integration/etc/adminhtml/system.xml index 6ef569a1d8a2f..3d465a9642805 100644 --- a/app/code/Magento/Integration/etc/adminhtml/system.xml +++ b/app/code/Magento/Integration/etc/adminhtml/system.xml @@ -16,12 +16,12 @@ <field id="customer" translate="label comment" type="text" sortOrder="30" showInDefault="1" canRestore="1"> <label>Customer Token Lifetime (hours)</label> <comment>We will disable this feature if the value is empty.</comment> - <validate>required-entry validate-zero-or-greater validate-number</validate> + <validate>validate-zero-or-greater validate-number</validate> </field> <field id="admin" translate="label comment" type="text" sortOrder="60" showInDefault="1" canRestore="1"> <label>Admin Token Lifetime (hours)</label> <comment>We will disable this feature if the value is empty.</comment> - <validate>required-entry validate-zero-or-greater validate-number</validate> + <validate>validate-zero-or-greater validate-number</validate> </field> </group> <group id="cleanup" translate="label" type="text" sortOrder="300" showInDefault="1"> diff --git a/app/code/Magento/LayeredNavigation/Test/Mftf/ActionGroup/StorefrontAssertAppliedFilterActionGroup.xml b/app/code/Magento/LayeredNavigation/Test/Mftf/ActionGroup/StorefrontAssertAppliedFilterActionGroup.xml new file mode 100644 index 0000000000000..92fea20a83157 --- /dev/null +++ b/app/code/Magento/LayeredNavigation/Test/Mftf/ActionGroup/StorefrontAssertAppliedFilterActionGroup.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="StorefrontAssertAppliedFilterActionGroup"> + <annotations> + <description>Asserts applied filter label and value on storefront category page.</description> + </annotations> + <arguments> + <argument name="attributeLabel" type="string"/> + <argument name="attributeOptionLabel" type="string"/> + </arguments> + <see selector="{{StorefrontLayeredNavigationSection.appliedFilterLabel('1')}}" userInput="{{attributeLabel}}" stepKey="seeAppliedFilterLabel"/> + <see selector="{{StorefrontLayeredNavigationSection.appliedFilterValue('1')}}" userInput="{{attributeOptionLabel}}" stepKey="seeAppliedFilterValue"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/LayeredNavigation/Test/Mftf/ActionGroup/StorefrontFilterCategoryPageByAttributeOptionActionGroup.xml b/app/code/Magento/LayeredNavigation/Test/Mftf/ActionGroup/StorefrontFilterCategoryPageByAttributeOptionActionGroup.xml new file mode 100644 index 0000000000000..f6fe2b20185e6 --- /dev/null +++ b/app/code/Magento/LayeredNavigation/Test/Mftf/ActionGroup/StorefrontFilterCategoryPageByAttributeOptionActionGroup.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="StorefrontFilterCategoryPageByAttributeOptionActionGroup"> + <annotations> + <description>Filters storefront category page by given filterable attribute and attribute option.</description> + </annotations> + <arguments> + <argument name="attributeLabel" type="string"/> + <argument name="attributeOptionLabel" type="string"/> + </arguments> + <waitForElementVisible selector="{{StorefrontCategorySidebarSection.filterOptionsTitle(attributeLabel)}}" stepKey="waitForFilterVisible"/> + <conditionalClick selector="{{StorefrontCategorySidebarSection.filterOptionsTitle(attributeLabel)}}" dependentSelector="{{StorefrontCategorySidebarSection.activeFilterOptions}}" visible="false" stepKey="clickToExpandFilter"/> + <click selector="{{StorefrontCategorySidebarSection.enabledFilterOptionItemByLabel(attributeOptionLabel)}}" stepKey="clickOnOption"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/LayeredNavigation/Test/Mftf/Section/LayeredNavigationSection/StorefrontLayeredNavigationSection.xml b/app/code/Magento/LayeredNavigation/Test/Mftf/Section/LayeredNavigationSection/StorefrontLayeredNavigationSection.xml index d3a3005c296b2..d8a103116ef06 100644 --- a/app/code/Magento/LayeredNavigation/Test/Mftf/Section/LayeredNavigationSection/StorefrontLayeredNavigationSection.xml +++ b/app/code/Magento/LayeredNavigation/Test/Mftf/Section/LayeredNavigationSection/StorefrontLayeredNavigationSection.xml @@ -9,5 +9,7 @@ xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> <section name="StorefrontLayeredNavigationSection"> <element name="shoppingOptionsByName" type="button" selector="//*[text()='Shopping Options']/..//*[contains(text(),'{{arg}}')]" parameterized="true"/> + <element name="appliedFilterLabel" type="text" selector=".filter-current .items > li.item:nth-of-type({{position}}) > span.filter-label" parameterized="true"/> + <element name="appliedFilterValue" type="text" selector=".filter-current .items > li.item:nth-of-type({{position}}) > span.filter-value" parameterized="true"/> </section> </sections> diff --git a/app/code/Magento/LayeredNavigation/Test/Mftf/Test/StorefrontDropdownAttributeInLayeredNavigationTest.xml b/app/code/Magento/LayeredNavigation/Test/Mftf/Test/StorefrontDropdownAttributeInLayeredNavigationTest.xml new file mode 100644 index 0000000000000..0cd115d3febeb --- /dev/null +++ b/app/code/Magento/LayeredNavigation/Test/Mftf/Test/StorefrontDropdownAttributeInLayeredNavigationTest.xml @@ -0,0 +1,103 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="StorefrontDropdownAttributeInLayeredNavigationTest"> + <annotations> + <features value="LayeredNavigation"/> + <stories value="Product attributes in Layered Navigation"/> + <title value="[ES] Search with Layered Navigation and different types of attribute products."/> + <description value="Filtering by dropdown attribute in Layered navigation"/> + <severity value="CRITICAL"/> + <testCaseId value="MC-36326"/> + <group value="layeredNavigation"/> + <group value="catalog"/> + <group value="SearchEngineElasticsearch"/> + </annotations> + <before> + <createData entity="SimpleSubCategory" stepKey="createCategory"/> + <createData entity="dropdownProductAttribute" stepKey="createDropdownProductAttribute"/> + <createData entity="productAttributeOption" stepKey="firstDropdownProductAttributeOption"> + <requiredEntity createDataKey="createDropdownProductAttribute"/> + </createData> + <createData entity="productAttributeOption" stepKey="secondDropdownProductAttributeOption"> + <requiredEntity createDataKey="createDropdownProductAttribute"/> + </createData> + <getData entity="ProductAttributeOptionGetter" index="1" stepKey="getFirstDropdownProductAttributeOption"> + <requiredEntity createDataKey="createDropdownProductAttribute"/> + </getData> + <getData entity="ProductAttributeOptionGetter" index="2" stepKey="getSecondDropdownProductAttributeOption"> + <requiredEntity createDataKey="createDropdownProductAttribute"/> + </getData> + <createData entity="AddToDefaultSet" stepKey="AddDropdownProductAttributeToAttributeSet"> + <requiredEntity createDataKey="createDropdownProductAttribute"/> + </createData> + <createData entity="ApiSimpleProductWithCategory" stepKey="createFirstProduct"> + <requiredEntity createDataKey="createDropdownProductAttribute"/> + <requiredEntity createDataKey="getFirstDropdownProductAttributeOption"/> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="ApiSimpleProductWithCategory" stepKey="createSecondProduct"> + <requiredEntity createDataKey="createDropdownProductAttribute"/> + <requiredEntity createDataKey="getSecondDropdownProductAttributeOption"/> + <requiredEntity createDataKey="createCategory"/> + </createData> + <magentoCron groups="index" stepKey="reindexInvalidatedIndices"/> + </before> + <after> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + <deleteData createDataKey="createFirstProduct" stepKey="deleteFirstProduct"/> + <deleteData createDataKey="createSecondProduct" stepKey="deleteSecondProduct"/> + <deleteData createDataKey="createDropdownProductAttribute" stepKey="deleteDropdownProductAttribute"/> + <magentoCron groups="index" stepKey="reindexInvalidatedIndices"/> + </after> + <actionGroup ref="StorefrontNavigateCategoryPageActionGroup" stepKey="openCategory"> + <argument name="category" value="$createCategory$"/> + </actionGroup> + <actionGroup ref="AssertStorefrontAttributeOptionPresentInLayeredNavigationActionGroup" stepKey="assertFirstAttributeOptionPresentInLayeredNavigation"> + <argument name="attributeLabel" value="$createDropdownProductAttribute.attribute[frontend_labels][0][label]$"/> + <argument name="attributeOptionLabel" value="$getFirstDropdownProductAttributeOption.label$"/> + <argument name="attributeOptionPosition" value="1"/> + </actionGroup> + <actionGroup ref="AssertStorefrontAttributeOptionPresentInLayeredNavigationActionGroup" stepKey="assertSecondAttributeOptionPresentInLayeredNavigation"> + <argument name="attributeLabel" value="$createDropdownProductAttribute.attribute[frontend_labels][0][label]$"/> + <argument name="attributeOptionLabel" value="$getSecondDropdownProductAttributeOption.label$"/> + <argument name="attributeOptionPosition" value="2"/> + </actionGroup> + <actionGroup ref="StorefrontFilterCategoryPageByAttributeOptionActionGroup" stepKey="filterCategoryByFirstOption"> + <argument name="attributeLabel" value="$createDropdownProductAttribute.attribute[frontend_labels][0][label]$"/> + <argument name="attributeOptionLabel" value="$getFirstDropdownProductAttributeOption.label$"/> + </actionGroup> + <actionGroup ref="StorefrontAssertAppliedFilterActionGroup" stepKey="assertFilterByFirstOption"> + <argument name="attributeLabel" value="$createDropdownProductAttribute.attribute[frontend_labels][0][label]$"/> + <argument name="attributeOptionLabel" value="$getFirstDropdownProductAttributeOption.label$"/> + </actionGroup> + <actionGroup ref="AssertStorefrontProductIsPresentOnCategoryPageActionGroup" stepKey="assertFirstProductOnCatalogPage"> + <argument name="productName" value="$createFirstProduct.name$"/> + </actionGroup> + <actionGroup ref="StorefrontCheckProductIsMissingInCategoryProductsPageActionGroup" stepKey="assertSecondProductIsMissingOnCatalogPage"> + <argument name="productName" value="$createSecondProduct.name$"/> + </actionGroup> + <click selector="{{StorefrontCategorySidebarSection.removeFilter}}" stepKey="removeSideBarFilter"/> + <actionGroup ref="StorefrontFilterCategoryPageByAttributeOptionActionGroup" stepKey="filterCategoryBySecondOption"> + <argument name="attributeLabel" value="$createDropdownProductAttribute.attribute[frontend_labels][0][label]$"/> + <argument name="attributeOptionLabel" value="$getSecondDropdownProductAttributeOption.label$"/> + </actionGroup> + <actionGroup ref="StorefrontAssertAppliedFilterActionGroup" stepKey="assertFilterBySecondOption"> + <argument name="attributeLabel" value="$createDropdownProductAttribute.attribute[frontend_labels][0][label]$"/> + <argument name="attributeOptionLabel" value="$getSecondDropdownProductAttributeOption.label$"/> + </actionGroup> + <actionGroup ref="AssertStorefrontProductIsPresentOnCategoryPageActionGroup" stepKey="assertSecondProductOnCatalogPage"> + <argument name="productName" value="$createSecondProduct.name$"/> + </actionGroup> + <actionGroup ref="StorefrontCheckProductIsMissingInCategoryProductsPageActionGroup" stepKey="assertFirstProductIsMissingOnCatalogPage"> + <argument name="productName" value="$createFirstProduct.name$"/> + </actionGroup> + </test> +</tests> diff --git a/app/code/Magento/LayeredNavigation/view/frontend/templates/layer/filter.phtml b/app/code/Magento/LayeredNavigation/view/frontend/templates/layer/filter.phtml index 6b65d184b462a..83f40ab4911e7 100644 --- a/app/code/Magento/LayeredNavigation/view/frontend/templates/layer/filter.phtml +++ b/app/code/Magento/LayeredNavigation/view/frontend/templates/layer/filter.phtml @@ -4,10 +4,10 @@ * See COPYING.txt for license details. */ -// phpcs:disable Magento2.Templates.ThisInTemplate.FoundThis ?> <?php -/** @var $block \Magento\LayeredNavigation\Block\Navigation\FilterRenderer */ +/** @var \Magento\LayeredNavigation\Block\Navigation\FilterRenderer $block */ +/** @var \Magento\Framework\Escaper $escaper */ /** @var \Magento\LayeredNavigation\ViewModel\Layer\Filter $viewModel */ $viewModel = $block->getData('product_layer_view_model'); ?> @@ -16,28 +16,29 @@ $viewModel = $block->getData('product_layer_view_model'); <?php foreach ($filterItems as $filterItem): ?> <li class="item"> <?php if ($filterItem->getCount() > 0): ?> - <a href="<?= $block->escapeUrl($filterItem->getUrl()) ?>" rel="nofollow"> - <?= /* @noEscape */ $filterItem->getLabel() ?> - <?php if ($viewModel->shouldDisplayProductCountOnLayer()): ?> - <span class="count"><?= /* @noEscape */ (int)$filterItem->getCount() ?> - <span class="filter-count-label"> - <?php if ($filterItem->getCount() == 1): - ?> <?= $block->escapeHtml(__('item')) ?><?php + <a + href="<?= $escaper->escapeUrl($filterItem->getUrl()) ?>" + rel="nofollow" + ><?= /* @noEscape */ $filterItem->getLabel() ?><?php + if ($viewModel->shouldDisplayProductCountOnLayer()): ?><span + class="count"><?= /* @noEscape */ (int) $filterItem->getCount() ?><span + class="filter-count-label"><?php + if ($filterItem->getCount() == 1): ?> + <?= $escaper->escapeHtml(__('item')) ?><?php else: - ?> <?= $block->escapeHtml(__('item')) ?><?php + ?><?= $escaper->escapeHtml(__('item')) ?><?php endif;?></span></span> - <?php endif; ?> - </a> + <?php endif; ?></a> <?php else: ?> - <?= /* @noEscape */ $filterItem->getLabel() ?> - <?php if ($viewModel->shouldDisplayProductCountOnLayer()): ?> - <span class="count"><?= /* @noEscape */ (int)$filterItem->getCount() ?> - <span class="filter-count-label"> - <?php if ($filterItem->getCount() == 1): - ?><?= $block->escapeHtml(__('items')) ?><?php - else: - ?><?= $block->escapeHtml(__('items')) ?><?php - endif;?></span></span> + <?= /* @noEscape */ $filterItem->getLabel() ?><?php + if ($viewModel->shouldDisplayProductCountOnLayer()): ?><span + class="count"><?= /* @noEscape */ (int) $filterItem->getCount() ?><span + class="filter-count-label"><?php + if ($filterItem->getCount() == 1): ?> + <?= $escaper->escapeHtml(__('items')) ?><?php + else: + ?><?= $escaper->escapeHtml(__('items')) ?><?php + endif;?></span></span> <?php endif; ?> <?php endif; ?> </li> diff --git a/app/code/Magento/LoginAsCustomer/Model/ResourceModel/GetAuthenticationDataBySecret.php b/app/code/Magento/LoginAsCustomer/Model/ResourceModel/GetAuthenticationDataBySecret.php index 078eb93405299..0c417f78800a2 100644 --- a/app/code/Magento/LoginAsCustomer/Model/ResourceModel/GetAuthenticationDataBySecret.php +++ b/app/code/Magento/LoginAsCustomer/Model/ResourceModel/GetAuthenticationDataBySecret.php @@ -7,9 +7,11 @@ namespace Magento\LoginAsCustomer\Model\ResourceModel; +use Magento\Framework\App\ObjectManager; use Magento\Framework\App\ResourceConnection; -use Magento\Framework\Stdlib\DateTime\DateTime; +use Magento\Framework\Encryption\EncryptorInterface; use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\Stdlib\DateTime\DateTime; use Magento\LoginAsCustomerApi\Api\ConfigInterface; use Magento\LoginAsCustomerApi\Api\Data\AuthenticationDataInterface; use Magento\LoginAsCustomerApi\Api\Data\AuthenticationDataInterfaceFactory; @@ -40,22 +42,30 @@ class GetAuthenticationDataBySecret implements GetAuthenticationDataBySecretInte */ private $authenticationDataFactory; + /** + * @var EncryptorInterface + */ + private $encryptor; + /** * @param ResourceConnection $resourceConnection * @param DateTime $dateTime * @param ConfigInterface $config * @param AuthenticationDataInterfaceFactory $authenticationDataFactory + * @param EncryptorInterface|null $encryptor */ public function __construct( ResourceConnection $resourceConnection, DateTime $dateTime, ConfigInterface $config, - AuthenticationDataInterfaceFactory $authenticationDataFactory + AuthenticationDataInterfaceFactory $authenticationDataFactory, + ?EncryptorInterface $encryptor = null ) { $this->resourceConnection = $resourceConnection; $this->dateTime = $dateTime; $this->config = $config; $this->authenticationDataFactory = $authenticationDataFactory; + $this->encryptor = $encryptor ?? ObjectManager::getInstance()->get(EncryptorInterface::class); } /** @@ -71,9 +81,11 @@ public function execute(string $secret): AuthenticationDataInterface $this->dateTime->gmtTimestamp() - $this->config->getAuthenticationDataExpirationTime() ); + $hash = $this->encryptor->hash($secret); + $select = $connection->select() ->from(['main_table' => $tableName]) - ->where('main_table.secret = ?', $secret) + ->where('main_table.secret = ?', $hash) ->where('main_table.created_at > ?', $timePoint); $data = $connection->fetchRow($select); diff --git a/app/code/Magento/LoginAsCustomer/Model/ResourceModel/SaveAuthenticationData.php b/app/code/Magento/LoginAsCustomer/Model/ResourceModel/SaveAuthenticationData.php index d120b0eae392e..10d110c8ddf0b 100644 --- a/app/code/Magento/LoginAsCustomer/Model/ResourceModel/SaveAuthenticationData.php +++ b/app/code/Magento/LoginAsCustomer/Model/ResourceModel/SaveAuthenticationData.php @@ -7,9 +7,11 @@ namespace Magento\LoginAsCustomer\Model\ResourceModel; +use Magento\Framework\App\ObjectManager; use Magento\Framework\App\ResourceConnection; -use Magento\Framework\Stdlib\DateTime\DateTime; +use Magento\Framework\Encryption\EncryptorInterface; use Magento\Framework\Math\Random; +use Magento\Framework\Stdlib\DateTime\DateTime; use Magento\LoginAsCustomerApi\Api\Data\AuthenticationDataInterface; use Magento\LoginAsCustomerApi\Api\SaveAuthenticationDataInterface; @@ -18,6 +20,11 @@ */ class SaveAuthenticationData implements SaveAuthenticationDataInterface { + /** + * @var EncryptorInterface + */ + private $encryptor; + /** * @var ResourceConnection */ @@ -37,15 +44,18 @@ class SaveAuthenticationData implements SaveAuthenticationDataInterface * @param ResourceConnection $resourceConnection * @param DateTime $dateTime * @param Random $random + * @param EncryptorInterface $encryptor */ public function __construct( ResourceConnection $resourceConnection, DateTime $dateTime, - Random $random + Random $random, + ?EncryptorInterface $encryptor = null ) { $this->resourceConnection = $resourceConnection; $this->dateTime = $dateTime; $this->random = $random; + $this->encryptor = $encryptor ?? ObjectManager::getInstance()->get(EncryptorInterface::class); } /** @@ -57,16 +67,18 @@ public function execute(AuthenticationDataInterface $authenticationData): string $tableName = $this->resourceConnection->getTableName('login_as_customer'); $secret = $this->random->getRandomString(64); + $hash = $this->encryptor->hash($secret); $connection->insert( $tableName, [ 'customer_id' => $authenticationData->getCustomerId(), 'admin_id' => $authenticationData->getAdminId(), - 'secret' => $secret, + 'secret' => $hash, 'created_at' => $this->dateTime->gmtDate(), ] ); + return $secret; } } diff --git a/app/code/Magento/LoginAsCustomer/Test/Mftf/ActionGroup/AdminLoginAsCustomerLoginFromCustomerPageManualChooseActionGroup.xml b/app/code/Magento/LoginAsCustomer/Test/Mftf/ActionGroup/AdminLoginAsCustomerLoginFromCustomerPageManualChooseActionGroup.xml index 8db34a05252ee..dc953061ca433 100644 --- a/app/code/Magento/LoginAsCustomer/Test/Mftf/ActionGroup/AdminLoginAsCustomerLoginFromCustomerPageManualChooseActionGroup.xml +++ b/app/code/Magento/LoginAsCustomer/Test/Mftf/ActionGroup/AdminLoginAsCustomerLoginFromCustomerPageManualChooseActionGroup.xml @@ -14,15 +14,15 @@ </annotations> <arguments> <argument name="customerId" type="string"/> - <argument name="storeViewName" type="string" defaultValue="default"/> + <argument name="storeName" type="string" defaultValue="default"/> </arguments> <amOnPage url="{{AdminEditCustomerPage.url(customerId)}}" stepKey="gotoCustomerPage"/> <waitForPageLoad stepKey="waitForCustomerPageLoad"/> <click selector="{{AdminCustomerMainActionsSection.loginAsCustomer}}" stepKey="clickLoginAsCustomerLink"/> - <see selector="{{AdminConfirmationModalSection.title}}" userInput="Login as Customer: Select Store View" stepKey="seeModal"/> - <see selector="{{AdminConfirmationModalSection.message}}" userInput="Actions taken while in "Login as Customer" will affect actual customer data." stepKey="seeModalMessage"/> - <selectOption selector="{{AdminLoginAsCustomerConfirmationModalSection.storeView}}" userInput="{{storeViewName}}" stepKey="selectStoreView"/> + <see selector="{{AdminConfirmationModalSection.title}}" userInput="Login as Customer: Select Store" stepKey="seeModal"/> + <see selector="{{AdminConfirmationModalSection.message}}" userInput="Actions taken while in "Login as Customer" will affect actual customer data." stepKey="seeModalMessage"/> + <selectOption selector="{{AdminLoginAsCustomerConfirmationModalSection.store}}" userInput="{{storeName}}" stepKey="selectStore"/> <click selector="{{AdminConfirmationModalSection.ok}}" stepKey="clickLogin"/> <switchToNextTab stepKey="switchToNewTab"/> <waitForPageLoad stepKey="waitForPageLoad"/> diff --git a/app/code/Magento/LoginAsCustomer/Test/Mftf/ActionGroup/AdminLoginAsCustomerLoginFromOrderPageManualChooseActionGroup.xml b/app/code/Magento/LoginAsCustomer/Test/Mftf/ActionGroup/AdminLoginAsCustomerLoginFromOrderPageManualChooseActionGroup.xml new file mode 100644 index 0000000000000..e778ede05f9a5 --- /dev/null +++ b/app/code/Magento/LoginAsCustomer/Test/Mftf/ActionGroup/AdminLoginAsCustomerLoginFromOrderPageManualChooseActionGroup.xml @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminLoginAsCustomerLoginFromOrderPageManualChooseActionGroup"> + <annotations> + <description>Verify Login as Customer Login action is works properly from Order page with manual Store View choose.</description> + </annotations> + <arguments> + <argument name="orderId" type="string"/> + <argument name="storeName" type="string" defaultValue="default"/> + </arguments> + + <amOnPage url="{{AdminOrderPage.url(orderId)}}" stepKey="gotoOrderPage"/> + <waitForPageLoad stepKey="waitForCustomerPageLoad"/> + <click selector="{{AdminOrderDetailsMainActionsSection.loginAsCustomer}}" stepKey="clickLoginAsCustomerLink"/> + <see selector="{{AdminConfirmationModalSection.title}}" userInput="Login as Customer: Select Store" stepKey="seeModal"/> + <see selector="{{AdminConfirmationModalSection.message}}" userInput="Actions taken while in "Login as Customer" will affect actual customer data." stepKey="seeModalMessage"/> + <selectOption selector="{{AdminLoginAsCustomerConfirmationModalSection.store}}" userInput="{{storeName}}" stepKey="selectStore"/> + <click selector="{{AdminConfirmationModalSection.ok}}" stepKey="clickLogin"/> + <switchToNextTab stepKey="switchToNewTab"/> + <waitForPageLoad stepKey="waitForPageLoad"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/LoginAsCustomer/Test/Mftf/Section/AdminLoginAsCustomerConfirmationModalSection.xml b/app/code/Magento/LoginAsCustomer/Test/Mftf/Section/AdminLoginAsCustomerConfirmationModalSection.xml index f400ba02a5392..96a9ed6a77f90 100644 --- a/app/code/Magento/LoginAsCustomer/Test/Mftf/Section/AdminLoginAsCustomerConfirmationModalSection.xml +++ b/app/code/Magento/LoginAsCustomer/Test/Mftf/Section/AdminLoginAsCustomerConfirmationModalSection.xml @@ -9,6 +9,6 @@ <sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> <section name="AdminLoginAsCustomerConfirmationModalSection"> - <element name="storeView" type="select" selector="//select[@id='lac-confirmation-popup-store-id']"/> + <element name="store" type="select" selector="//select[@id='lac-confirmation-popup-store-id']"/> </section> </sections> diff --git a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerAutoDetectionTest.xml b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerAutoDetectionTest.xml index de9790894015c..1175103395427 100644 --- a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerAutoDetectionTest.xml +++ b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerAutoDetectionTest.xml @@ -11,7 +11,7 @@ <test name="AdminLoginAsCustomerAutoDetectionTest"> <annotations> <features value="Login as Customer"/> - <stories value="Select Store View based on 'Store View To Login In' setting"/> + <stories value="Select Store based on 'Store View To Login In' setting"/> <title value="Admin user directly login into customer account with store View To Login In = Auto detection"/> <description diff --git a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerManualChooseFromOrderPageTest.xml b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerManualChooseFromOrderPageTest.xml new file mode 100644 index 0000000000000..e4f0209c55233 --- /dev/null +++ b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerManualChooseFromOrderPageTest.xml @@ -0,0 +1,90 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminLoginAsCustomerManualChooseFromOrderPageTest"> + <annotations> + <features value="Login as Customer"/> + <stories value="Select Store based on 'Store View To Login In' setting"/> + <title + value="Admin user directly login into customer account with store View To Login In = Manual Choose on Order Page"/> + <description + value="Verify admin user can directly login into customer account to Custom store view when Store View To Login In = Manual Choose on Order Page"/> + <severity value="CRITICAL"/> + <group value="login_as_customer"/> + </annotations> + <before> + <createData entity="Simple_US_Customer_Assistance_Allowed" stepKey="createCustomer"/> + <createData entity="_defaultCategory" stepKey="createCategory"/> + <createData entity="SimpleProduct" stepKey="createProduct"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <magentoCLI command="config:set {{LoginAsCustomerConfigDataEnabled.path}} 1" + stepKey="enableLoginAsCustomer"/> + <magentoCLI command="config:set {{LoginAsCustomerStoreViewLogin.path}} 1" + stepKey="enableLoginAsCustomerManualChoose"/> + <magentoCLI command="cache:flush config" stepKey="flushCacheBeforeTestRun"/> + <actionGroup ref="AdminLoginActionGroup" stepKey="adminLogin"/> + <actionGroup ref="AdminCreateNewStoreGroupActionGroup" stepKey="createCustomStore"> + <argument name="website" value="{{_defaultWebsite.name}}"/> + <argument name="storeGroupName" value="{{customStoreGroup.name}}"/> + <argument name="storeGroupCode" value="{{customStoreGroup.code}}"/> + </actionGroup> + <actionGroup ref="AdminCreateStoreViewActionGroup" stepKey="createFirstCustomStoreView"> + <argument name="StoreGroup" value="customStoreGroup"/> + <argument name="customStore" value="customStoreEN"/> + </actionGroup> + <actionGroup ref="AdminCreateStoreViewActionGroup" stepKey="createSecondCustomStoreView"> + <argument name="StoreGroup" value="customStoreGroup"/> + <argument name="customStore" value="customStoreFR"/> + </actionGroup> + </before> + <after> + <actionGroup ref="DeleteCustomStoreActionGroup" stepKey="deleteCustomStore"> + <argument name="storeGroupName" value="customStoreGroup.name"/> + </actionGroup> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> + <magentoCLI command="config:set {{LoginAsCustomerConfigDataEnabled.path}} 0" + stepKey="disableLoginAsCustomer"/> + <magentoCLI command="config:set {{LoginAsCustomerStoreViewLogin.path}} 0" + stepKey="enableLoginAsCustomerAutoDetection"/> + <magentoCLI command="cache:flush config" stepKey="flushCacheAfterTestRun"/> + <deleteData createDataKey="createCustomer" stepKey="deleteCustomer"/> + <deleteData createDataKey="createProduct" stepKey="deleteProduct"/> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + </after> + + <!-- Create order --> + <actionGroup ref="CreateOrderInStoreActionGroup" stepKey="createOrder"> + <argument name="product" value="$$createProduct$$"/> + <argument name="customer" value="$$createCustomer$$"/> + <argument name="storeView" value="customStoreFR"/> + </actionGroup> + <grabFromCurrentUrl regex="~/order_id/(\d+)/~" stepKey="grabOrderId"/> + + <!-- Login as Customer from Order page --> + <actionGroup ref="AdminLoginAsCustomerLoginFromOrderPageManualChooseActionGroup" + stepKey="loginAsCustomerFromOrderPage"> + <argument name="orderId" value="{$grabOrderId}"/> + <argument name="storeName" value="{{customStoreGroup.name}}"/> + </actionGroup> + + <!-- Assert Customer logged on on custom store view --> + <actionGroup ref="StorefrontAssertLoginAsCustomerLoggedInActionGroup" stepKey="assertLoggedInFromCustomerGird"> + <argument name="customerFullName" value="$$createCustomer.firstname$$ $$createCustomer.lastname$$"/> + <argument name="customerEmail" value="$$createCustomer.email$$"/> + </actionGroup> + <actionGroup ref="StorefrontAssertCustomerOnStoreViewActionGroup" stepKey="assertCustomStoreView"> + <argument name="storeViewName" value="{{customStoreEN.name}}"/> + </actionGroup> + + <!-- Log out Customer and close tab --> + <actionGroup ref="StorefrontSignOutAndCloseTabActionGroup" stepKey="signOutAndCloseTab"/> + </test> +</tests> diff --git a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerManualChooseStoreCodeInUrlTest.xml b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerManualChooseStoreCodeInUrlTest.xml index 27aee2061f204..4f888e33ac801 100644 --- a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerManualChooseStoreCodeInUrlTest.xml +++ b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerManualChooseStoreCodeInUrlTest.xml @@ -11,7 +11,7 @@ <test name="AdminLoginAsCustomerManualChooseStoreCodeInUrlTest" extends="AdminLoginAsCustomerManualChooseTest"> <annotations> <features value="Login as Customer"/> - <stories value="Select Store View based on 'Store View To Login In' setting"/> + <stories value="Select Store based on 'Store View To Login In' setting"/> <title value="Admin user directly login into customer account with store View To Login In = Manual Choose when store code is added to url"/> <description @@ -29,8 +29,8 @@ command="config:set {{StorefrontDisableAddStoreCodeToUrls.path}} {{StorefrontDisableAddStoreCodeToUrls.value}}" stepKey="disableAddStoreCodeToUrls" after="enableLoginAsCustomerAutoDetection"/> </after> - <actionGroup ref="AssertStorefrontStoreCodeInUrlActionGroup" stepKey="seeCustomStoreCodeInUrl" after="assertCustomStoreView"> - <argument name="storeCode" value="{{customStore.code}}"/> + <actionGroup ref="AssertStorefrontStoreCodeInUrlActionGroup" stepKey="seeCustomStoreViewCodeInUrl" after="assertCustomStoreView"> + <argument name="storeCode" value="{{customStoreEN.code}}"/> </actionGroup> </test> </tests> diff --git a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerManualChooseTest.xml b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerManualChooseTest.xml index da966fdcc1291..5f706a814eb71 100644 --- a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerManualChooseTest.xml +++ b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerManualChooseTest.xml @@ -11,16 +11,13 @@ <test name="AdminLoginAsCustomerManualChooseTest"> <annotations> <features value="Login as Customer"/> - <stories value="Select Store View based on 'Store View To Login In' setting"/> + <stories value="Select Store based on 'Store View To Login In' setting"/> <title value="Admin user directly login into customer account with store View To Login In = Manual Choose"/> <description value="Verify admin user can directly login into customer account to Custom store view when Store View To Login In = Manual Choose"/> <severity value="CRITICAL"/> <group value="login_as_customer"/> - <skip> - <issueId value="https://github.com/magento/magento2-login-as-customer/issues/58"/> - </skip> </annotations> <before> <createData entity="Simple_US_Customer_Assistance_Allowed" stepKey="createCustomer"/> @@ -30,11 +27,23 @@ stepKey="enableLoginAsCustomerManualChoose"/> <magentoCLI command="cache:flush config" stepKey="flushCacheBeforeTestRun"/> <actionGroup ref="AdminLoginActionGroup" stepKey="adminLogin"/> - <actionGroup ref="AdminCreateStoreViewActionGroup" stepKey="createCustomStoreView"/> + <actionGroup ref="AdminCreateNewStoreGroupActionGroup" stepKey="createCustomStore"> + <argument name="website" value="{{_defaultWebsite.name}}"/> + <argument name="storeGroupName" value="{{customStoreGroup.name}}"/> + <argument name="storeGroupCode" value="{{customStoreGroup.code}}"/> + </actionGroup> + <actionGroup ref="AdminCreateStoreViewActionGroup" stepKey="createFirstCustomStoreView"> + <argument name="StoreGroup" value="customStoreGroup"/> + <argument name="customStore" value="customStoreEN"/> + </actionGroup> + <actionGroup ref="AdminCreateStoreViewActionGroup" stepKey="createSecondCustomStoreView"> + <argument name="StoreGroup" value="customStoreGroup"/> + <argument name="customStore" value="customStoreFR"/> + </actionGroup> </before> <after> - <actionGroup ref="AdminDeleteStoreViewActionGroup" stepKey="deleteCustomStoreView"> - <argument name="customStore" value="customStore"/> + <actionGroup ref="DeleteCustomStoreActionGroup" stepKey="deleteCustomStore"> + <argument name="storeGroupName" value="customStoreGroup.name"/> </actionGroup> <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> <magentoCLI command="config:set {{LoginAsCustomerConfigDataEnabled.path}} 0" @@ -49,7 +58,7 @@ <actionGroup ref="AdminLoginAsCustomerLoginFromCustomerPageManualChooseActionGroup" stepKey="loginAsCustomerFromCustomerPage"> <argument name="customerId" value="$$createCustomer.id$$"/> - <argument name="storeViewName" value="{{customStore.name}}"/> + <argument name="storeName" value="{{customStoreGroup.name}}"/> </actionGroup> <!-- Assert Customer logged on on custom store view --> @@ -58,7 +67,7 @@ <argument name="customerEmail" value="$$createCustomer.email$$"/> </actionGroup> <actionGroup ref="StorefrontAssertCustomerOnStoreViewActionGroup" stepKey="assertCustomStoreView"> - <argument name="storeViewName" value="{{customStore.name}}"/> + <argument name="storeViewName" value="{{customStoreEN.name}}"/> </actionGroup> <!-- Log out Customer and close tab --> diff --git a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminUIShownIfLoginAsCustomerEnabledTest.xml b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminUIShownIfLoginAsCustomerEnabledTest.xml index ea06263901b9e..02a96df93eae4 100644 --- a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminUIShownIfLoginAsCustomerEnabledTest.xml +++ b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminUIShownIfLoginAsCustomerEnabledTest.xml @@ -23,13 +23,18 @@ stepKey="enableLoginAsCustomer"/> <magentoCLI command="config:set {{LoginAsCustomerStoreViewLogin.path}} 0" stepKey="enableLoginAsCustomerAutoDetection"/> - <magentoCLI command="cache:flush config" stepKey="flushCacheBeforeTestRun"/> <createData entity="_defaultCategory" stepKey="createCategory"/> <createData entity="SimpleProduct" stepKey="createSimpleProduct"> <requiredEntity createDataKey="createCategory"/> </createData> <createData entity="Simple_US_Customer_Assistance_Allowed" stepKey="createCustomer"/> <actionGroup ref="AdminLoginActionGroup" stepKey="login"/> + <actionGroup ref="CliIndexerReindexActionGroup" stepKey="reindex"> + <argument name="indices" value=""/> + </actionGroup> + <actionGroup ref="CliCacheCleanActionGroup" stepKey="cleanInvalidatedCachesAfterSet"> + <argument name="tags" value="config full_page"/> + </actionGroup> </before> <after> <deleteData createDataKey="createCustomer" stepKey="deleteCustomer"/> @@ -39,7 +44,12 @@ <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> <magentoCLI command="config:set {{LoginAsCustomerConfigDataEnabled.path}} 0" stepKey="disableLoginAsCustomer"/> - <magentoCLI command="cache:flush config" stepKey="flushCacheAfterTestRun"/> + <actionGroup ref="CliIndexerReindexActionGroup" stepKey="reindexAfter"> + <argument name="indices" value=""/> + </actionGroup> + <actionGroup ref="CliCacheCleanActionGroup" stepKey="cleanInvalidatedCachesDefault"> + <argument name="tags" value="config full_page"/> + </actionGroup> </after> <!-- Verify Login as Customer Login action works correctly from Customer page --> @@ -59,10 +69,7 @@ <argument name="product" value="$$createSimpleProduct$$"/> <argument name="customer" value="$$createCustomer$$"/> </actionGroup> - <grabTextFrom selector="{{AdminOrderDetailsInformationSection.orderId}}" stepKey="grabOrderId"/> - <actionGroup ref="FilterOrderGridByIdActionGroup" stepKey="filterOrderGridById"> - <argument name="orderId" value="$grabOrderId"/> - </actionGroup> + <grabFromCurrentUrl regex="~/order_id/(\d+)/~" stepKey="grabOrderId"/> <!-- Verify Login as Customer Login action works correctly from Order page --> <actionGroup ref="AdminLoginAsCustomerLoginFromOrderPageActionGroup" diff --git a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/StorefrontLoginAsCustomerSeeSpecialPriceOnCategoryTest.xml b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/StorefrontLoginAsCustomerSeeSpecialPriceOnCategoryTest.xml index 3e70da8f8158d..c0d7d26816fa2 100644 --- a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/StorefrontLoginAsCustomerSeeSpecialPriceOnCategoryTest.xml +++ b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/StorefrontLoginAsCustomerSeeSpecialPriceOnCategoryTest.xml @@ -16,9 +16,6 @@ <description value="Login as customer sees special prices on category"/> <severity value="CRITICAL"/> <group value="login_as_customer"/> - <skip> - <issueId value="https://github.com/magento/magento2-login-as-customer/pull/193"/> - </skip> </annotations> <before> <magentoCLI command="config:set {{LoginAsCustomerConfigDataEnabled.path}} 1" diff --git a/app/code/Magento/LoginAsCustomerAdminUi/Block/Adminhtml/ConfirmationPopup.php b/app/code/Magento/LoginAsCustomerAdminUi/Block/Adminhtml/ConfirmationPopup.php index ce5a5501fbe55..646a0df296101 100644 --- a/app/code/Magento/LoginAsCustomerAdminUi/Block/Adminhtml/ConfirmationPopup.php +++ b/app/code/Magento/LoginAsCustomerAdminUi/Block/Adminhtml/ConfirmationPopup.php @@ -8,7 +8,9 @@ namespace Magento\LoginAsCustomerAdminUi\Block\Adminhtml; use Magento\Backend\Block\Template; +use Magento\Framework\App\ObjectManager; use Magento\Framework\Serialize\Serializer\Json; +use Magento\LoginAsCustomerAdminUi\Ui\Customer\Component\ConfirmationPopup\Options; use Magento\LoginAsCustomerApi\Api\ConfigInterface; use Magento\Store\Ui\Component\Listing\Column\Store\Options as StoreOptions; @@ -35,24 +37,32 @@ class ConfirmationPopup extends Template */ private $json; + /** + * @var Options + */ + private $options; + /** * @param Template\Context $context * @param StoreOptions $storeOptions * @param ConfigInterface $config * @param Json $json * @param array $data + * @param Options|null $options */ public function __construct( Template\Context $context, StoreOptions $storeOptions, ConfigInterface $config, Json $json, - array $data = [] + array $data = [], + ?Options $options = null ) { parent::__construct($context, $data); $this->storeOptions = $storeOptions; $this->config = $config; $this->json = $json; + $this->options = $options ?? ObjectManager::getInstance()->get(Options::class); } /** @@ -65,14 +75,14 @@ public function getJsLayout() $showStoreViewOptions = $this->config->isStoreManualChoiceEnabled(); $layout['components']['lac-confirmation-popup']['title'] = $showStoreViewOptions - ? __('Login as Customer: Select Store View') + ? __('Login as Customer: Select Store') : __('You are about to Login as Customer'); $layout['components']['lac-confirmation-popup']['content'] = __('Actions taken while in "Login as Customer" will affect actual customer data.'); $layout['components']['lac-confirmation-popup']['showStoreViewOptions'] = $showStoreViewOptions; $layout['components']['lac-confirmation-popup']['storeViewOptions'] = $showStoreViewOptions - ? $this->storeOptions->toOptionArray() + ? $this->options->toOptionArray() : []; return $this->json->serialize($layout); diff --git a/app/code/Magento/LoginAsCustomerAdminUi/Controller/Adminhtml/Login/Login.php b/app/code/Magento/LoginAsCustomerAdminUi/Controller/Adminhtml/Login/Login.php index 39a7055ed65bb..e80c3700349df 100644 --- a/app/code/Magento/LoginAsCustomerAdminUi/Controller/Adminhtml/Login/Login.php +++ b/app/code/Magento/LoginAsCustomerAdminUi/Controller/Adminhtml/Login/Login.php @@ -11,6 +11,7 @@ use Magento\Backend\App\Action\Context; use Magento\Backend\Model\Auth\Session; use Magento\Customer\Api\CustomerRepositoryInterface; +use Magento\Customer\Model\Config\Share; use Magento\Framework\App\Action\HttpGetActionInterface; use Magento\Framework\App\ObjectManager; use Magento\Framework\Controller\Result\Redirect; @@ -27,6 +28,7 @@ use Magento\LoginAsCustomerApi\Api\SaveAuthenticationDataInterface; use Magento\LoginAsCustomerApi\Api\SetLoggedAsCustomerCustomerIdInterface; use Magento\Store\Model\StoreManagerInterface; +use Magento\Store\Model\StoreSwitcher\ManageStoreCookie; /** * Login as customer action @@ -83,6 +85,16 @@ class Login extends Action implements HttpGetActionInterface */ private $url; + /** + * @var Share + */ + private $share; + + /** + * @var ManageStoreCookie + */ + private $manageStoreCookie; + /** * @var SetLoggedAsCustomerCustomerIdInterface */ @@ -103,6 +115,8 @@ class Login extends Action implements HttpGetActionInterface * @param SaveAuthenticationDataInterface $saveAuthenticationData * @param DeleteAuthenticationDataForUserInterface $deleteAuthenticationDataForUser * @param Url $url + * @param Share $share + * @param ManageStoreCookie $manageStoreCookie * @param SetLoggedAsCustomerCustomerIdInterface $setLoggedAsCustomerCustomerId * @param IsLoginAsCustomerEnabledForCustomerInterface $isLoginAsCustomerEnabled * @@ -118,6 +132,8 @@ public function __construct( SaveAuthenticationDataInterface $saveAuthenticationData, DeleteAuthenticationDataForUserInterface $deleteAuthenticationDataForUser, Url $url, + ?Share $share = null, + ?ManageStoreCookie $manageStoreCookie = null, ?SetLoggedAsCustomerCustomerIdInterface $setLoggedAsCustomerCustomerId = null, ?IsLoginAsCustomerEnabledForCustomerInterface $isLoginAsCustomerEnabled = null ) { @@ -131,6 +147,8 @@ public function __construct( $this->saveAuthenticationData = $saveAuthenticationData; $this->deleteAuthenticationDataForUser = $deleteAuthenticationDataForUser; $this->url = $url; + $this->share = $share ?? ObjectManager::getInstance()->get(Share::class); + $this->manageStoreCookie = $manageStoreCookie ?? ObjectManager::getInstance()->get(ManageStoreCookie::class); $this->setLoggedAsCustomerCustomerId = $setLoggedAsCustomerCustomerId ?? ObjectManager::getInstance()->get(SetLoggedAsCustomerCustomerIdInterface::class); $this->isLoginAsCustomerEnabled = $isLoginAsCustomerEnabled @@ -173,9 +191,11 @@ public function execute(): ResultInterface if ($this->config->isStoreManualChoiceEnabled()) { $storeId = (int)$this->_request->getParam('store_id'); if (empty($storeId)) { - $this->messageManager->addNoticeMessage(__('Please select a Store View to login in.')); + $this->messageManager->addNoticeMessage(__('Please select a Store to login in.')); return $resultRedirect->setPath('customer/index/edit', ['id' => $customerId]); } + } elseif ($this->share->isGlobalScope()) { + $storeId = (int)$this->storeManager->getDefaultStoreView()->getId(); } else { $storeId = (int)$customer->getStoreId(); } @@ -211,10 +231,17 @@ public function execute(): ResultInterface */ private function getLoginProceedRedirectUrl(string $secret, int $storeId): string { - $store = $this->storeManager->getStore($storeId); + $targetStore = $this->storeManager->getStore($storeId); - return $this->url - ->setScope($store) + $redirectUrl = $this->url + ->setScope($targetStore) ->getUrl('loginascustomer/login/index', ['secret' => $secret, '_nosid' => true]); + + if (!$targetStore->isUseStoreInUrl()) { + $fromStore = $this->storeManager->getStore(); + $redirectUrl = $this->manageStoreCookie->switch($fromStore, $targetStore, $redirectUrl); + } + + return $redirectUrl; } } diff --git a/app/code/Magento/LoginAsCustomerAdminUi/Ui/Customer/Component/ConfirmationPopup/Options.php b/app/code/Magento/LoginAsCustomerAdminUi/Ui/Customer/Component/ConfirmationPopup/Options.php new file mode 100644 index 0000000000000..791ed35519850 --- /dev/null +++ b/app/code/Magento/LoginAsCustomerAdminUi/Ui/Customer/Component/ConfirmationPopup/Options.php @@ -0,0 +1,262 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\LoginAsCustomerAdminUi\Ui\Customer\Component\ConfirmationPopup; + +use Magento\Customer\Api\CustomerRepositoryInterface; +use Magento\Customer\Api\Data\CustomerInterface; +use Magento\Customer\Model\Config\Share; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\App\RequestInterface; +use Magento\Framework\Data\OptionSourceInterface; +use Magento\Framework\Escaper; +use Magento\Framework\Exception\LocalizedException; +use Magento\Sales\Api\CreditmemoRepositoryInterface; +use Magento\Sales\Api\InvoiceRepositoryInterface; +use Magento\Sales\Api\OrderRepositoryInterface; +use Magento\Sales\Api\ShipmentRepositoryInterface; +use Magento\Store\Model\Group; +use Magento\Store\Model\System\Store as SystemStore; +use Magento\Store\Model\Website; + +/** + * Store group options for Login As Customer confirmation pop-up. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ +class Options implements OptionSourceInterface +{ + /** + * @var CustomerRepositoryInterface + */ + private $customerRepository; + + /** + * @var Escaper + */ + private $escaper; + + /** + * @var RequestInterface + */ + private $request; + + /** + * @var Share + */ + private $share; + + /** + * @var SystemStore + */ + private $systemStore; + + /** + * @var array + */ + private $options; + + /** + * @var OrderRepositoryInterface + */ + private $orderRepository; + + /** + * @var InvoiceRepositoryInterface + */ + private $invoiceRepository; + + /** + * @var ShipmentRepositoryInterface + */ + private $shipmentRepository; + + /** + * @var CreditmemoRepositoryInterface + */ + private $creditmemoRepository; + + /** + * @param CustomerRepositoryInterface $customerRepository + * @param Escaper $escaper + * @param RequestInterface $request + * @param Share $share + * @param SystemStore $systemStore + * @param OrderRepositoryInterface|null $orderRepository + * @param InvoiceRepositoryInterface|null $invoiceRepository + * @param ShipmentRepositoryInterface|null $shipmentRepository + * @param CreditmemoRepositoryInterface|null $creditmemoRepository + */ + public function __construct( + CustomerRepositoryInterface $customerRepository, + Escaper $escaper, + RequestInterface $request, + Share $share, + SystemStore $systemStore, + ?OrderRepositoryInterface $orderRepository = null, + ?InvoiceRepositoryInterface $invoiceRepository = null, + ?ShipmentRepositoryInterface $shipmentRepository = null, + ?CreditmemoRepositoryInterface $creditmemoRepository = null + ) { + $this->customerRepository = $customerRepository; + $this->escaper = $escaper; + $this->request = $request; + $this->share = $share; + $this->systemStore = $systemStore; + $this->orderRepository = $orderRepository + ?? ObjectManager::getInstance()->get(OrderRepositoryInterface::class); + $this->invoiceRepository = $invoiceRepository + ?? ObjectManager::getInstance()->get(InvoiceRepositoryInterface::class); + $this->shipmentRepository = $shipmentRepository + ?? ObjectManager::getInstance()->get(ShipmentRepositoryInterface::class); + $this->creditmemoRepository = $creditmemoRepository + ?? ObjectManager::getInstance()->get(CreditmemoRepositoryInterface::class); + } + + /** + * @inheritdoc + * + * @throws LocalizedException + */ + public function toOptionArray(): array + { + if ($this->options !== null) { + return $this->options; + } + + $customerId = $this->getCustomerId(); + $this->options = $this->generateCurrentOptions($customerId); + + return $this->options; + } + + /** + * Sanitize website/store option name. + * + * @param string $name + * + * @return string + */ + private function sanitizeName(string $name): string + { + $matches = []; + preg_match('/\$[:]*{(.)*}/', $name, $matches); + if (count($matches) > 0) { + $name = $this->escaper->escapeHtml($this->escaper->escapeJs($name)); + } else { + $name = $this->escaper->escapeHtml($name); + } + + return $name; + } + + /** + * Generate current options. + * + * @param int $customerId + * @return array + */ + private function generateCurrentOptions(int $customerId): array + { + $options = []; + if ($customerId) { + $customer = $this->customerRepository->getById($customerId); + $websiteCollection = $this->systemStore->getWebsiteCollection(); + /** @var Website $website */ + foreach ($websiteCollection as $website) { + $groups = $this->fillStoreGroupOptions($website, $customer); + if (!empty($groups)) { + $code = $website->getCode(); + $name = $this->sanitizeName($website->getName()); + $options[$code]['label'] = $name; + $options[$code]['value'] = $groups; + } + } + } + + return $options; + } + + /** + * Fill Store Group options array. + * + * @param Website $website + * @param CustomerInterface $customer + * @return array + */ + private function fillStoreGroupOptions(Website $website, CustomerInterface $customer): array + { + $groups = []; + $groupCollection = $this->systemStore->getGroupCollection(); + $isGlobalScope = $this->share->isGlobalScope(); + $customerWebsiteId = $customer->getWebsiteId(); + $customerStoreId = $customer->getStoreId(); + $websiteId = $website->getId(); + /** @var Group $group */ + foreach ($groupCollection as $group) { + if ($group->getWebsiteId() == $websiteId) { + $storeViewIds = $group->getStoreIds(); + if (!empty($storeViewIds)) { + $code = $group->getCode(); + $name = $this->sanitizeName($group->getName()); + $groups[$code]['label'] = str_repeat(' ', 4) . $name; + $groups[$code]['value'] = array_values($storeViewIds)[0]; + $groups[$code]['disabled'] = !$isGlobalScope && $customerWebsiteId !== $websiteId; + $groups[$code]['selected'] = in_array($customerStoreId, $storeViewIds) ? true : false; + } + } + } + + return $groups; + } + + /** + * Get Customer id from request param. + * + * @return int + * @throws LocalizedException + */ + private function getCustomerId(): int + { + $customerId = $this->request->getParam('id'); + if ($customerId) { + return (int)$customerId; + } + try { + $orderId = $this->getOrderId(); + } catch (LocalizedException $exception) { + throw new LocalizedException(__('Unable to get Customer ID.')); + } + + return (int)$this->orderRepository->get($orderId)->getCustomerId(); + } + + /** + * Get Order id from request param + * + * @return int + * @throws LocalizedException + */ + private function getOrderId(): int + { + $orderId = $this->request->getParam('order_id'); + if ($orderId) { + return (int)$orderId; + } + $shipmentId = $this->request->getParam('shipment_id'); + $creditmemoId = $this->request->getParam('creditmemo_id'); + $invoiceId = $this->request->getParam('invoice_id'); + if ($invoiceId) { + return (int)$this->invoiceRepository->get($invoiceId)->getOrderId(); + } elseif ($shipmentId) { + return (int)$this->shipmentRepository->get($shipmentId)->getOrderId(); + } elseif ($creditmemoId) { + return (int)$this->creditmemoRepository->get($creditmemoId)->getOrderId(); + } + throw new LocalizedException(__('Unable to get Order ID.')); + } +} diff --git a/app/code/Magento/LoginAsCustomerAdminUi/composer.json b/app/code/Magento/LoginAsCustomerAdminUi/composer.json index 8bbe0e2bd6c9e..b6291226827a8 100644 --- a/app/code/Magento/LoginAsCustomerAdminUi/composer.json +++ b/app/code/Magento/LoginAsCustomerAdminUi/composer.json @@ -8,6 +8,7 @@ "magento/module-login-as-customer-frontend-ui": "*", "magento/module-backend": "*", "magento/module-customer": "*", + "magento/module-sales": "*", "magento/module-store": "*" }, "suggest": { diff --git a/app/code/Magento/LoginAsCustomerAdminUi/etc/adminhtml/system.xml b/app/code/Magento/LoginAsCustomerAdminUi/etc/adminhtml/system.xml index 580f478f32780..fb8d990e5bab2 100644 --- a/app/code/Magento/LoginAsCustomerAdminUi/etc/adminhtml/system.xml +++ b/app/code/Magento/LoginAsCustomerAdminUi/etc/adminhtml/system.xml @@ -23,7 +23,7 @@ <source_model>Magento\LoginAsCustomerAdminUi\Model\Config\Source\StoreViewLogin</source_model> <comment><![CDATA[ Use the "Manual Selection" option on a multi-website setup that has "Share Customer Accounts" enabled globally. - If set to "Manual Selection", the "Login as Customer" admin can select a store view after logging in. + If set to "Manual Selection", the "Login as Customer" admin can select a Store after logging in. ]]></comment> </field> </group> diff --git a/app/code/Magento/LoginAsCustomerAdminUi/view/adminhtml/web/template/confirmation-popup/store-view-ptions.html b/app/code/Magento/LoginAsCustomerAdminUi/view/adminhtml/web/template/confirmation-popup/store-view-ptions.html index ed1f991245e70..916a5583abe57 100644 --- a/app/code/Magento/LoginAsCustomerAdminUi/view/adminhtml/web/template/confirmation-popup/store-view-ptions.html +++ b/app/code/Magento/LoginAsCustomerAdminUi/view/adminhtml/web/template/confirmation-popup/store-view-ptions.html @@ -18,10 +18,10 @@ <% _.each(data.storeViewOptions, function(website) { %> <optgroup label="<%= website.label %>"></optgroup> <% _.each(website.value, function(group) { %> - <optgroup label="<%= group.label %>"></optgroup> - <% _.each(group.value, function(storeview) { %> - <option value="<%= storeview.value %>"><%= storeview.label %></option> - <% }); %> + <option + <% if(group.disabled){ %>disabled <% } %> + <% if(group.selected){ %>selected <% } %> + value="<%= group.value %>"><%= group.label %></option> <% }); %> <% }); %> </select> diff --git a/app/code/Magento/LoginAsCustomerAssistance/Block/Adminhtml/NotAllowedPopup.php b/app/code/Magento/LoginAsCustomerAssistance/Block/Adminhtml/NotAllowedPopup.php index b98ea203057b1..520a1168b688f 100644 --- a/app/code/Magento/LoginAsCustomerAssistance/Block/Adminhtml/NotAllowedPopup.php +++ b/app/code/Magento/LoginAsCustomerAssistance/Block/Adminhtml/NotAllowedPopup.php @@ -13,8 +13,6 @@ /** * Pop-up for Login as Customer button then Login as Customer is not allowed. - * - * @api */ class NotAllowedPopup extends Template { diff --git a/app/code/Magento/LoginAsCustomerAssistance/etc/acl.xml b/app/code/Magento/LoginAsCustomerAssistance/etc/acl.xml index 2c16b5a9125df..fd16eb2e51b03 100644 --- a/app/code/Magento/LoginAsCustomerAssistance/etc/acl.xml +++ b/app/code/Magento/LoginAsCustomerAssistance/etc/acl.xml @@ -10,7 +10,7 @@ <resources> <resource id="Magento_Backend::admin"> <resource id="Magento_Customer::customer"> - <resource id="Magento_LoginAsCustomer::login"> + <resource id="Magento_LoginAsCustomer::login" title="Login as Customer"> <resource id="Magento_LoginAsCustomer::allow_shopping_assistance" title="Allow remote shopping assistance" sortOrder="20" /> </resource> </resource> diff --git a/app/code/Magento/LoginAsCustomerAssistance/view/adminhtml/web/js/not-allowed-popup.js b/app/code/Magento/LoginAsCustomerAssistance/view/adminhtml/web/js/not-allowed-popup.js index 59d8dd4a7ed49..2e54a249c5be6 100644 --- a/app/code/Magento/LoginAsCustomerAssistance/view/adminhtml/web/js/not-allowed-popup.js +++ b/app/code/Magento/LoginAsCustomerAssistance/view/adminhtml/web/js/not-allowed-popup.js @@ -35,7 +35,7 @@ define([ modalClass: 'confirm lac-confirm', buttons: [ { - text: $t('Cancel'), + text: $t('Close'), class: 'action-secondary action-dismiss', /** diff --git a/app/code/Magento/Marketplace/Test/Unit/Controller/Partners/IndexTest.php b/app/code/Magento/Marketplace/Test/Unit/Controller/Partners/IndexTest.php index ae4042536eb0c..3f44c5616d597 100644 --- a/app/code/Magento/Marketplace/Test/Unit/Controller/Partners/IndexTest.php +++ b/app/code/Magento/Marketplace/Test/Unit/Controller/Partners/IndexTest.php @@ -93,7 +93,7 @@ public function getControllerIndexMock($methods = null) */ public function getLayoutFactoryMock($methods = null) { - return $this->createPartialMock(LayoutFactory::class, $methods, []); + return $this->createPartialMock(LayoutFactory::class, $methods); } /** @@ -109,7 +109,7 @@ public function getLayoutMock() */ public function getResponseMock($methods = null) { - return $this->createPartialMock(Response::class, $methods, []); + return $this->createPartialMock(Response::class, $methods); } /** @@ -117,7 +117,7 @@ public function getResponseMock($methods = null) */ public function getRequestMock($methods = null) { - return $this->createPartialMock(Http::class, $methods, []); + return $this->createPartialMock(Http::class, $methods); } /** diff --git a/app/code/Magento/Marketplace/Test/Unit/Model/PartnersTest.php b/app/code/Magento/Marketplace/Test/Unit/Model/PartnersTest.php index 76bb7be6e8b19..59928f40ca599 100644 --- a/app/code/Magento/Marketplace/Test/Unit/Model/PartnersTest.php +++ b/app/code/Magento/Marketplace/Test/Unit/Model/PartnersTest.php @@ -140,7 +140,7 @@ public function getPartnersBlockMock($methods = null) */ public function getPartnersModelMock($methods) { - return $this->createPartialMock(Partners::class, $methods, []); + return $this->createPartialMock(Partners::class, $methods); } /** @@ -150,7 +150,7 @@ public function getPartnersModelMock($methods) */ public function getCurlMock($methods) { - return $this->createPartialMock(Curl::class, $methods, []); + return $this->createPartialMock(Curl::class, $methods); } /** @@ -160,6 +160,6 @@ public function getCurlMock($methods) */ public function getCacheMock($methods) { - return $this->createPartialMock(Cache::class, $methods, []); + return $this->createPartialMock(Cache::class, $methods); } } diff --git a/app/code/Magento/MediaContentApi/Model/Composite/GetAssetIdsByContentField.php b/app/code/Magento/MediaContentApi/Model/Composite/GetAssetIdsByContentField.php index 61df8504b4c77..946550dc70a4d 100644 --- a/app/code/Magento/MediaContentApi/Model/Composite/GetAssetIdsByContentField.php +++ b/app/code/Magento/MediaContentApi/Model/Composite/GetAssetIdsByContentField.php @@ -42,9 +42,8 @@ public function execute(string $field, string $value): array $ids = []; /** @var GetAssetIdsByContentFieldInterface $fieldHandler */ foreach ($this->fieldHandlers[$field] as $fieldHandler) { - // phpcs:ignore Magento2.Performance.ForeachArrayMerge - $ids = array_merge($ids, $fieldHandler->execute($value)); + $ids[] = $fieldHandler->execute($value); } - return array_unique($ids); + return array_unique(array_merge([], ...$ids)); } } diff --git a/app/code/Magento/MediaGalleryCatalogUi/Test/Mftf/ActionGroup/AdminAssertMediaGalleryFilterPlaceHolderGridActionGroup.xml b/app/code/Magento/MediaGalleryCatalogUi/Test/Mftf/ActionGroup/AdminAssertMediaGalleryFilterPlaceHolderGridActionGroup.xml index e21fa89965391..32065da7bb1e7 100644 --- a/app/code/Magento/MediaGalleryCatalogUi/Test/Mftf/ActionGroup/AdminAssertMediaGalleryFilterPlaceHolderGridActionGroup.xml +++ b/app/code/Magento/MediaGalleryCatalogUi/Test/Mftf/ActionGroup/AdminAssertMediaGalleryFilterPlaceHolderGridActionGroup.xml @@ -15,6 +15,8 @@ <argument name="filterPlaceholder" type="string"/> </arguments> + <waitForPageLoad stepKey="waitVisibleFilter"/> + <waitForElementVisible selector="{{AdminProductGridFilterSection.enabledFilters}}" stepKey="waitForRequest"/> <see selector="{{AdminProductGridFilterSection.enabledFilters}}" userInput="{{filterPlaceholder}}" stepKey="seeFilter"/> </actionGroup> </actionGroups> diff --git a/app/code/Magento/MediaGalleryCatalogUi/Test/Mftf/Test/AdminMediaGalleryCatalogUiEditCategoryFromGridPageTest.xml b/app/code/Magento/MediaGalleryCatalogUi/Test/Mftf/Test/AdminMediaGalleryCatalogUiEditCategoryFromGridPageTest.xml new file mode 100644 index 0000000000000..2beb0ad12e5d0 --- /dev/null +++ b/app/code/Magento/MediaGalleryCatalogUi/Test/Mftf/Test/AdminMediaGalleryCatalogUiEditCategoryFromGridPageTest.xml @@ -0,0 +1,39 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminMediaGalleryCatalogUiEditCategoryFromGridPageTest"> + <annotations> + <features value="MediaGalleryCatalogUi"/> + <stories value="Story 58: User sees entities where asset is used in" /> + <title value="User Edits Category from Category grid"/> + <description value="Edit Category from Media Gallery Category Grid"/> + <testCaseId value="https://studio.cucumber.io/projects/131313/test-plan/folders/1320712/scenarios/5034526"/> + <useCaseId value="https://github.com/magento/adobe-stock-integration/issues/1667"/> + <severity value="CRITICAL"/> + <group value="media_gallery_ui"/> + </annotations> + + <before> + <createData entity="SimpleSubCategory" stepKey="category"/> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + </before> + + <after> + <deleteData createDataKey="category" stepKey="deleteCategory"/> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logoutFromAdmin"/> + </after> + + <actionGroup ref="AdminOpenCategoryGridPageActionGroup" stepKey="openCategoryPage"/> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="resetGridFilters"/> + <actionGroup ref="AdminEditCategoryInGridPageActionGroup" stepKey="editCategoryItem"> + <argument name="categoryName" value="$category.name$"/> + </actionGroup> + <actionGroup ref="AdminAssertCategoryPageTitleActionGroup" stepKey="assertCategoryByName"/> + </test> +</tests> diff --git a/app/code/Magento/MediaGalleryCatalogUi/Test/Mftf/Test/AdminMediaGalleryCatalogUiEditCategoryGridPageTest.xml b/app/code/Magento/MediaGalleryCatalogUi/Test/Mftf/Test/AdminMediaGalleryCatalogUiEditCategoryGridPageTest.xml index 2a606d8ab6a9e..739b25d1ce0ed 100644 --- a/app/code/Magento/MediaGalleryCatalogUi/Test/Mftf/Test/AdminMediaGalleryCatalogUiEditCategoryGridPageTest.xml +++ b/app/code/Magento/MediaGalleryCatalogUi/Test/Mftf/Test/AdminMediaGalleryCatalogUiEditCategoryGridPageTest.xml @@ -7,16 +7,19 @@ --> <tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> - <test name="AdminMediaGalleryCatalogUiEditCategoryGridPageTest"> + <test name="AdminMediaGalleryCatalogUiEditCategoryGridPageTest" deprecated="Use AdminMediaGalleryCatalogUiEditCategoryFromGridPageTest instead"> <annotations> <features value="AdminMediaGalleryCategoryGrid"/> <useCaseId value="https://github.com/magento/adobe-stock-integration/issues/1667"/> - <title value="User Edits Category from Category grid"/> + <title value="DEPRECATED. User Edits Category from Category grid"/> <stories value="Story 58: User sees entities where asset is used in" /> <testCaseId value="https://studio.cucumber.io/projects/131313/test-plan/folders/1320712/scenarios/5034526"/> <description value="Edit Category from Media Gallery Category Grid"/> <severity value="CRITICAL"/> <group value="media_gallery_ui"/> + <skip> + <issueId value="DEPRECATED">Use AdminMediaGalleryCatalogUiEditCategoryFromGridPageTest instead</issueId> + </skip> </annotations> <before> <createData entity="SimpleSubCategory" stepKey="category"/> diff --git a/app/code/Magento/MediaGalleryCatalogUi/Test/Mftf/Test/AdminMediaGalleryCatalogUiUsedInCategoryFilterTest.xml b/app/code/Magento/MediaGalleryCatalogUi/Test/Mftf/Test/AdminMediaGalleryCatalogUiUsedInCategoryFilterTest.xml index a495e2ff07e6a..df3ac35c0bfcd 100644 --- a/app/code/Magento/MediaGalleryCatalogUi/Test/Mftf/Test/AdminMediaGalleryCatalogUiUsedInCategoryFilterTest.xml +++ b/app/code/Magento/MediaGalleryCatalogUi/Test/Mftf/Test/AdminMediaGalleryCatalogUiUsedInCategoryFilterTest.xml @@ -27,7 +27,7 @@ <actionGroup ref="AdminEnhancedMediaGallerySelectImageForMassActionActionGroup" stepKey="selectSecondImageToDelete"> <argument name="imageName" value="{{UpdatedImageDetails.title}}"/> </actionGroup> - <actionGroup ref="AdminEnhancedMediaGalleryClickDeleteImagesButtonActionGroup" stepKey="clikDeleteSelectedButton"/> + <actionGroup ref="AdminEnhancedMediaGalleryClickDeleteImagesButtonActionGroup" stepKey="clickDeleteSelectedButton"/> <actionGroup ref="AdminEnhancedMediaGalleryConfirmDeleteImagesActionGroup" stepKey="deleteImages"/> <actionGroup ref="ResetAdminDataGridToDefaultViewActionGroup" stepKey="resetAdminDataGridToDefaultView"/> <deleteData createDataKey="category" stepKey="deleteCategory"/> @@ -37,6 +37,8 @@ <argument name="category" value="$$category$$"/> </actionGroup> <actionGroup ref="AdminOpenMediaGalleryFromCategoryImageUploaderActionGroup" stepKey="openMediaGalleryFromImageUploader"/> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearFilter"/> + <actionGroup ref="ResetAdminDataGridToDefaultViewActionGroup" stepKey="resetGridToDefaultView"/> <actionGroup ref="AdminEnhancedMediaGalleryUploadImageActionGroup" stepKey="uploadImage"> <argument name="image" value="ImageUpload3"/> </actionGroup> @@ -50,6 +52,8 @@ <actionGroup ref="AdminMediaGalleryClickAddSelectedActionGroup" stepKey="clickAddSelectedContentImage"/> <actionGroup ref="AdminSaveCategoryFormActionGroup" stepKey="saveCategoryForm"/> <actionGroup ref="AdminOpenMediaGalleryFromCategoryImageUploaderActionGroup" stepKey="openMediaGalleryFromImageUploaderAgain"/> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearFilterAgain"/> + <actionGroup ref="ResetAdminDataGridToDefaultViewActionGroup" stepKey="resetGridToDefaultViewAgain"/> <actionGroup ref="AdminEnhancedMediaGalleryExpandFilterActionGroup" stepKey="expandFilters"/> <actionGroup ref="AdminEnhancedMediaGallerySelectUsedInFilterActionGroup" stepKey="setUsedInFilter"> <argument name="filterName" value="Used in Categories"/> diff --git a/app/code/Magento/MediaGalleryCatalogUi/Test/Mftf/Test/AdminMediaGalleryCatalogUiUsedInProductFilterOnTest.xml b/app/code/Magento/MediaGalleryCatalogUi/Test/Mftf/Test/AdminMediaGalleryCatalogUiUsedInProductFilterOnTest.xml new file mode 100644 index 0000000000000..a48256a4c2d29 --- /dev/null +++ b/app/code/Magento/MediaGalleryCatalogUi/Test/Mftf/Test/AdminMediaGalleryCatalogUiUsedInProductFilterOnTest.xml @@ -0,0 +1,79 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminMediaGalleryCatalogUiUsedInProductFilterOnTest"> + <annotations> + <features value="MediaGalleryCatalogUi"/> + <stories value="Story 58 - User sees entities where asset is used in" /> + <title value="User can open the product entity the asset is associated"/> + <description value="User filters assets used in products"/> + <severity value="CRITICAL"/> + <testCaseId value="https://studio.cucumber.io/projects/131313/test-plan/folders/943908/scenarios/4523889"/> + <useCaseId value="https://github.com/magento/adobe-stock-integration/issues/1503"/> + <group value="media_gallery_ui"/> + </annotations> + + <before> + <magentoCLI command="config:set {{WysiwygEnabledByDefault.path}} {{WysiwygEnabledByDefault.value}}" stepKey="enableWYSIWYG"/> + <createData entity="SimpleProduct2" stepKey="createProduct"/> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + </before> + + <after> + <magentoCLI command="config:set {{WysiwygDisabledByDefault.path}} {{WysiwygDisabledByDefault.value}}" stepKey="disableWYSIWYG"/> + <deleteData createDataKey="createProduct" stepKey="deleteProduct"/> + <actionGroup ref="AdminOpenStandaloneMediaGalleryActionGroup" stepKey="openMediaGallery"/> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearFilters"/> + <actionGroup ref="AdminEnhancedMediaGalleryViewImageDetails" stepKey="openViewImageDetailsToAssertEmptyUsedIn"/> + <actionGroup ref="AssertAdminEnhancedMediaGalleryUsedInSectionNotDisplayedActionGroup" stepKey="assertThereIsNoUsedInSection"/> + <actionGroup ref="AdminEnhancedMediaGalleryCloseViewDetailsActionGroup" stepKey="closeDetails"/> + + <actionGroup ref="AdminEnhancedMediaGalleryEnableMassActionModeActionGroup" stepKey="enableMassActionToDeleteImages"/> + <actionGroup ref="AdminEnhancedMediaGallerySelectImageForMassActionActionGroup" stepKey="selectFirstImageToDelete"> + <argument name="imageName" value="{{ImageMetadata.title}}"/> + </actionGroup> + <actionGroup ref="AdminEnhancedMediaGalleryClickDeleteImagesButtonActionGroup" stepKey="clickDeleteSelectedButton"/> + <actionGroup ref="AdminEnhancedMediaGalleryConfirmDeleteImagesActionGroup" stepKey="deleteImages"/> + + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearFiltersAfterDeleteImages"/> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logoutFromAdmin"/> + </after> + + <actionGroup ref="AdminProductPageOpenByIdActionGroup" stepKey="goToProductEditPage"> + <argument name="productId" value="$createProduct.id$"/> + </actionGroup> + <actionGroup ref="AdminOpenMediaGalleryTinyMce4ActionGroup" stepKey="openMediaGalleryFromWysiwyg"/> + <actionGroup ref="AdminEnhancedMediaGalleryUploadImageActionGroup" stepKey="uploadImage"> + <argument name="image" value="ImageUpload3"/> + </actionGroup> + <actionGroup ref="AdminMediaGalleryClickImageInGridActionGroup" stepKey="selectContentImageInGrid"> + <argument name="imageName" value="{{ImageMetadata.title}}"/> + </actionGroup> + <actionGroup ref="AdminMediaGalleryClickAddSelectedActionGroup" stepKey="clickAddSelectedContentImage"/> + <actionGroup ref="AdminMediaGalleryClickOkButtonTinyMce4ActionGroup" stepKey="clickOkButton"/> + <actionGroup ref="SaveProductFormActionGroup" stepKey="saveProduct"/> + <actionGroup ref="AdminOpenStandaloneMediaGalleryActionGroup" stepKey="openStandaloneMediaGallery"/> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearFilter"/> + <actionGroup ref="AdminEnhancedMediaGalleryExpandFilterActionGroup" stepKey="expandFilters"/> + <actionGroup ref="AdminEnhancedMediaGallerySelectUsedInFilterActionGroup" stepKey="setUsedInFilter"> + <argument name="filterName" value="Used in Products"/> + <argument name="optionName" value="$createProduct.name$"/> + </actionGroup> + <actionGroup ref="AdminEnhancedMediaGalleryApplyFiltersActionGroup" stepKey="applyFilters"/> + <actionGroup ref="AdminEnhancedMediaGalleryViewImageDetails" stepKey="openViewImageDetails"/> + <actionGroup ref="AdminEnhancedMediaGalleryClickEntityUsedInActionGroup" stepKey="clickUsedInProducts"> + <argument name="entityName" value="Products"/> + </actionGroup> + <actionGroup ref="AdminAssertMediaGalleryFilterPlaceHolderGridActionGroup" stepKey="assertFilterApplied"> + <argument name="filterPlaceholder" value="{{ImageMetadata.title}}"/> + </actionGroup> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearFiltersOnProductGrid"/> + + </test> +</tests> diff --git a/app/code/Magento/MediaGalleryCatalogUi/Test/Mftf/Test/AdminMediaGalleryCatalogUiUsedInProductFilterTest.xml b/app/code/Magento/MediaGalleryCatalogUi/Test/Mftf/Test/AdminMediaGalleryCatalogUiUsedInProductFilterTest.xml index a66009e9d2045..02d96b725603f 100644 --- a/app/code/Magento/MediaGalleryCatalogUi/Test/Mftf/Test/AdminMediaGalleryCatalogUiUsedInProductFilterTest.xml +++ b/app/code/Magento/MediaGalleryCatalogUi/Test/Mftf/Test/AdminMediaGalleryCatalogUiUsedInProductFilterTest.xml @@ -7,16 +7,19 @@ --> <tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> - <test name="AdminMediaGalleryCatalogUiUsedInProductFilterTest"> + <test name="AdminMediaGalleryCatalogUiUsedInProductFilterTest" deprecated="Use AdminMediaGalleryCatalogUiUsedInProductFilterOnTest instead"> <annotations> <features value="AdminMediaGalleryUsedInProductsFilter"/> <useCaseId value="https://github.com/magento/adobe-stock-integration/issues/1503"/> - <title value="User can open product entity the asset is associated"/> + <title value="Deprecated. User can open product entity the asset is associated"/> <stories value="Story 58: User sees entities where asset is used in" /> <testCaseId value="https://studio.cucumber.io/projects/131313/test-plan/folders/943908/scenarios/4523889"/> <description value="User filters assets used in products"/> <severity value="CRITICAL"/> <group value="media_gallery_ui"/> + <skip> + <issueId value="DEPRECATED">Use AdminMediaGalleryCatalogUiUsedInProductFilterOnTest instead</issueId> + </skip> </annotations> <before> <magentoCLI command="config:set cms/wysiwyg/enabled enabled" stepKey="enableWYSIWYG"/> @@ -58,8 +61,11 @@ <argument name="entityName" value="Products"/> </actionGroup> - <actionGroup ref="AdminAssertMediaGalleryFilterPlaceHolderGridActionGroup" stepKey="assertFilterApplied"> - <argument name="filterPlaceholder" value="{{ImageMetadata.title}}"/> + <wait time="10" stepKey="waitForBookmarkToSaveView"/> + <reloadPage stepKey="reloadPage"/> + <waitForPageLoad stepKey="waitForGridReloaded"/> + <actionGroup ref="AdminAssertMediaGalleryFilterPlaceholderActionGroup" stepKey="assertFilterApplied"> + <argument name="filterPlaceholder" value="$$product.name$$"/> </actionGroup> <actionGroup ref="AdminClearFiltersActionGroup" stepKey="clearFiltersOnProductGrid"/> @@ -76,6 +82,5 @@ </actionGroup> <actionGroup ref="AdminEnhancedMediaGalleryClickDeleteImagesButtonActionGroup" stepKey="clickDeleteSelectedButton"/> <actionGroup ref="AdminEnhancedMediaGalleryConfirmDeleteImagesActionGroup" stepKey="deleteImages"/> - </test> </tests> diff --git a/app/code/Magento/MediaGalleryCatalogUi/Test/Mftf/Test/AdminMediaGalleryCatalogUiVerifyUsedInLinkCategoryGridTest.xml b/app/code/Magento/MediaGalleryCatalogUi/Test/Mftf/Test/AdminMediaGalleryCatalogUiVerifyUsedInLinkCategoryGridTest.xml index f9ffda43d2547..a3f1bd7c01136 100644 --- a/app/code/Magento/MediaGalleryCatalogUi/Test/Mftf/Test/AdminMediaGalleryCatalogUiVerifyUsedInLinkCategoryGridTest.xml +++ b/app/code/Magento/MediaGalleryCatalogUi/Test/Mftf/Test/AdminMediaGalleryCatalogUiVerifyUsedInLinkCategoryGridTest.xml @@ -7,16 +7,19 @@ --> <tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> - <test name="AdminMediaGalleryCatalogUiVerifyUsedInLinkCategoryGridTest"> + <test name="AdminMediaGalleryCatalogUiVerifyUsedInLinkCategoryGridTest" deprecated="Use AdminMediaGalleryCatalogUiVerifyUsedInLinkCategoryGridTest instead"> <annotations> <features value="AdminMediaGalleryCategoryGrid"/> <useCaseId value="https://github.com/magento/adobe-stock-integration/issues/1503"/> - <title value="User can open each entity the asset is associated with in a separate tab to manage association"/> + <title value="DEPRECATED. User can open each entity the asset is associated with in a separate tab to manage association"/> <stories value="Story 58: User sees entities where asset is used in" /> <testCaseId value="https://studio.cucumber.io/projects/131313/test-plan/folders/943908/scenarios/4523889"/> <description value="User can open each entity the asset is associated with in a separate tab to manage association"/> <severity value="CRITICAL"/> <group value="media_gallery_ui"/> + <skip> + <issueId value="DEPRECATED">Use AdminMediaGalleryCatalogUiVerifyUsedInLinkCategoryGridTest instead</issueId> + </skip> </annotations> <before> <createData entity="SimpleSubCategory" stepKey="category"/> diff --git a/app/code/Magento/MediaGalleryCatalogUi/Test/Mftf/Test/AdminMediaGalleryCatalogUiVerifyUsedInLinkProductGridTest.xml b/app/code/Magento/MediaGalleryCatalogUi/Test/Mftf/Test/AdminMediaGalleryCatalogUiVerifyUsedInLinkProductGridTest.xml index db7942d4c53bf..d2e8574ef7033 100644 --- a/app/code/Magento/MediaGalleryCatalogUi/Test/Mftf/Test/AdminMediaGalleryCatalogUiVerifyUsedInLinkProductGridTest.xml +++ b/app/code/Magento/MediaGalleryCatalogUi/Test/Mftf/Test/AdminMediaGalleryCatalogUiVerifyUsedInLinkProductGridTest.xml @@ -29,6 +29,8 @@ <after> <magentoCLI command="config:set cms/wysiwyg/enabled disabled" stepKey="disableWYSIWYG"/> <actionGroup ref="ResetAdminDataGridToDefaultViewActionGroup" stepKey="resetAdminDataGridToDefaultView"/> + <actionGroup ref="AdminOpenCatalogProductPageActionGroup" stepKey="goToProductCatalogPage"/> + <actionGroup ref="ResetAdminDataGridToDefaultViewActionGroup" stepKey="resetProductGridToDefaultView"/> <deleteData createDataKey="category" stepKey="deleteCategory"/> </after> <actionGroup ref="SearchForProductOnBackendActionGroup" stepKey="searchProduct"> @@ -42,6 +44,10 @@ <click selector="{{CatalogWYSIWYGSection.InsertImageIcon}}" stepKey="clickInsertImageIcon" /> <waitForPageLoad stepKey="waitForPageLoad" /> <actionGroup ref="ClickBrowseBtnOnUploadPopupActionGroup" stepKey="clickBrowserBtn"/> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearFilter"/> + <actionGroup ref="AdminMediaGalleryFolderSelectActionGroup" stepKey="selectWysiwygFolder"> + <argument name="name" value="wysiwyg"/> + </actionGroup> <actionGroup ref="AdminEnhancedMediaGalleryUploadImageActionGroup" stepKey="uploadImage"> <argument name="image" value="ImageUpload3"/> </actionGroup> @@ -51,6 +57,8 @@ <actionGroup ref="AdminMediaGalleryClickAddSelectedActionGroup" stepKey="clickAddSelectedContentImage"/> <actionGroup ref="AdminMediaGalleryClickOkButtonTinyMce4ActionGroup" stepKey="clickOkButton"/> <actionGroup ref="SaveProductFormActionGroup" stepKey="saveProduct"/> + <actionGroup ref="AdminOpenCatalogProductPageActionGroup" stepKey="goToProductCatalogPage"/> + <actionGroup ref="ResetAdminDataGridToDefaultViewActionGroup" stepKey="resetProductGridToDefaultView"/> <actionGroup ref="AdminOpenStandaloneMediaGalleryActionGroup" stepKey="openStandaloneMediaGallery"/> <actionGroup ref="AdminEnhancedMediaGalleryViewImageDetails" stepKey="openViewImageDetails"/> @@ -64,15 +72,14 @@ <deleteData createDataKey="product" stepKey="deleteProduct"/> <actionGroup ref="AdminOpenStandaloneMediaGalleryActionGroup" stepKey="openMediaGallery"/> - <actionGroup ref="AdminEnhancedMediaGalleryViewImageDetails" stepKey="openViewImageDetailsToVerfifyEmptyUsedIn"/> + <actionGroup ref="AdminEnhancedMediaGalleryViewImageDetails" stepKey="openViewImageDetailsToVerifyEmptyUsedIn"/> <actionGroup ref="AssertAdminEnhancedMediaGalleryUsedInSectionNotDisplayedActionGroup" stepKey="assertThereIsNoUsedInSection"/> <actionGroup ref="AdminEnhancedMediaGalleryCloseViewDetailsActionGroup" stepKey="closeDetails"/> <actionGroup ref="AdminEnhancedMediaGalleryEnableMassActionModeActionGroup" stepKey="enableMassActionToDeleteImages"/> <actionGroup ref="AdminEnhancedMediaGallerySelectImageForMassActionActionGroup" stepKey="selectFirstImageToDelete"> <argument name="imageName" value="{{ImageMetadata.title}}"/> </actionGroup> - <actionGroup ref="AdminEnhancedMediaGalleryClickDeleteImagesButtonActionGroup" stepKey="clikDeleteSelectedButton"/> + <actionGroup ref="AdminEnhancedMediaGalleryClickDeleteImagesButtonActionGroup" stepKey="clickDeleteSelectedButton"/> <actionGroup ref="AdminEnhancedMediaGalleryConfirmDeleteImagesActionGroup" stepKey="deleteImages"/> - </test> </tests> diff --git a/app/code/Magento/MediaGalleryCatalogUi/Test/Mftf/Test/AdminMediaGalleryCatalogUiVerifyUsedInLinkedCategoryGridTest.xml b/app/code/Magento/MediaGalleryCatalogUi/Test/Mftf/Test/AdminMediaGalleryCatalogUiVerifyUsedInLinkedCategoryGridTest.xml new file mode 100644 index 0000000000000..8e197b740bb11 --- /dev/null +++ b/app/code/Magento/MediaGalleryCatalogUi/Test/Mftf/Test/AdminMediaGalleryCatalogUiVerifyUsedInLinkedCategoryGridTest.xml @@ -0,0 +1,102 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminMediaGalleryCatalogUiVerifyUsedInLinkedCategoryGridTest"> + <annotations> + <features value="MediaGalleryCatalogUi"/> + <stories value="Story 58: User sees entities where asset is used in" /> + <title value="User can open each entity the asset is associated with in a separate tab to manage association"/> + <description value="User can open each entity the asset is associated with in a separate tab to manage association"/> + <useCaseId value="https://github.com/magento/adobe-stock-integration/issues/1503"/> + <testCaseId value="https://studio.cucumber.io/projects/131313/test-plan/folders/943908/scenarios/4523889"/> + <severity value="CRITICAL"/> + <group value="media_gallery_ui"/> + </annotations> + + <before> + <createData entity="SimpleSubCategory" stepKey="category"/> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + <actionGroup ref="AdminEnhancedMediaGalleryDeletedAllImagesActionGroup" stepKey="deleteAllMediaGalleryImages"/> + <actionGroup ref="AdminOpenCategoryGridPageActionGroup" stepKey="openMediaGalleryCategoryGridPage"/> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearGridFilters"/> + </before> + + <after> + <actionGroup ref="AdminOpenStandaloneMediaGalleryActionGroup" stepKey="openMediaGallery"/> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="resetMediaGalleryGridFilters"/> + <actionGroup ref="AdminMediaGalleryFolderSelectActionGroup" stepKey="selectFolder"/> + <actionGroup ref="AdminMediaGalleryFolderDeleteActionGroup" stepKey="deleteFolder"/> + <actionGroup ref="AdminMediaGalleryAssertFolderDoesNotExistActionGroup" stepKey="assertFolderWasDeleted"/> + <actionGroup ref="AdminEnhancedMediaGalleryDeletedAllImagesActionGroup" stepKey="deleteAllMediaGalleryImages"/> + <actionGroup ref="AdminOpenCategoryGridPageActionGroup" stepKey="openMediaGalleryCategoryGridPage"/> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearGridFilters"/> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logoutFromAdmin"/> + </after> + + <actionGroup ref="GoToAdminCategoryPageByIdActionGroup" stepKey="openCategoryPage"> + <argument name="id" value="$category.id$"/> + </actionGroup> + <actionGroup ref="AdminOpenMediaGalleryFromCategoryImageUploaderActionGroup" stepKey="openMediaGalleryFromImageUploader"/> + <waitForLoadingMaskToDisappear stepKey="waitForLoadingMaskToDisappear" /> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="resetMediaGalleryGridFilters"/> + <actionGroup ref="AdminMediaGalleryOpenNewFolderFormActionGroup" stepKey="openNewFolderForm"/> + <actionGroup ref="AdminMediaGalleryCreateNewFolderActionGroup" stepKey="createNewFolder"/> + <actionGroup ref="AdminMediaGalleryAssertFolderNameActionGroup" stepKey="assertNewFolderCreated"/> + <actionGroup ref="AdminEnhancedMediaGalleryUploadImageActionGroup" stepKey="uploadImage"> + <argument name="image" value="ImageUpload"/> + </actionGroup> + <actionGroup ref="AdminEnhancedMediaGalleryViewImageDetails" stepKey="viewImageDetails"/> + <actionGroup ref="AdminEnhancedMediaGalleryImageDetailsEditActionGroup" stepKey="editImage"/> + <actionGroup ref="AdminEnhancedMediaGalleryImageDetailsSaveActionGroup" stepKey="saveImage"> + <argument name="image" value="UpdatedImageDetails"/> + </actionGroup> + <actionGroup ref="AdminEnhancedMediaGalleryCloseViewDetailsActionGroup" stepKey="closeViewDetails"/> + <actionGroup ref="AdminMediaGalleryClickAddSelectedActionGroup" stepKey="clickAddSelectedCategoryImage"/> + <actionGroup ref="AdminSaveCategoryFormActionGroup" stepKey="saveCategory"/> + + <actionGroup ref="AdminOpenMediaGalleryFromCategoryImageUploaderActionGroup" stepKey="openMediaGalleryFromImageUploaderToVerifyLink"/> + <actionGroup ref="AdminMediaGalleryFolderSelectActionGroup" stepKey="selectCategoryImageFolder"/> + <actionGroup ref="AdminEnhancedMediaGalleryViewImageDetails" stepKey="openViewImageDetails"/> + <actionGroup ref="AdminEnhancedMediaGalleryClickEntityUsedInActionGroup" stepKey="clickUsedInCategories"> + <argument name="entityName" value="Categories"/> + </actionGroup> + <actionGroup ref="AssertAdminMediaGalleryAssetFilterPlaceHolderActionGroup" stepKey="assertFilterApplied"> + <argument name="filterPlaceholder" value="{{UpdatedImageDetails.title}}"/> + </actionGroup> + <actionGroup ref="AssertAdminCategoryGridPageNumberOfRecordsActionGroup" stepKey="assertOneRecordInGrid"> + <argument name="numberOfRecords" value="1 records found"/> + </actionGroup> + <actionGroup ref="AssertAdminCategoryGridPageImageColumnActionGroup" stepKey="assertCategoryGridPageImageColumn"> + <argument name="file" value="{{UpdatedImageDetails.file}}"/> + </actionGroup> + <actionGroup ref="AssertAdminCategoryGridPageDetailsActionGroup" stepKey="assertCategoryInGrid"> + <argument name="category" value="$category$"/> + </actionGroup> + <actionGroup ref="AssertAdminCategoryGridPageProductsInMenuEnabledColumnsActionGroup" stepKey="assertCategoryGridPageProductsInMenuEnabledColumns"/> + + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="resetCategoriesGridFilters"/> + + <actionGroup ref="AdminEnhancedMediaGalleryCategoryGridExpandFilterActionGroup" stepKey="expandFilters"/> + <actionGroup ref="AdminEnhancedMediaGallerySelectUsedInFilterActionGroup" stepKey="setAssetFilter"> + <argument name="filterName" value="Asset"/> + <argument name="optionName" value="{{UpdatedImageDetails.title}}"/> + </actionGroup> + <actionGroup ref="AdminEnhancedMediaGalleryCategoryGridApplyFiltersActionGroup" stepKey="applyFilters"/> + <actionGroup ref="AssertAdminMediaGalleryAssetFilterPlaceHolderActionGroup" stepKey="assertFilterAppliedAfterUrlFilterApplier"> + <argument name="filterPlaceholder" value="{{UpdatedImageDetails.title}}"/> + </actionGroup> + + <deleteData createDataKey="category" stepKey="deleteCategory"/> + <actionGroup ref="AdminOpenStandaloneMediaGalleryActionGroup" stepKey="openMediaGallery"/> + <actionGroup ref="AdminMediaGalleryFolderSelectActionGroup" stepKey="openCategoryImageFolder"/> + <actionGroup ref="AdminEnhancedMediaGalleryViewImageDetails" stepKey="openViewImageDetailsToVerifyEmptyUsedIn"/> + <actionGroup ref="AssertAdminEnhancedMediaGalleryUsedInSectionNotDisplayedActionGroup" stepKey="assertThereIsNoUsedInSection"/> + <actionGroup ref="AdminEnhancedMediaGalleryCloseViewDetailsActionGroup" stepKey="closeDetails"/> + </test> +</tests> diff --git a/app/code/Magento/MediaGalleryCmsUi/Test/Mftf/ActionGroup/FillOutCustomCMSPageContentActionGroup.xml b/app/code/Magento/MediaGalleryCmsUi/Test/Mftf/ActionGroup/FillOutCustomCMSPageContentActionGroup.xml index f0938016d12f1..e0ec79394fdda 100644 --- a/app/code/Magento/MediaGalleryCmsUi/Test/Mftf/ActionGroup/FillOutCustomCMSPageContentActionGroup.xml +++ b/app/code/Magento/MediaGalleryCmsUi/Test/Mftf/ActionGroup/FillOutCustomCMSPageContentActionGroup.xml @@ -10,7 +10,7 @@ xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> <actionGroup name="FillOutCustomCMSPageContentActionGroup"> <annotations> - <description>Fills out the Page details (Page Title, Content and URL Key)</description> + <description>DEPRECATED. Use AdminCmsPageFillOutBasicFieldsActionGroup and other AGs from CMS module. Fills out the Page details (Page Title, Content and URL Key)</description> </annotations> <arguments> diff --git a/app/code/Magento/MediaGalleryCmsUi/Test/Mftf/Test/AdminMediaGalleryAssertImageUsedInLinkBlocksGridTest.xml b/app/code/Magento/MediaGalleryCmsUi/Test/Mftf/Test/AdminMediaGalleryAssertImageUsedInLinkBlocksGridTest.xml new file mode 100644 index 0000000000000..530605bb7e233 --- /dev/null +++ b/app/code/Magento/MediaGalleryCmsUi/Test/Mftf/Test/AdminMediaGalleryAssertImageUsedInLinkBlocksGridTest.xml @@ -0,0 +1,87 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminMediaGalleryAssertImageUsedInLinkBlocksGridTest"> + <annotations> + <features value="MediaGalleryCmsUi"/> + <stories value="Story 58: User sees entities where asset is used in" /> + <title value="Used in blocks link"/> + <description value="User filters assets used in blocks"/> + <severity value="CRITICAL"/> + <testCaseId value="https://studio.cucumber.io/projects/131313/test-plan/folders/1320712/scenarios/4951848"/> + <useCaseId value="https://github.com/magento/adobe-stock-integration/issues/1168"/> + <group value="media_gallery_ui"/> + </annotations> + <before> + <createData entity="_defaultBlock" stepKey="createBlock" /> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + <actionGroup ref="AdminEnhancedMediaGalleryDeletedAllImagesActionGroup" stepKey="deleteAllImages"/> + </before> + <after> + <deleteData createDataKey="createBlock" stepKey="deleteBlock"/> + + <actionGroup ref="AdminOpenStandaloneMediaGalleryActionGroup" stepKey="openMediaGallery"/> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearFiltersOnStandaloneMediaGalleryPage"/> + <actionGroup ref="AdminMediaGalleryFolderSelectActionGroup" stepKey="selectCreatedFolderAgain"> + <argument name="name" value="{{AdminMediaGalleryFolderData.name}}"/> + </actionGroup> + <actionGroup ref="AdminEnhancedMediaGalleryViewImageDetails" stepKey="openViewImageDetailsToVerifyEmptyUsedIn"/> + <actionGroup ref="AssertAdminEnhancedMediaGalleryUsedInSectionNotDisplayedActionGroup" stepKey="assertThereIsNoUsedInSection"/> + <actionGroup ref="AdminEnhancedMediaGalleryCloseViewDetailsActionGroup" stepKey="closeImageDetails"/> + + <actionGroup ref="AdminMediaGalleryFolderDeleteActionGroup" stepKey="deleteFolder"/> + <actionGroup ref="AdminMediaGalleryAssertFolderDoesNotExistActionGroup" stepKey="assertFolderWasDeleted"> + <argument name="name" value="{{AdminMediaGalleryFolderData.name}}"/> + </actionGroup> + + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearFiltersOnMediaGalleryPage"/> + <actionGroup ref="AdminEnhancedMediaGalleryDeletedAllImagesActionGroup" stepKey="deleteAllImagesAfterTest"/> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logoutFromAdmin"/> + </after> + + <actionGroup ref="NavigateToCreatedCMSBlockPageActionGroup" stepKey="navigateToCreatedCMSBlockPage"> + <argument name="CMSBlockPage" value="$createBlock$"/> + </actionGroup> + <click selector="{{CmsWYSIWYGSection.InsertImageBtn}}" stepKey="clickInsertImageIcon" /> + <waitForPageLoad stepKey="waitForInitialPageLoad" /> + <waitForElementVisible selector="{{AdminMediaGalleryFolderSection.folderNewCreateButton}}" stepKey="waitForNewFolderButton"/> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearFilter"/> + <actionGroup ref="AdminMediaGalleryOpenNewFolderFormActionGroup" stepKey="openNewFolderForm"/> + <actionGroup ref="AdminMediaGalleryCreateNewFolderActionGroup" stepKey="createNewFolder"> + <argument name="name" value="{{AdminMediaGalleryFolderData.name}}"/> + </actionGroup> + <actionGroup ref="AdminMediaGalleryAssertFolderNameActionGroup" stepKey="assertNewFolderCreated"> + <argument name="name" value="{{AdminMediaGalleryFolderData.name}}"/> + </actionGroup> + <waitForPageLoad stepKey="waitForGridToLoadAfterNewFolderCreated"/> + <actionGroup ref="AdminEnhancedMediaGalleryUploadImageActionGroup" stepKey="uploadImage"> + <argument name="image" value="ImageUpload3"/> + </actionGroup> + <actionGroup ref="AdminMediaGalleryClickImageInGridActionGroup" stepKey="selectContentImageInGrid"> + <argument name="imageName" value="{{ImageMetadata.title}}"/> + </actionGroup> + <actionGroup ref="AdminMediaGalleryClickAddSelectedActionGroup" stepKey="clickAddSelectedContentImage"/> + <click selector="{{BlockNewPagePageActionsSection.saveBlock}}" stepKey="saveBlock"/> + + <actionGroup ref="AdminOpenStandaloneMediaGalleryActionGroup" stepKey="openStandaloneMediaGallery"/> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearFiltersOnStandaloneMediaGallery"/> + <actionGroup ref="AdminMediaGalleryFolderSelectActionGroup" stepKey="selectCreatedFolder"> + <argument name="name" value="{{AdminMediaGalleryFolderData.name}}"/> + </actionGroup> + <actionGroup ref="AdminEnhancedMediaGalleryViewImageDetails" stepKey="openViewImageDetails"/> + <actionGroup ref="AdminEnhancedMediaGalleryClickEntityUsedInActionGroup" stepKey="clickUsedInBlocks"> + <argument name="entityName" value="Blocks"/> + </actionGroup> + + <actionGroup ref="AdminAssertMediaGalleryFilterPlaceHolderGridActionGroup" stepKey="assertFilterApplied"> + <argument name="filterPlaceholder" value="{{ImageMetadata.title}}"/> + </actionGroup> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearFilterInBlocksGrid"/> + </test> +</tests> diff --git a/app/code/Magento/MediaGalleryCmsUi/Test/Mftf/Test/AdminMediaGalleryAssertUsedInLinkBlocksGridTest.xml b/app/code/Magento/MediaGalleryCmsUi/Test/Mftf/Test/AdminMediaGalleryAssertUsedInLinkBlocksGridTest.xml index a0cd04fad54c5..b360d958aee33 100644 --- a/app/code/Magento/MediaGalleryCmsUi/Test/Mftf/Test/AdminMediaGalleryAssertUsedInLinkBlocksGridTest.xml +++ b/app/code/Magento/MediaGalleryCmsUi/Test/Mftf/Test/AdminMediaGalleryAssertUsedInLinkBlocksGridTest.xml @@ -7,16 +7,19 @@ --> <tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> - <test name="AdminMediaGalleryAssertUsedInLinkBlocksGridTest"> + <test name="AdminMediaGalleryAssertUsedInLinkBlocksGridTest" deprecated="Use AdminMediaGalleryAssertImageUsedInLinkBlocksGridTest instead"> <annotations> <features value="AdminMediaGalleryUsedInBlocksFilter"/> <useCaseId value="https://github.com/magento/adobe-stock-integration/issues/1168"/> - <title value="Used in blocks link"/> + <title value="Deprecated. Used in blocks link"/> <stories value="Story 58: User sees entities where asset is used in" /> <testCaseId value="https://studio.cucumber.io/projects/131313/test-plan/folders/1320712/scenarios/4951848"/> <description value="User filters assets used in blocks"/> <severity value="CRITICAL"/> <group value="media_gallery_ui"/> + <skip> + <issueId value="DEPRECATED">Use AdminMediaGalleryAssertImageUsedInLinkBlocksGridTest instead</issueId> + </skip> </annotations> <before> <createData entity="_defaultBlock" stepKey="block" /> @@ -30,7 +33,17 @@ <argument name="CMSBlockPage" value="$$block$$"/> </actionGroup> <click selector="{{CmsWYSIWYGSection.InsertImageBtn}}" stepKey="clickInsertImageIcon" /> - <waitForPageLoad stepKey="waitForPageLoad" /> + <waitForPageLoad stepKey="waitForInitialPageLoad" /> + <waitForPageLoad stepKey="waitForSecondaryPageLoad" /> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearFilter"/> + <actionGroup ref="AdminMediaGalleryOpenNewFolderFormActionGroup" stepKey="openNewFolderForm"/> + <actionGroup ref="AdminMediaGalleryCreateNewFolderActionGroup" stepKey="createNewFolder"> + <argument name="name" value="blockImage"/> + </actionGroup> + <actionGroup ref="AdminMediaGalleryAssertFolderNameActionGroup" stepKey="assertNewFolderCreated"> + <argument name="name" value="blockImage"/> + </actionGroup> + <waitForPageLoad stepKey="waitForGridToLoadAfterNewFolderCreated"/> <actionGroup ref="AdminEnhancedMediaGalleryUploadImageActionGroup" stepKey="uploadImage"> <argument name="image" value="ImageUpload3"/> </actionGroup> @@ -41,26 +54,33 @@ <click selector="{{BlockNewPagePageActionsSection.saveBlock}}" stepKey="saveBlock"/> <actionGroup ref="AdminOpenStandaloneMediaGalleryActionGroup" stepKey="openStandaloneMediaGallery"/> + <actionGroup ref="ResetAdminDataGridToDefaultViewActionGroup" stepKey="resetAdminDataGridToDefaultView"/> + <actionGroup ref="AdminMediaGalleryFolderSelectActionGroup" stepKey="selectFolder"> + <argument name="name" value="blockImage"/> + </actionGroup> <actionGroup ref="AdminEnhancedMediaGalleryViewImageDetails" stepKey="openViewImageDetails"/> - <actionGroup ref="AdminEnhancedMediaGalleryClickEntityUsedInActionGroup" stepKey="clickUsedInPages"> + <actionGroup ref="AdminEnhancedMediaGalleryClickEntityUsedInActionGroup" stepKey="clickUsedInBlocks"> <argument name="entityName" value="Blocks"/> </actionGroup> <actionGroup ref="AdminAssertMediaGalleryFilterPlaceHolderGridActionGroup" stepKey="assertFilterApplied"> <argument name="filterPlaceholder" value="{{ImageMetadata.title}}"/> </actionGroup> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearFilterInBlocksGrid"/> <deleteData createDataKey="block" stepKey="deleteBlock"/> <actionGroup ref="AdminOpenStandaloneMediaGalleryActionGroup" stepKey="openMediaGallery"/> - <actionGroup ref="AdminEnhancedMediaGalleryViewImageDetails" stepKey="openViewImageDetailsToVerfifyEmptyUsedIn"/> + <actionGroup ref="ResetAdminDataGridToDefaultViewActionGroup" stepKey="resetAdminDataGridToDefaultViewAgain"/> + <actionGroup ref="AdminMediaGalleryFolderSelectActionGroup" stepKey="selectFolderAgain"> + <argument name="name" value="blockImage"/> + </actionGroup> + <actionGroup ref="AdminEnhancedMediaGalleryViewImageDetails" stepKey="openViewImageDetailsToVerifyEmptyUsedIn"/> <actionGroup ref="AssertAdminEnhancedMediaGalleryUsedInSectionNotDisplayedActionGroup" stepKey="assertThereIsNoUsedInSection"/> <actionGroup ref="AdminEnhancedMediaGalleryCloseViewDetailsActionGroup" stepKey="closeDetails"/> - <actionGroup ref="AdminEnhancedMediaGalleryEnableMassActionModeActionGroup" stepKey="enableMassActionToDeleteImages"/> - <actionGroup ref="AdminEnhancedMediaGallerySelectImageForMassActionActionGroup" stepKey="selectFirstImageToDelete"> - <argument name="imageName" value="{{ImageMetadata.title}}"/> + <actionGroup ref="AdminMediaGalleryFolderDeleteActionGroup" stepKey="deleteFolder"/> + <actionGroup ref="AdminMediaGalleryAssertFolderDoesNotExistActionGroup" stepKey="assertFolderWasDeleted"> + <argument name="name" value="blockImage"/> </actionGroup> - <actionGroup ref="AdminEnhancedMediaGalleryClickDeleteImagesButtonActionGroup" stepKey="clikDeleteSelectedButton"/> - <actionGroup ref="AdminEnhancedMediaGalleryConfirmDeleteImagesActionGroup" stepKey="deleteImages"/> </test> </tests> diff --git a/app/code/Magento/MediaGalleryCmsUi/Test/Mftf/Test/AdminMediaGalleryAssertUsedInLinkPagesGridTest.xml b/app/code/Magento/MediaGalleryCmsUi/Test/Mftf/Test/AdminMediaGalleryAssertUsedInLinkPagesGridTest.xml index 5a375d9153a6d..c29ddbae0eb52 100644 --- a/app/code/Magento/MediaGalleryCmsUi/Test/Mftf/Test/AdminMediaGalleryAssertUsedInLinkPagesGridTest.xml +++ b/app/code/Magento/MediaGalleryCmsUi/Test/Mftf/Test/AdminMediaGalleryAssertUsedInLinkPagesGridTest.xml @@ -7,23 +7,34 @@ --> <tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> - <test name="AdminMediaGalleryAssertUsedInLinkPagesGridTest"> + <test name="AdminMediaGalleryAssertUsedInLinkPagesGridTest" deprecated="Use AdminMediaGalleryAssertUsedInLinkedPagesGridTest instead"> <annotations> - <skip> - <issueId value="https://github.com/magento/adobe-stock-integration/issues/1825"/> - </skip> <features value="AdminMediaGalleryUsedInBlocksFilter"/> <useCaseId value="https://github.com/magento/adobe-stock-integration/issues/1168"/> - <title value="Used in pages link"/> + <title value="DEPRECATED. Used in pages link"/> <stories value="Story 58: User sees entities where asset is used in" /> <testCaseId value="https://studio.cucumber.io/projects/131313/test-plan/folders/1320712/scenarios/4951848"/> <description value="User filters assets used in pages"/> <severity value="CRITICAL"/> <group value="media_gallery_ui"/> + <skip> + <issueId value="DEPRECATED">Use AdminMediaGalleryAssertUsedInLinkedPagesGridTest instead</issueId> + </skip> </annotations> <before> <actionGroup ref="AdminLoginActionGroup" stepKey="login"/> </before> + <after> + <actionGroup ref="AdminOpenStandaloneMediaGalleryActionGroup" stepKey="openMediaGallery"/> + <actionGroup ref="ResetAdminDataGridToDefaultViewActionGroup" stepKey="resetAdminDataGridToDefaultViewAgain"/> + <actionGroup ref="AdminMediaGalleryFolderSelectActionGroup" stepKey="selectFolder"> + <argument name="name" value="pageTestImage"/> + </actionGroup> + <actionGroup ref="AdminMediaGalleryFolderDeleteActionGroup" stepKey="deleteFolder"/> + <actionGroup ref="AdminMediaGalleryAssertFolderDoesNotExistActionGroup" stepKey="assertFolderWasDeleted"> + <argument name="name" value="pageTestImage"/> + </actionGroup> + </after> <actionGroup ref="AdminOpenCreateNewCMSPageActionGroup" stepKey="navigateToCreateNewPage"/> <actionGroup ref="FillOutCustomCMSPageContentActionGroup" stepKey="fillBasicPageDataForPageWithDefaultStore"> <argument name="title" value="Unique page title MediaGalleryUi"/> @@ -32,41 +43,52 @@ </actionGroup> <actionGroup ref="AdminOpenMediaGalleryFromPageNoEditorActionGroup" stepKey="openMediaGalleryForPage"/> - <waitForPageLoad stepKey="waitForPageLoad" /> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearFilter"/> + <wait time="5" stepKey="waitFilterReallyCleared"/> + <actionGroup ref="AdminMediaGalleryOpenNewFolderFormActionGroup" stepKey="openNewFolderForm"/> + <actionGroup ref="AdminMediaGalleryCreateNewFolderActionGroup" stepKey="createNewFolder"> + <argument name="name" value="pageTestImage"/> + </actionGroup> + <actionGroup ref="AdminMediaGalleryAssertFolderNameActionGroup" stepKey="assertNewFolderCreated"> + <argument name="name" value="pageTestImage"/> + </actionGroup> + <waitForPageLoad stepKey="waitForGridToLoadAfterNewFolderCreated"/> <actionGroup ref="AdminEnhancedMediaGalleryUploadImageActionGroup" stepKey="uploadImage"> <argument name="image" value="ImageUpload3"/> </actionGroup> - <actionGroup ref="AdminEnhancedMediaGalleryViewImageDetails" stepKey="viewImageDetails"/> - <actionGroup ref="AdminEnhancedMediaGalleryImageDetailsEditActionGroup" stepKey="editImage"/> - <actionGroup ref="AdminEnhancedMediaGalleryImageDetailsSaveActionGroup" stepKey="saveImage"> - <argument name="image" value="UpdatedImageDetails"/> + <actionGroup ref="AdminMediaGalleryClickImageInGridActionGroup" stepKey="selectContentImageInGrid"> + <argument name="imageName" value="{{ImageMetadata.title}}"/> </actionGroup> - <actionGroup ref="AdminEnhancedMediaGalleryCloseViewDetailsActionGroup" stepKey="closeViewDetails"/> - <actionGroup ref="AdminMediaGalleryClickAddSelectedActionGroup" stepKey="clickAddSelectedContentImage"/> - <click selector="{{CmsNewPagePageActionsSection.saveAndContinueEdit}}" stepKey="savePage"/> + <waitForPageLoad stepKey="waitForPageLoad10" /> + <waitForElementVisible selector="{{CmsNewPagePageActionsSection.saveAndContinueEdit}}" stepKey="waitForSaveButtonVisible"/> + <click selector="{{CmsNewPagePageActionsSection.expandSplitButton}}" stepKey="expandButtonMenu"/> + <click selector="{{CmsNewPagePageActionsSection.savePage}}" stepKey="savePage"/> + <waitForPageLoad stepKey="waitForSaveToFinish"/> + <see userInput="You saved the page." stepKey="seeSuccessMessage"/> <actionGroup ref="AdminOpenStandaloneMediaGalleryActionGroup" stepKey="openStandaloneMediaGallery"/> + <actionGroup ref="ResetAdminDataGridToDefaultViewActionGroup" stepKey="resetAdminDataGridToDefaultView"/> + <actionGroup ref="AdminMediaGalleryFolderSelectActionGroup" stepKey="selectFolder"> + <argument name="name" value="pageTestImage"/> + </actionGroup> <actionGroup ref="AdminEnhancedMediaGalleryViewImageDetails" stepKey="openViewImageDetails"/> <actionGroup ref="AdminEnhancedMediaGalleryClickEntityUsedInActionGroup" stepKey="clickUsedInPages"> <argument name="entityName" value="Pages"/> </actionGroup> <actionGroup ref="AdminAssertMediaGalleryFilterPlaceHolderGridActionGroup" stepKey="assertFilterApplied"> - <argument name="filterPlaceholder" value="{{UpdatedImageDetails.title}}"/> + <argument name="filterPlaceholder" value="{{ImageMetadata.title}}"/> </actionGroup> <actionGroup ref="AdminDeleteCmsPageFromGridActionGroup" stepKey="deleteCmsPage"> <argument name="urlKey" value="test-page-1"/> </actionGroup> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearFiltersInPageGrid"/> <actionGroup ref="AdminOpenStandaloneMediaGalleryActionGroup" stepKey="openMediaGallery"/> - <actionGroup ref="AdminEnhancedMediaGalleryViewImageDetails" stepKey="openViewImageDetailsToVerfifyEmptyUsedIn"/> + <actionGroup ref="ResetAdminDataGridToDefaultViewActionGroup" stepKey="resetAdminDataGridToDefaultViewAgain"/> + <actionGroup ref="AdminMediaGalleryFolderSelectActionGroup" stepKey="selectFolderAgain"> + <argument name="name" value="pageTestImage"/> + </actionGroup> + <actionGroup ref="AdminEnhancedMediaGalleryViewImageDetails" stepKey="openViewImageDetailsToVerifyEmptyUsedIn"/> <actionGroup ref="AssertAdminEnhancedMediaGalleryUsedInSectionNotDisplayedActionGroup" stepKey="assertThereIsNoUsedInSection"/> <actionGroup ref="AdminEnhancedMediaGalleryCloseViewDetailsActionGroup" stepKey="closeDetails"/> - - <actionGroup ref="AdminEnhancedMediaGalleryEnableMassActionModeActionGroup" stepKey="enableMassActionToDeleteImages"/> - <actionGroup ref="AdminEnhancedMediaGallerySelectImageForMassActionActionGroup" stepKey="selectFirstImageToDelete"> - <argument name="imageName" value="{{UpdatedImageDetails.title}}"/> - </actionGroup> - <actionGroup ref="AdminEnhancedMediaGalleryClickDeleteImagesButtonActionGroup" stepKey="clikDeleteSelectedButton"/> - <actionGroup ref="AdminEnhancedMediaGalleryConfirmDeleteImagesActionGroup" stepKey="deleteImages"/> - </test> </tests> diff --git a/app/code/Magento/MediaGalleryCmsUi/Test/Mftf/Test/AdminMediaGalleryAssertUsedInLinkedPagesGridTest.xml b/app/code/Magento/MediaGalleryCmsUi/Test/Mftf/Test/AdminMediaGalleryAssertUsedInLinkedPagesGridTest.xml new file mode 100644 index 0000000000000..4e589faef9a1f --- /dev/null +++ b/app/code/Magento/MediaGalleryCmsUi/Test/Mftf/Test/AdminMediaGalleryAssertUsedInLinkedPagesGridTest.xml @@ -0,0 +1,75 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminMediaGalleryAssertUsedInLinkedPagesGridTest"> + <annotations> + <features value="MediaGalleryCmsUi"/> + <stories value="Story 58: User sees entities where asset is used in" /> + <title value="Used in pages link"/> + <description value="User filters assets used in pages"/> + <severity value="CRITICAL"/> + <useCaseId value="https://github.com/magento/adobe-stock-integration/issues/1168"/> + <testCaseId value="https://studio.cucumber.io/projects/131313/test-plan/folders/1320712/scenarios/4951848"/> + <group value="media_gallery_ui"/> + </annotations> + + <before> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + <actionGroup ref="AdminEnhancedMediaGalleryDeletedAllImagesActionGroup" stepKey="deleteAllMediaGalleryImages"/> + </before> + + <after> + <actionGroup ref="AdminOpenStandaloneMediaGalleryActionGroup" stepKey="openMediaGallery"/> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="resetMediaGalleryGridFilters"/> + <actionGroup ref="AdminMediaGalleryFolderSelectActionGroup" stepKey="selectFolder"/> + <actionGroup ref="AdminMediaGalleryFolderDeleteActionGroup" stepKey="deleteFolder"/> + <actionGroup ref="AdminMediaGalleryAssertFolderDoesNotExistActionGroup" stepKey="assertFolderWasDeleted"/> + <actionGroup ref="AdminEnhancedMediaGalleryDeletedAllImagesActionGroup" stepKey="deleteAllMediaGalleryImages"/> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logoutFromAdmin"/> + </after> + + <actionGroup ref="AdminOpenCreateNewCMSPageActionGroup" stepKey="navigateToCreateNewPage"/> + <actionGroup ref="AdminCmsPageFillOutBasicFieldsActionGroup" stepKey="fillBasicPageFields"/> + <actionGroup ref="AdminOpenMediaGalleryFromPageNoEditorActionGroup" stepKey="openMediaGalleryForPage"/> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="resetMediaGalleryGridFilters"/> + <actionGroup ref="AdminMediaGalleryOpenNewFolderFormActionGroup" stepKey="openNewFolderForm"/> + <actionGroup ref="AdminMediaGalleryCreateNewFolderActionGroup" stepKey="createNewFolder"/> + <actionGroup ref="AdminMediaGalleryAssertFolderNameActionGroup" stepKey="assertNewFolderCreated"/> + <actionGroup ref="AdminEnhancedMediaGalleryUploadImageActionGroup" stepKey="uploadImage"> + <argument name="image" value="ImageUpload3"/> + </actionGroup> + <actionGroup ref="AdminMediaGalleryClickImageInGridActionGroup" stepKey="selectContentImageInGrid"> + <argument name="imageName" value="{{ImageMetadata.title}}"/> + </actionGroup> + <actionGroup ref="AdminMediaGalleryClickAddSelectedActionGroup" stepKey="clickAddSelectedContentImage"/> + <actionGroup ref="AdminSaveAndContinueEditCmsPageActionGroup" stepKey="saveCmsPageAndContinue"/> + + <actionGroup ref="AdminOpenStandaloneMediaGalleryActionGroup" stepKey="openStandaloneMediaGallery"/> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="resetMediaGalleryGridFiltersAgain"/> + <actionGroup ref="AdminMediaGalleryFolderSelectActionGroup" stepKey="selectFolder"/> + <actionGroup ref="AdminEnhancedMediaGalleryViewImageDetails" stepKey="openViewImageDetails"/> + <actionGroup ref="AdminEnhancedMediaGalleryClickEntityUsedInActionGroup" stepKey="clickUsedInPages"> + <argument name="entityName" value="Pages"/> + </actionGroup> + <actionGroup ref="AdminAssertMediaGalleryFilterPlaceHolderGridActionGroup" stepKey="assertFilterApplied"> + <argument name="filterPlaceholder" value="{{ImageMetadata.title}}"/> + </actionGroup> + <actionGroup ref="AdminDeleteCMSPageByUrlKeyActionGroup" stepKey="deleteCmsPage"> + <argument name="pageUrlKey" value="{{_defaultCmsPage.identifier}}"/> + </actionGroup> + + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearFiltersInPageGrid"/> + <actionGroup ref="AdminOpenStandaloneMediaGalleryActionGroup" stepKey="openMediaGallery"/> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="resetMediaGalleryGridFiltersAndAgain"/> + <actionGroup ref="AdminMediaGalleryFolderSelectActionGroup" stepKey="selectFolderAgain"/> + <actionGroup ref="AdminEnhancedMediaGalleryViewImageDetails" stepKey="openViewImageDetailsToVerifyEmptyUsedIn"/> + <actionGroup ref="AssertAdminEnhancedMediaGalleryUsedInSectionNotDisplayedActionGroup" stepKey="assertThereIsNoUsedInSection"/> + <actionGroup ref="AdminEnhancedMediaGalleryCloseViewDetailsActionGroup" stepKey="closeDetails"/> + </test> +</tests> diff --git a/app/code/Magento/MediaGalleryCmsUi/Test/Mftf/Test/AdminMediaGalleryCmsUiUsedInPagesFilterTest.xml b/app/code/Magento/MediaGalleryCmsUi/Test/Mftf/Test/AdminMediaGalleryCmsUiUsedInPagesFilterTest.xml index e72e65cf8de90..7254e95d619f5 100644 --- a/app/code/Magento/MediaGalleryCmsUi/Test/Mftf/Test/AdminMediaGalleryCmsUiUsedInPagesFilterTest.xml +++ b/app/code/Magento/MediaGalleryCmsUi/Test/Mftf/Test/AdminMediaGalleryCmsUiUsedInPagesFilterTest.xml @@ -9,9 +9,6 @@ <tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> <test name="AdminMediaGalleryCmsUiUsedInPagesFilterTest"> <annotations> - <skip> - <issueId value="https://github.com/magento/adobe-stock-integration/issues/1825"/> - </skip> <features value="AdminMediaGalleryUsedInPagesFilter"/> <useCaseId value="https://github.com/magento/adobe-stock-integration/issues/1168"/> <title value="Used in pages filter"/> @@ -58,9 +55,10 @@ <actionGroup ref="AdminEnhancedMediaGallerySelectImageForMassActionActionGroup" stepKey="selectFirstImageToDelete"> <argument name="imageName" value="{{ImageMetadata.title}}"/> </actionGroup> - <actionGroup ref="AdminEnhancedMediaGalleryClickDeleteImagesButtonActionGroup" stepKey="clikDeleteSelectedButton"/> + <actionGroup ref="AdminEnhancedMediaGalleryClickDeleteImagesButtonActionGroup" stepKey="clickDeleteSelectedButton"/> + <actionGroup ref="AdminEnhancedMediaGalleryConfirmDeleteImagesActionGroup" stepKey="deleteImages"/> - <actionGroup ref="AdminNavigateToPageGridActionGroup" stepKey="navigateToCmsPageGrid"/> + <actionGroup ref="AdminOpenCMSPagesGridActionGroup" stepKey="navigateToCmsPageGrid"/> <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearGridFilters"/> <actionGroup ref="AdminSearchCmsPageInGridByUrlKeyActionGroup" stepKey="findCreatedCmsPage"> <argument name="urlKey" value="test-page-1"/> @@ -68,5 +66,6 @@ <actionGroup ref="AdminDeleteCmsPageFromGridActionGroup" stepKey="deleteCmsPage"> <argument name="urlKey" value="test-page-1"/> </actionGroup> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clickApplyFiltersButton"/> </test> </tests> diff --git a/app/code/Magento/MediaGalleryRenditions/Model/Config.php b/app/code/Magento/MediaGalleryRenditions/Model/Config.php index d1a48904d1f13..6622ef36dffd7 100644 --- a/app/code/Magento/MediaGalleryRenditions/Model/Config.php +++ b/app/code/Magento/MediaGalleryRenditions/Model/Config.php @@ -18,7 +18,8 @@ class Config { private const TABLE_CORE_CONFIG_DATA = 'core_config_data'; - private const XML_PATH_ENABLED = 'system/media_gallery/enabled'; + private const XML_PATH_MEDIA_GALLERY_ENABLED = 'system/media_gallery/enabled'; + private const XML_PATH_ENABLED = 'system/media_gallery_renditions/enabled'; private const XML_PATH_MEDIA_GALLERY_RENDITIONS_WIDTH_PATH = 'system/media_gallery_renditions/width'; private const XML_PATH_MEDIA_GALLERY_RENDITIONS_HEIGHT_PATH = 'system/media_gallery_renditions/height'; @@ -49,6 +50,16 @@ public function __construct( * * @return bool */ + public function isMediaGalleryEnabled(): bool + { + return $this->scopeConfig->isSetFlag(self::XML_PATH_MEDIA_GALLERY_ENABLED); + } + + /** + * Should the renditions be inserted in the content instead of original image + * + * @return bool + */ public function isEnabled(): bool { return $this->scopeConfig->isSetFlag(self::XML_PATH_ENABLED); diff --git a/app/code/Magento/MediaGalleryRenditions/Plugin/SetRenditionPath.php b/app/code/Magento/MediaGalleryRenditions/Plugin/SetRenditionPath.php index ec2012c528ef1..2fc49950463ea 100644 --- a/app/code/Magento/MediaGalleryRenditions/Plugin/SetRenditionPath.php +++ b/app/code/Magento/MediaGalleryRenditions/Plugin/SetRenditionPath.php @@ -91,7 +91,7 @@ public function beforeExecute( $storeId ]; - if (!$this->config->isEnabled()) { + if (!$this->config->isEnabled() || !$this->config->isMediaGalleryEnabled()) { return $arguments; } diff --git a/app/code/Magento/MediaGalleryRenditions/Plugin/UpdateRenditionsOnConfigChange.php b/app/code/Magento/MediaGalleryRenditions/Plugin/UpdateRenditionsOnConfigChange.php index 9cf969c16782f..6fcb37398f3ad 100644 --- a/app/code/Magento/MediaGalleryRenditions/Plugin/UpdateRenditionsOnConfigChange.php +++ b/app/code/Magento/MediaGalleryRenditions/Plugin/UpdateRenditionsOnConfigChange.php @@ -8,6 +8,7 @@ namespace Magento\MediaGalleryRenditions\Plugin; use Magento\Framework\App\Config\Value; +use Magento\MediaGalleryRenditions\Model\Config; use Magento\MediaGalleryRenditions\Model\Queue\ScheduleRenditionsUpdate; /** @@ -15,6 +16,7 @@ */ class UpdateRenditionsOnConfigChange { + private const XML_PATH_MEDIA_GALLERY_RENDITIONS_ENABLE_PATH = 'system/media_gallery_renditions/enabled'; private const XML_PATH_MEDIA_GALLERY_RENDITIONS_WIDTH_PATH = 'system/media_gallery_renditions/width'; private const XML_PATH_MEDIA_GALLERY_RENDITIONS_HEIGHT_PATH = 'system/media_gallery_renditions/height'; @@ -24,10 +26,17 @@ class UpdateRenditionsOnConfigChange private $scheduleRenditionsUpdate; /** + * @var Config + */ + private $config; + + /** + * @param Config $config * @param ScheduleRenditionsUpdate $scheduleRenditionsUpdate */ - public function __construct(ScheduleRenditionsUpdate $scheduleRenditionsUpdate) + public function __construct(Config $config, ScheduleRenditionsUpdate $scheduleRenditionsUpdate) { + $this->config = $config; $this->scheduleRenditionsUpdate = $scheduleRenditionsUpdate; } @@ -41,7 +50,13 @@ public function __construct(ScheduleRenditionsUpdate $scheduleRenditionsUpdate) */ public function afterSave(Value $config, Value $result): Value { - if ($this->isRenditionsValue($result) && $result->isValueChanged()) { + if ($this->isRenditionsEnabled($result)) { + $this->scheduleRenditionsUpdate->execute(); + + return $result; + } + + if ($this->config->isEnabled() && $this->isRenditionsValue($result) && $result->isValueChanged()) { $this->scheduleRenditionsUpdate->execute(); } @@ -59,4 +74,17 @@ private function isRenditionsValue(Value $value): bool return $value->getPath() === self::XML_PATH_MEDIA_GALLERY_RENDITIONS_WIDTH_PATH || $value->getPath() === self::XML_PATH_MEDIA_GALLERY_RENDITIONS_HEIGHT_PATH; } + + /** + * Determine if media gallery renditions is enabled based on configuration value + * + * @param Value $value + * @return bool + */ + private function isRenditionsEnabled(Value $value): bool + { + return $value->getPath() === self::XML_PATH_MEDIA_GALLERY_RENDITIONS_ENABLE_PATH + && $value->isValueChanged() + && $value->getValue(); + } } diff --git a/app/code/Magento/MediaGalleryRenditions/Test/Mftf/ActionGroup/AdminRenditionsSetImageSizeActionGroup.xml b/app/code/Magento/MediaGalleryRenditions/Test/Mftf/ActionGroup/AdminRenditionsSetImageSizeActionGroup.xml new file mode 100644 index 0000000000000..b841d064aab7e --- /dev/null +++ b/app/code/Magento/MediaGalleryRenditions/Test/Mftf/ActionGroup/AdminRenditionsSetImageSizeActionGroup.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminRenditionsSetImageSizeActionGroup"> + <arguments> + <argument name="width" defaultValue="1000" type="string"/> + <argument name="height" defaultValue="1000" type="string"/> + </arguments> + <magentoCLI command="config:set system/media_gallery_renditions/width {{width}}" stepKey="setWidth"/> + <magentoCLI command="config:set system/media_gallery_renditions/height {{height}}" stepKey="setHeight"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/MediaGalleryRenditions/Test/Mftf/Test/AdminMediaGalleryInsertImageLargeFileSizeTest.xml b/app/code/Magento/MediaGalleryRenditions/Test/Mftf/Test/AdminMediaGalleryInsertImageLargeFileSizeTest.xml new file mode 100644 index 0000000000000..2d80b490e2d9b --- /dev/null +++ b/app/code/Magento/MediaGalleryRenditions/Test/Mftf/Test/AdminMediaGalleryInsertImageLargeFileSizeTest.xml @@ -0,0 +1,58 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminMediaGalleryInsertImageLargeFileSizeTest"> + <annotations> + <features value="MediaGalleryRenditions"/> + <stories value="User inserts image rendition to the content"/> + <title value="Admin user should see correct image file size after rendition"/> + <description value="Admin user should see correct image file size after rendition"/> + <testCaseId value="https://studio.cucumber.io/projects/131313/test-plan/folders/1507933/scenarios/5200023"/> + <useCaseId value="https://github.com/magento/adobe-stock-integration/issues/1806"/> + <severity value="AVERAGE"/> + <group value="media_gallery_ui"/> + </annotations> + + <before> + <createData entity="SimpleSubCategory" stepKey="category"/> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + <actionGroup ref="AdminEnhancedMediaGalleryDeletedAllImagesActionGroup" stepKey="deleteAllImages"/> + </before> + + <after> + <deleteData createDataKey="category" stepKey="deleteCategory"/> + <actionGroup ref="AdminEnhancedMediaGalleryDeletedAllImagesActionGroup" stepKey="deleteAllImages"/> + <actionGroup ref="AdminOpenCategoryGridPageActionGroup" stepKey="openMediaGalleryCategoryGridPage"/> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearGridFilters"/> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logoutFromAdmin"/> + </after> + + <!-- Open category page --> + <actionGroup ref="GoToAdminCategoryPageByIdActionGroup" stepKey="openCategoryPage"> + <argument name="id" value="$category.id$"/> + </actionGroup> + + <!-- Add image to category from gallery --> + <actionGroup ref="AdminOpenMediaGalleryFromCategoryImageUploaderActionGroup" stepKey="openMediaGallery"/> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearGridFiltersAgain"/> + <actionGroup ref="AdminEnhancedMediaGalleryUploadImageActionGroup" stepKey="addCategoryImage"> + <argument name="image" value="ImageUpload"/> + </actionGroup> + <actionGroup ref="AdminMediaGalleryClickImageInGridActionGroup" stepKey="selectImage"> + <argument name="imageName" value="{{ImageUpload.fileName}}"/> + </actionGroup> + <actionGroup ref="AdminMediaGalleryClickAddSelectedActionGroup" stepKey="addSelected"/> + + <!-- Assert added image size --> + <actionGroup ref="AdminAssertImageUploadFileSizeThanActionGroup" stepKey="assertSize"> + <argument name="fileSize" value="26 KB"/> + </actionGroup> + </test> +</tests> diff --git a/app/code/Magento/MediaGalleryRenditions/Test/Mftf/Test/AdminMediaGalleryInsertLargeImageFileSizeTest.xml b/app/code/Magento/MediaGalleryRenditions/Test/Mftf/Test/AdminMediaGalleryInsertLargeImageFileSizeTest.xml new file mode 100644 index 0000000000000..061f062eabe6b --- /dev/null +++ b/app/code/Magento/MediaGalleryRenditions/Test/Mftf/Test/AdminMediaGalleryInsertLargeImageFileSizeTest.xml @@ -0,0 +1,69 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminMediaGalleryInsertLargeImageFileSizeTest" deprecated="Use AdminMediaGalleryInsertImageLargeFileSizeTest instead"> + <annotations> + <features value="AdminMediaGalleryInsertLargeImageFileSizeTest"/> + <useCaseId value="https://github.com/magento/adobe-stock-integration/issues/1806"/> + <title value="DEPRECATED. Admin user should see correct image file size after rendition"/> + <testCaseId value="https://studio.cucumber.io/projects/131313/test-plan/folders/1507933/scenarios/5200023"/> + <stories value="User inserts image rendition to the content"/> + <description value="Admin user should see correct image file size after rendition"/> + <severity value="AVERAGE"/> + <group value="media_gallery_ui"/> + <skip> + <issueId value="DEPRECATED">Use AdminMediaGalleryInsertImageLargeFileSizeTest instead</issueId> + </skip> + </annotations> + <before> + <createData entity="SimpleSubCategory" stepKey="category"/> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + </before> + <after> + <!-- Delete uploaded image --> + <actionGroup ref="AdminOpenCategoryGridPageActionGroup" stepKey="openCategoryPageFoDelete"/> + <actionGroup ref="AdminEditCategoryInGridPageActionGroup" stepKey="editCategoryItemForDelete"> + <argument name="categoryName" value="$category.name$"/> + </actionGroup> + <actionGroup ref="AdminOpenMediaGalleryFromCategoryImageUploaderActionGroup" stepKey="openMediaGalleryForDelete"/> + <actionGroup ref="AdminEnhancedMediaGalleryEnableMassActionModeActionGroup" stepKey="enableMassActionToDeleteImages"/> + <actionGroup ref="AdminEnhancedMediaGallerySelectImageForMassActionActionGroup" stepKey="selectSecondImageToDelete"> + <argument name="imageName" value="{{ImageUpload.fileName}}"/> + </actionGroup> + <actionGroup ref="AdminEnhancedMediaGalleryClickDeleteImagesButtonActionGroup" stepKey="clickDeleteSelectedButton"/> + <actionGroup ref="AdminEnhancedMediaGalleryConfirmDeleteImagesActionGroup" stepKey="deleteImages"/> + + <!-- Delete category --> + <deleteData createDataKey="category" stepKey="deleteCategory"/> + </after> + + <!-- Open category page --> + <actionGroup ref="AdminOpenCategoryGridPageActionGroup" stepKey="openCategoryPage"/> + <actionGroup ref="AdminEditCategoryInGridPageActionGroup" stepKey="editCategoryItem"> + <argument name="categoryName" value="$category.name$"/> + </actionGroup> + + <!-- Add image to category from gallery --> + <actionGroup ref="AdminOpenMediaGalleryFromCategoryImageUploaderActionGroup" stepKey="openMediaGallery"/> + <actionGroup ref="AdminEnhancedMediaGalleryUploadImageActionGroup" stepKey="addCategoryImage"> + <argument name="image" value="ImageUpload"/> + </actionGroup> + <actionGroup ref="AdminMediaGalleryClickImageInGridActionGroup" stepKey="selectImage"> + <argument name="imageName" value="{{ImageUpload.fileName}}"/> + </actionGroup> + <actionGroup ref="AdminMediaGalleryClickAddSelectedActionGroup" stepKey="addSelected"/> + + + <!-- Assert added image size --> + <actionGroup ref="AdminAssertImageUploadFileSizeThanActionGroup" stepKey="assertSize"> + <argument name="fileSize" value="26 KB"/> + </actionGroup> + </test> +</tests> diff --git a/app/code/Magento/MediaGalleryRenditions/etc/adminhtml/system.xml b/app/code/Magento/MediaGalleryRenditions/etc/adminhtml/system.xml index 64f338d53a283..f36f628cb122f 100644 --- a/app/code/Magento/MediaGalleryRenditions/etc/adminhtml/system.xml +++ b/app/code/Magento/MediaGalleryRenditions/etc/adminhtml/system.xml @@ -9,14 +9,22 @@ <system> <section id="system"> <group id="media_gallery_renditions" translate="label" type="text" sortOrder="1010" showInDefault="1" showInWebsite="0" showInStore="0"> - <label>Media Gallery Renditions</label> - <field id="width" translate="label" type="text" sortOrder="10" showInDefault="1" showInWebsite="0" showInStore="0"> - <label>Max Width</label> + <label>Media Gallery Image Optimization</label> + <field id="enabled" translate="label" type="select" sortOrder="10" showInDefault="1" showInWebsite="0" showInStore="0"> + <label>Enable Image Optimization</label> + <source_model>Magento\Config\Model\Config\Source\Yesno</source_model> + </field> + <comment>Resize images to improve performance and decrease the file size. When you use an image from Media Gallery on the storefront, the smaller image is generated and placed instead of the original. + Changing these settings will update all generated images.</comment> + <field id="width" translate="label" type="text" sortOrder="20" showInDefault="1" showInWebsite="0" showInStore="0"> + <label>Maximum Width</label> <validate>validate-zero-or-greater validate-digits</validate> + <comment>Enter the maximum width of an image in pixels.</comment> </field> - <field id="height" translate="label" type="text" sortOrder="20" showInDefault="1" showInWebsite="0" showInStore="0"> - <label>Max Height</label> + <field id="height" translate="label" type="text" sortOrder="30" showInDefault="1" showInWebsite="0" showInStore="0"> + <label>Maximum Height</label> <validate>validate-zero-or-greater validate-digits</validate> + <comment>Enter the maximum height of an image in pixels.</comment> </field> </group> </section> diff --git a/app/code/Magento/MediaGalleryRenditions/etc/config.xml b/app/code/Magento/MediaGalleryRenditions/etc/config.xml index 58c5aa1f11fd2..871571a049875 100644 --- a/app/code/Magento/MediaGalleryRenditions/etc/config.xml +++ b/app/code/Magento/MediaGalleryRenditions/etc/config.xml @@ -9,9 +9,15 @@ <default> <system> <media_gallery_renditions> + <enabled>1</enabled> <width>1000</width> <height>1000</height> </media_gallery_renditions> + <media_storage_configuration> + <allowed_resources> + <renditions_folder>.renditions</renditions_folder> + </allowed_resources> + </media_storage_configuration> </system> </default> </config> diff --git a/app/code/Magento/MediaGallerySynchronization/Model/CreateAssetFromFile.php b/app/code/Magento/MediaGallerySynchronization/Model/CreateAssetFromFile.php index b4c360c3e0538..19c2569695d56 100644 --- a/app/code/Magento/MediaGallerySynchronization/Model/CreateAssetFromFile.php +++ b/app/code/Magento/MediaGallerySynchronization/Model/CreateAssetFromFile.php @@ -10,7 +10,6 @@ use Magento\Framework\App\Filesystem\DirectoryList; use Magento\Framework\Exception\FileSystemException; use Magento\Framework\Filesystem; -use Magento\Framework\Filesystem\Directory\ReadInterface; use Magento\Framework\Filesystem\Driver\File; use Magento\MediaGalleryApi\Api\Data\AssetInterface; use Magento\MediaGalleryApi\Api\Data\AssetInterfaceFactory; @@ -74,19 +73,37 @@ public function __construct( public function execute(string $path): AssetInterface { $absolutePath = $this->getMediaDirectory()->getAbsolutePath($path); - $file = $this->getFileInfo->execute($absolutePath); - [$width, $height] = getimagesize($absolutePath); + $driver = $this->getMediaDirectory()->getDriver(); + + if ($driver instanceof Filesystem\ExtendedDriverInterface) { + $meta = $driver->getMetadata($absolutePath); + } else { + /** + * SPL file info is not compatible with remote storages and must not be used. + */ + $file = $this->getFileInfo->execute($absolutePath); + [$width, $height] = getimagesize($absolutePath); + $meta = [ + 'size' => $file->getSize(), + 'extension' => $file->getExtension(), + 'basename' => $file->getBasename(), + 'extra' => [ + 'image-width' => $width, + 'image-height' => $height + ] + ]; + } return $this->assetFactory->create( [ 'id' => null, 'path' => $path, - 'title' => $file->getBasename(), - 'width' => $width, - 'height' => $height, + 'title' => $meta['basename'], + 'width' => $meta['extra']['image-width'], + 'height' => $meta['extra']['image-height'], 'hash' => $this->getHash($path), - 'size' => $file->getSize(), - 'contentType' => 'image/' . $file->getExtension(), + 'size' => $meta['size'], + 'contentType' => 'image/' . $meta['extension'], 'source' => 'Local' ] ); @@ -105,12 +122,12 @@ private function getHash(string $path): string } /** - * Retrieve media directory instance with read access + * Retrieve media directory instance with write access * - * @return ReadInterface + * @return Filesystem\Directory\WriteInterface */ - private function getMediaDirectory(): ReadInterface + private function getMediaDirectory(): Filesystem\Directory\WriteInterface { - return $this->filesystem->getDirectoryRead(DirectoryList::MEDIA); + return $this->filesystem->getDirectoryWrite(DirectoryList::MEDIA); } } diff --git a/app/code/Magento/MediaGalleryUi/Controller/Adminhtml/Directories/GetTree.php b/app/code/Magento/MediaGalleryUi/Controller/Adminhtml/Directories/GetTree.php index d4885cae055dd..d9a38895e1fa0 100644 --- a/app/code/Magento/MediaGalleryUi/Controller/Adminhtml/Directories/GetTree.php +++ b/app/code/Magento/MediaGalleryUi/Controller/Adminhtml/Directories/GetTree.php @@ -11,7 +11,7 @@ use Magento\Framework\App\Action\HttpGetActionInterface; use Magento\Framework\Controller\Result\Json; use Magento\Framework\Controller\ResultFactory; -use Magento\MediaGalleryUi\Model\Directories\GetFolderTree; +use Magento\MediaGalleryUi\Model\Directories\GetDirectoryTree; use Psr\Log\LoggerInterface; /** @@ -33,25 +33,25 @@ class GetTree extends Action implements HttpGetActionInterface private $logger; /** - * @var GetFolderTree + * @var GetDirectoryTree */ - private $getFolderTree; + private $getDirectoryTree; /** * Constructor * * @param Action\Context $context * @param LoggerInterface $logger - * @param GetFolderTree $getFolderTree + * @param GetDirectoryTree $getDirectoryTree */ public function __construct( Action\Context $context, LoggerInterface $logger, - GetFolderTree $getFolderTree + GetDirectoryTree $getDirectoryTree ) { parent::__construct($context); $this->logger = $logger; - $this->getFolderTree = $getFolderTree; + $this->getDirectoryTree = $getDirectoryTree; } /** * @inheritdoc @@ -59,7 +59,9 @@ public function __construct( public function execute() { try { - $responseContent[] = $this->getFolderTree->execute(); + $responseContent = [ + $this->getDirectoryTree->execute() + ]; $responseCode = self::HTTP_OK; } catch (\Exception $exception) { $this->logger->critical($exception); diff --git a/app/code/Magento/MediaGalleryUi/Controller/Adminhtml/Image/OnInsert.php b/app/code/Magento/MediaGalleryUi/Controller/Adminhtml/Image/OnInsert.php new file mode 100644 index 0000000000000..b92724f64148e --- /dev/null +++ b/app/code/Magento/MediaGalleryUi/Controller/Adminhtml/Image/OnInsert.php @@ -0,0 +1,74 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +declare(strict_types=1); + +namespace Magento\MediaGalleryUi\Controller\Adminhtml\Image; + +use Magento\Backend\App\Action; +use Magento\Backend\App\Action\Context; +use Magento\Framework\App\Action\HttpPostActionInterface; +use Magento\Framework\Controller\Result\JsonFactory; +use Magento\Framework\Controller\ResultInterface; +use Magento\MediaGalleryUi\Model\InsertImageData\GetInsertImageData; + +/** + * OnInsert action returns on insert image details + */ +class OnInsert extends Action implements HttpPostActionInterface +{ + /** + * @see _isAllowed() + */ + public const ADMIN_RESOURCE = 'Magento_MediaGalleryUiApi::insert_assets'; + + /** + * @var JsonFactory + */ + private $resultJsonFactory; + + /** + * @var GetInsertImageData + */ + private $getInsertImageData; + + /** + * @param Context $context + * @param JsonFactory $resultJsonFactory + * @param GetInsertImageData $getInsertImageData + */ + public function __construct( + Context $context, + JsonFactory $resultJsonFactory, + GetInsertImageData $getInsertImageData + ) { + parent::__construct($context); + $this->resultJsonFactory = $resultJsonFactory; + $this->getInsertImageData = $getInsertImageData; + } + + /** + * Return a content (just a link or an html block) for inserting image to the content + * + * @return ResultInterface + */ + public function execute() + { + $data = $this->getRequest()->getParams(); + $insertImageData = $this->getInsertImageData->execute( + $data['filename'], + (bool)$data['force_static_path'], + (bool)$data['as_is'], + isset($data['store']) ? (int)$data['store'] : null + ); + + return $this->resultJsonFactory->create()->setData([ + 'content' => $insertImageData->getContent(), + 'size' => $insertImageData->getSize(), + 'type' => $insertImageData->getType(), + ]); + } +} diff --git a/app/code/Magento/MediaGalleryUi/Model/Directories/GetFolderTree.php b/app/code/Magento/MediaGalleryUi/Model/Directories/GetDirectoryTree.php similarity index 99% rename from app/code/Magento/MediaGalleryUi/Model/Directories/GetFolderTree.php rename to app/code/Magento/MediaGalleryUi/Model/Directories/GetDirectoryTree.php index c22165ba4e51f..35e34a7e5532c 100644 --- a/app/code/Magento/MediaGalleryUi/Model/Directories/GetFolderTree.php +++ b/app/code/Magento/MediaGalleryUi/Model/Directories/GetDirectoryTree.php @@ -16,7 +16,7 @@ /** * Build media gallery folder tree structure by path */ -class GetFolderTree +class GetDirectoryTree { /** * @var Filesystem diff --git a/app/code/Magento/MediaGalleryUi/Model/InsertImageData.php b/app/code/Magento/MediaGalleryUi/Model/InsertImageData.php new file mode 100644 index 0000000000000..f70ed8e308c99 --- /dev/null +++ b/app/code/Magento/MediaGalleryUi/Model/InsertImageData.php @@ -0,0 +1,102 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +declare(strict_types=1); + +namespace Magento\MediaGalleryUi\Model; + +use Magento\MediaGalleryUi\Model\InsertImageDataExtensionInterface; + +/** + * Class responsible to provide insert image details + */ +class InsertImageData implements InsertImageDataInterface +{ + /** + * @var InsertImageDataExtensionInterface + */ + private $extensionAttributes; + + /** + * @var string + */ + private $content; + + /** + * @var int + */ + private $size; + + /** + * @var string + */ + private $type; + + /** + * InsertImageData constructor. + * + * @param string $content + * @param int $size + * @param string $type + */ + public function __construct(string $content, int $size, string $type) + { + $this->content = $content; + $this->size = $size; + $this->type = $type; + } + + /** + * Returns a content (just a link or an html block) for inserting image to the content + * + * @return string + */ + public function getContent(): string + { + return $this->content; + } + + /** + * Returns size of requested file + * + * @return int + */ + public function getSize(): int + { + return $this->size; + } + + /** + * Returns MIME type of requested file + * + * @return string + */ + public function getType(): string + { + return $this->type; + } + + /** + * Get extension attributes + * + * @return \Magento\MediaGalleryUi\Model\InsertImageDataExtensionInterface|null + */ + public function getExtensionAttributes(): ?InsertImageDataExtensionInterface + { + return $this->extensionAttributes; + } + + /** + * Set extension attributes + * + * @param \Magento\MediaGalleryUi\Model\InsertImageDataExtensionInterface $extensionAttributes + * @return void + */ + public function setExtensionAttributes(InsertImageDataExtensionInterface $extensionAttributes): void + { + $this->extensionAttributes = $extensionAttributes; + } +} diff --git a/app/code/Magento/MediaGalleryUi/Model/InsertImageData/GetInsertImageData.php b/app/code/Magento/MediaGalleryUi/Model/InsertImageData/GetInsertImageData.php new file mode 100644 index 0000000000000..6f1d399784139 --- /dev/null +++ b/app/code/Magento/MediaGalleryUi/Model/InsertImageData/GetInsertImageData.php @@ -0,0 +1,158 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +declare(strict_types=1); + +namespace Magento\MediaGalleryUi\Model\InsertImageData; + +use Magento\Cms\Helper\Wysiwyg\Images; +use Magento\Cms\Model\Wysiwyg\Images\GetInsertImageContent; +use Magento\Framework\App\Filesystem\DirectoryList; +use Magento\Framework\File\Mime; +use Magento\Framework\Filesystem; +use Magento\Framework\Filesystem\Directory\ReadInterface; +use Magento\MediaGalleryUi\Model\InsertImageDataFactory; +use Magento\MediaGalleryUi\Model\InsertImageDataInterface; + +class GetInsertImageData +{ + /** + * @var ReadInterface + */ + private $mediaDirectory; + + /** + * @var Filesystem + */ + private $filesystem; + + /** + * @var GetInsertImageContent + */ + private $getInsertImageContent; + + /** + * @var InsertImageDataFactory + */ + private $insertImageDataFactory; + + /** + * @var Mime + */ + private $mime; + + /** + * @var Images + */ + private $imagesHelper; + + /** + * GetInsertImageData constructor. + * + * @param GetInsertImageContent $getInsertImageContent + * @param Filesystem $fileSystem + * @param Mime $mime + * @param InsertImageDataFactory $insertImageDataFactory + * @param Images $imagesHelper + */ + public function __construct( + GetInsertImageContent $getInsertImageContent, + Filesystem $fileSystem, + Mime $mime, + InsertImageDataFactory $insertImageDataFactory, + Images $imagesHelper + ) { + $this->getInsertImageContent = $getInsertImageContent; + $this->filesystem = $fileSystem; + $this->mime = $mime; + $this->insertImageDataFactory = $insertImageDataFactory; + $this->imagesHelper = $imagesHelper; + } + + /** + * Returns image data object + * + * @param string $encodedFilename + * @param bool $forceStaticPath + * @param bool $renderAsTag + * @param int|null $storeId + * @return InsertImageDataInterface + */ + public function execute( + string $encodedFilename, + bool $forceStaticPath, + bool $renderAsTag, + ?int $storeId = null + ): InsertImageDataInterface { + $content = $this->getInsertImageContent->execute( + $encodedFilename, + $forceStaticPath, + $renderAsTag, + $storeId + ); + $relativePath = $this->getImageRelativePath($content); + $size = $forceStaticPath ? $this->getSize($relativePath) : 0; + $type = $forceStaticPath ? $this->getType($relativePath) : ''; + return $this->insertImageDataFactory->create([ + 'content' => $content, + 'size' => $size, + 'type' => $type + ]); + } + + /** + * Retrieve size of requested file + * + * @param string $path + * @return int + */ + private function getSize(string $path): int + { + $directory = $this->getMediaDirectory(); + + return $directory->isExist($path) ? $directory->stat($path)['size'] : 0; + } + + /** + * Retrieve MIME type of requested file + * + * @param string $path + * @return string + */ + public function getType(string $path): string + { + $fileExist = $this->getMediaDirectory()->isExist($path); + + return $fileExist ? $this->mime->getMimeType($this->getMediaDirectory()->getAbsolutePath($path)) : ''; + } + + /** + * Retrieve pub directory read interface instance + * + * @return ReadInterface + */ + private function getMediaDirectory(): ReadInterface + { + if ($this->mediaDirectory === null) { + $this->mediaDirectory = $this->filesystem->getDirectoryRead(DirectoryList::MEDIA); + } + + return $this->mediaDirectory; + } + + /** + * Retrieves image relative path + * + * @param string $content + * @return string + */ + private function getImageRelativePath(string $content): string + { + // phpcs:ignore Magento2.Functions.DiscouragedFunction + $mediaPath = parse_url($this->imagesHelper->getCurrentUrl(), PHP_URL_PATH); + return substr($content, strlen($mediaPath)); + } +} diff --git a/app/code/Magento/MediaGalleryUi/Model/InsertImageDataInterface.php b/app/code/Magento/MediaGalleryUi/Model/InsertImageDataInterface.php new file mode 100644 index 0000000000000..063d76292d625 --- /dev/null +++ b/app/code/Magento/MediaGalleryUi/Model/InsertImageDataInterface.php @@ -0,0 +1,55 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +declare(strict_types=1); + +namespace Magento\MediaGalleryUi\Model; + +use Magento\Framework\Api\ExtensibleDataInterface; + +/** + * Class responsible to provide insert image details + */ +interface InsertImageDataInterface extends ExtensibleDataInterface +{ + /** + * Returns a content (just a link or an html block) for inserting image to the content + * + * @return null|string + */ + public function getContent(): ?string; + + /** + * Returns size of requested file + * + * @return int + */ + public function getSize(): int; + + /** + * Returns MIME type of requested file + * + * @return string + */ + public function getType(): string; + + /** + * Get extension attributes + * + * @return \Magento\MediaGalleryUi\Model\InsertImageDataExtensionInterface|null + */ + public function getExtensionAttributes(): ?\Magento\MediaGalleryUi\Model\InsertImageDataExtensionInterface; + + /** + * Set extension attributes + * + * @param \Magento\MediaGalleryUi\Model\InsertImageDataExtensionInterface $extensionAttributes + * @return void + */ + public function setExtensionAttributes( + \Magento\MediaGalleryUi\Model\InsertImageDataExtensionInterface $extensionAttributes + ): void; +} diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminAssertImageUploadFileSizeActionGroup.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminAssertImageUploadFileSizeActionGroup.xml new file mode 100644 index 0000000000000..8d8ff1f34e293 --- /dev/null +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminAssertImageUploadFileSizeActionGroup.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminAssertImageUploadFileSizeThanActionGroup"> + <annotations> + <description>Validates that the provided image has correct file size in category content section.</description> + </annotations> + <arguments> + <argument name="fileSize" type="string"/> + </arguments> + + <grabTextFrom selector="{{AdminCategoryContentSection.imageFileMeta}}" stepKey="imageSize"/> + <assertStringContainsString stepKey="assertFileSize"> + <expectedResult type="string">{{fileSize}}</expectedResult> + <actualResult type="variable">imageSize</actualResult> + </assertStringContainsString> + + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminEnhancedMediaGalleryAssertImagesDeletedInBulkActionGroup.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminEnhancedMediaGalleryAssertImagesDeletedInBulkActionGroup.xml index ca503b7357300..6ffa8f4647341 100644 --- a/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminEnhancedMediaGalleryAssertImagesDeletedInBulkActionGroup.xml +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminEnhancedMediaGalleryAssertImagesDeletedInBulkActionGroup.xml @@ -15,6 +15,6 @@ <arguments> <argument name="numberOfAssetsDeleted" type="string"/> </arguments> - <see userInput='{{numberOfAssetsDeleted}} assets have been successfully deleted.' stepKey="verifyDeleteImages"/> + <waitForText userInput='{{numberOfAssetsDeleted}} assets have been successfully deleted.' stepKey="verifyDeleteImages"/> </actionGroup> </actionGroups> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminEnhancedMediaGalleryAssertMassActionModeDetailsActionGroup.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminEnhancedMediaGalleryAssertMassActionModeDetailsActionGroup.xml index efcf40cd2b644..761333fdbb151 100644 --- a/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminEnhancedMediaGalleryAssertMassActionModeDetailsActionGroup.xml +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminEnhancedMediaGalleryAssertMassActionModeDetailsActionGroup.xml @@ -15,7 +15,7 @@ <arguments> <argument name="imageName" type="string"/> </arguments> - <click selector="{{AdminEnhancedMediaGalleryMassActionSection.massActionCheckbox(imageName)}}" stepKey="selectImageInGridToDelte"/> - <see selector="{{AdminEnhancedMediaGalleryMassActionSection.totalSelected}}" userInput="(1 Selected)" stepKey="verifySelectedCount"/> + <checkOption selector="{{AdminEnhancedMediaGalleryMassActionSection.massActionCheckbox(imageName)}}" stepKey="selectImageInGridToDelte"/> + <waitForText selector="{{AdminEnhancedMediaGalleryMassActionSection.totalSelected}}" userInput="(1 Selected)" stepKey="verifySelectedCount"/> </actionGroup> </actionGroups> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminEnhancedMediaGalleryCloseViewDetailsActionGroup.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminEnhancedMediaGalleryCloseViewDetailsActionGroup.xml index 3754eb319da44..effb574853bb7 100644 --- a/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminEnhancedMediaGalleryCloseViewDetailsActionGroup.xml +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminEnhancedMediaGalleryCloseViewDetailsActionGroup.xml @@ -10,10 +10,10 @@ xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> <actionGroup name="AdminEnhancedMediaGalleryCloseViewDetailsActionGroup"> <annotations> - <description>Closes View Details panel</description> + <description>Closes View Details panel of Media Gallery image.</description> </annotations> <click selector="{{AdminEnhancedMediaGalleryViewDetailsSection.cancel}}" stepKey="clickCancel"/> - <wait time="1" stepKey="waitForElementRender"/> + <waitForElementNotVisible selector="{{AdminEnhancedMediaGalleryViewDetailsSection.modalTitle}}" stepKey="waitForElementRender"/> </actionGroup> </actionGroups> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminEnhancedMediaGalleryDeletedAllImagesActionGroup.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminEnhancedMediaGalleryDeletedAllImagesActionGroup.xml new file mode 100644 index 0000000000000..4aa460327578a --- /dev/null +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminEnhancedMediaGalleryDeletedAllImagesActionGroup.xml @@ -0,0 +1,26 @@ +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<actionGroups xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> + <actionGroup name="AdminEnhancedMediaGalleryDeletedAllImagesActionGroup"> + <annotations> + <description>Open Media Gallery page and delete all images</description> + </annotations> + <amOnPage url="{{AdminStandaloneMediaGalleryPage.url}}" stepKey="openMediaGalleryPage"/> + <!-- It sometimes is loading too long for default 10s --> + <waitForPageLoad time="60" stepKey="waitForPageFullyLoaded"/> + <conditionalClick selector="{{AdminDataGridHeaderSection.clearFilters}}" dependentSelector="{{AdminDataGridHeaderSection.clearFilters}}" visible="true" stepKey="clearExistingFilters"/> + <helper class="\Magento\MediaGalleryUi\Test\Mftf\Helper\MediaGalleryUiHelper" method="deleteAllImagesUsingMassAction" stepKey="deleteAllImagesUsingMassAction"> + <argument name="emptyRow">{{AdminMediaGalleryGridSection.noDataMessage}}</argument> + <argument name="deleteImagesButton">{{AdminEnhancedMediaGalleryMassActionSection.deleteImages}}</argument> + <argument name="checkImage">{{AdminEnhancedMediaGalleryMassActionSection.massActionCheckboxAll}}</argument> + <argument name="deleteSelectedButton">{{AdminEnhancedMediaGalleryMassActionSection.deleteSelected}}</argument> + <argument name="modalAcceptButton">{{AdminEnhancedMediaGalleryDeleteModalSection.confirmDelete}}</argument> + <argument name="successMessageContainer">{{AdminMediaGalleryMessagesSection.success}}</argument> + <argument name="successMessage">been successfully deleted</argument> + </helper> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminEnhancedMediaGalleryEnableMassActionModeActionGroup.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminEnhancedMediaGalleryEnableMassActionModeActionGroup.xml index 5e5c89637c6a1..15ef00c41a6e4 100644 --- a/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminEnhancedMediaGalleryEnableMassActionModeActionGroup.xml +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminEnhancedMediaGalleryEnableMassActionModeActionGroup.xml @@ -13,7 +13,10 @@ <description>Activate massaction mode by click on Delete Selected..</description> </annotations> + <wait stepKey="waitBefore" time="5" /> <waitForElementVisible selector="{{AdminEnhancedMediaGalleryMassActionSection.deleteImages}}" stepKey="waitForMassActionButton"/> <click selector="{{AdminEnhancedMediaGalleryMassActionSection.deleteImages}}" stepKey="clickOnMassActionButton"/> + <waitForPageLoad stepKey="waitForPageLoaded" /> + <wait stepKey="waitAfter" time="5" /> </actionGroup> </actionGroups> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminEnhancedMediaGalleryImageDetailsEditActionGroup.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminEnhancedMediaGalleryImageDetailsEditActionGroup.xml index 931da0ee06fef..5f7ab2d2d008f 100644 --- a/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminEnhancedMediaGalleryImageDetailsEditActionGroup.xml +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminEnhancedMediaGalleryImageDetailsEditActionGroup.xml @@ -13,6 +13,6 @@ <description>Edit image from the View Details panel</description> </annotations> <click selector="{{AdminEnhancedMediaGalleryViewDetailsSection.edit}}" stepKey="editImage"/> - <waitForLoadingMaskToDisappear stepKey="waitForLoadingMaskToDisappear"/> + <waitForElementVisible selector="{{AdminEnhancedMediaGalleryEditDetailsSection.modalTitle}}" stepKey="waitForLoadingMaskToDisappear"/> </actionGroup> </actionGroups> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminEnhancedMediaGalleryUploadImageActionGroup.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminEnhancedMediaGalleryUploadImageActionGroup.xml index 053a1185b3fda..9a9c09cda9ab2 100644 --- a/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminEnhancedMediaGalleryUploadImageActionGroup.xml +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminEnhancedMediaGalleryUploadImageActionGroup.xml @@ -17,6 +17,7 @@ <argument name="image"/> </arguments> + <waitForPageLoad stepKey="waitForPageFullyLoaded"/> <attachFile selector="{{AdminEnhancedMediaGalleryActionsSection.upload}}" userInput="{{image.value}}" stepKey="uploadImage"/> <waitForPageLoad stepKey="waitForPageLoad"/> <waitForLoadingMaskToDisappear stepKey="waitForLoadingMaskToDisappear"/> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminEnhancedMediaGalleryViewImageDetailsActionGroup.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminEnhancedMediaGalleryViewImageDetailsActionGroup.xml index b5c0bbac69bec..d216af75c7be1 100644 --- a/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminEnhancedMediaGalleryViewImageDetailsActionGroup.xml +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminEnhancedMediaGalleryViewImageDetailsActionGroup.xml @@ -15,6 +15,6 @@ <click selector="{{AdminEnhancedMediaGalleryImageActionsSection.openContextMenu}}" stepKey="openContextMenu"/> <click selector="{{AdminEnhancedMediaGalleryImageActionsSection.viewDetails}}" stepKey="viewDetails"/> - <waitForLoadingMaskToDisappear stepKey="waitForLoadingMaskToDisappear"/> + <waitForElementVisible selector="{{AdminEnhancedMediaGalleryViewDetailsSection.modalTitle}}" stepKey="waitForLoadingMaskToDisappear"/> </actionGroup> </actionGroups> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminMediaGalleryAssertFolderDoesNotExistActionGroup.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminMediaGalleryAssertFolderDoesNotExistActionGroup.xml index d0d9817da6d34..488ea03ebd86c 100644 --- a/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminMediaGalleryAssertFolderDoesNotExistActionGroup.xml +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminMediaGalleryAssertFolderDoesNotExistActionGroup.xml @@ -12,7 +12,7 @@ <arguments> <argument name="name" type="string" defaultValue="{{AdminMediaGalleryFolderData.name}}"/> </arguments> - <wait time="5" stepKey="waitForFolderTreeReloads"/> - <dontSeeElement selector="//div[contains(@class, 'media-directory-container')]//a[contains(text(), '{{name}}')]" stepKey="folderDoesNotExist"/> + <waitForPageLoad stepKey="waitForFolderTreeReloads"/> + <dontSeeElement selector="{{AdminMediaGalleryFolderSection.folderInTree(name)}}" stepKey="folderDoesNotExist"/> </actionGroup> </actionGroups> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminMediaGalleryAssertFolderNameActionGroup.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminMediaGalleryAssertFolderNameActionGroup.xml index 7d71c764bc8de..e42eb31dc00d8 100644 --- a/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminMediaGalleryAssertFolderNameActionGroup.xml +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminMediaGalleryAssertFolderNameActionGroup.xml @@ -12,6 +12,6 @@ <arguments> <argument name="name" type="string" defaultValue="{{AdminMediaGalleryFolderData.name}}"/> </arguments> - <waitForElementVisible selector="//div[contains(@class, 'media-directory-container')]//a[contains(text(), '{{name}}')]" stepKey="waitForFolder"/> + <waitForElementVisible selector="{{AdminMediaGalleryFolderSection.folderInTree(name)}}" stepKey="waitForFolder"/> </actionGroup> </actionGroups> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminMediaGalleryClickAddSelectedActionGroup.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminMediaGalleryClickAddSelectedActionGroup.xml index 28dcc1c553a5a..6cfc7b07831d0 100644 --- a/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminMediaGalleryClickAddSelectedActionGroup.xml +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminMediaGalleryClickAddSelectedActionGroup.xml @@ -11,6 +11,7 @@ <actionGroup name="AdminMediaGalleryClickAddSelectedActionGroup"> <waitForElementVisible selector="{{AdminMediaGalleryHeaderButtonsSection.addSelected}}" stepKey="waitForAddSelectedButton"/> <click selector="{{AdminMediaGalleryHeaderButtonsSection.addSelected}}" stepKey="ClickAddSelected"/> - <wait time="5" stepKey="waitForImageToBeAdded"/> + <waitForPageLoad stepKey="waitForImageToBeAdded"/> + <waitForLoadingMaskToDisappear stepKey="waitForLoadingMaskDisappear"/> </actionGroup> </actionGroups> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminMediaGalleryExpandFolderActionGroup.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminMediaGalleryExpandFolderActionGroup.xml new file mode 100644 index 0000000000000..f10aed54c8447 --- /dev/null +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminMediaGalleryExpandFolderActionGroup.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminMediaGalleryExpandFolderActionGroup"> + <arguments> + <argument name="fieldId" type="string"/> + </arguments> + <conditionalClick selector="{{AdminMediaGalleryFolderSection.folderArrow(fieldId)}}" + dependentSelector="{{AdminMediaGalleryFolderSection.checkIfFolderArrowExpand(fieldId)}}" stepKey="clickArrowIfClosed" visible="true"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminMediaGalleryFolderDeleteActionGroup.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminMediaGalleryFolderDeleteActionGroup.xml index f7e8f551e681f..398003c7f2b02 100644 --- a/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminMediaGalleryFolderDeleteActionGroup.xml +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminMediaGalleryFolderDeleteActionGroup.xml @@ -9,8 +9,8 @@ <actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> <actionGroup name="AdminMediaGalleryFolderDeleteActionGroup"> - <wait time="2" stepKey="waitBeforeDeleteButtonWillBeActive"/> - <click selector="{{AdminMediaGalleryFolderSection.folderDeleteButton}}" stepKey="clickDeleteButton"/> + <waitForElementVisible selector="{{AdminMediaGalleryFolderSection.folderDeleteButtonActive}}" stepKey="waitBeforeDeleteButtonWillBeActive"/> + <click selector="{{AdminMediaGalleryFolderSection.folderDeleteButtonActive}}" stepKey="clickDeleteButton"/> <waitForElementVisible selector="{{AdminMediaGalleryFolderSection.folderDeleteModalHeader}}" stepKey="waitBeforeModalAppears"/> <click selector="{{AdminMediaGalleryFolderSection.folderConfirmDeleteButton}}" stepKey="clickConfirmDeleteButton"/> </actionGroup> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminMediaGalleryFolderSelectActionGroup.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminMediaGalleryFolderSelectActionGroup.xml index b8ed1d4f1cd25..5751b8ec323da 100644 --- a/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminMediaGalleryFolderSelectActionGroup.xml +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminMediaGalleryFolderSelectActionGroup.xml @@ -9,11 +9,15 @@ <actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> <actionGroup name="AdminMediaGalleryFolderSelectActionGroup"> + <annotations> + <description>Wait for folder name appeared in tree and then click it.</description> + </annotations> <arguments> <argument name="name" type="string" defaultValue="{{AdminMediaGalleryFolderData.name}}"/> </arguments> - <wait time="2" stepKey="waitBeforeClickOnFolder"/> - <click selector="//div[contains(@class, 'media-directory-container')]//a[contains(text(), '{{name}}')]" stepKey="selectFolder"/> + + <waitForElementVisible selector="{{AdminMediaGalleryFolderSection.folderInTree(name)}}" stepKey="waitBeforeClickOnFolder"/> + <click selector="{{AdminMediaGalleryFolderSection.folderInTree(name)}}" stepKey="selectFolder"/> <waitForLoadingMaskToDisappear stepKey="waitForFolderContents"/> </actionGroup> </actionGroups> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminMediaGalleryRenditionsEnableActionGroup.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminMediaGalleryRenditionsEnableActionGroup.xml new file mode 100644 index 0000000000000..b64247a708242 --- /dev/null +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminMediaGalleryRenditionsEnableActionGroup.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminMediaGalleryRenditionsEnableActionGroup"> + <arguments> + <argument name="enabled" type="string" defaultValue="{{MediaGalleryRenditionsDataEnabled.value}}"/> + </arguments> + <amOnPage url="{{AdminMediaGalleryConfigSystemPage.url}}" stepKey="navigateToSystemConfigurationPage" /> + <waitForPageLoad stepKey="waitForPageLoad"/> + <scrollTo selector="{{AdminConfigSystemSection.mediaGalleryRenditionsFieldset}}" stepKey="scrollToMediaGalleryRenditionsFieldset"/> + <conditionalClick stepKey="expandMediaGalleryRenditionsTab" selector="{{AdminConfigSystemSection.mediaGalleryRenditionsFieldset}}" dependentSelector="{{AdminConfigSystemSection.mediaGalleryRenditionsEnabledField}}" visible="false" /> + <waitForElementVisible selector="{{AdminConfigSystemSection.mediaGalleryRenditionsFieldset}}" stepKey="waitForFieldset" /> + <selectOption userInput="{{enabled}}" selector="{{AdminConfigSystemSection.mediaGalleryRenditionsEnabledField}}" stepKey="enableOrDisableMediaGalleryRenditions"/> + <click selector="{{AdminConfigSystemSection.saveConfig}}" stepKey="saveConfiguration"/> + <waitForPageLoad stepKey="waitForConfigurationToSave"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminOpenMediaGalleryFromPageNoEditorActionGroup.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminOpenMediaGalleryFromPageNoEditorActionGroup.xml index 0b2540de5288e..287b6219115f2 100644 --- a/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminOpenMediaGalleryFromPageNoEditorActionGroup.xml +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminOpenMediaGalleryFromPageNoEditorActionGroup.xml @@ -9,8 +9,9 @@ <actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> <actionGroup name="AdminOpenMediaGalleryFromPageNoEditorActionGroup"> - <click selector="{{CmsNewPagePageContentSection.header}}" stepKey="clickExpandContent"/> + <conditionalClick selector="{{CmsNewPagePageContentSection.header}}" dependentSelector="{{CmsNewPagePageContentSection.contentHeading}}" visible="false" stepKey="clickExpandContent"/> <waitForElementVisible selector="{{CmsWYSIWYGSection.InsertImageBtn}}" stepKey="waitForInsertImageButton" /> + <scrollTo selector="{{CmsWYSIWYGSection.InsertImageBtn}}" x="0" y="-80" stepKey="scrollToInsertImageButton"/> <click selector="{{CmsWYSIWYGSection.InsertImageBtn}}" stepKey="clickInsertImage" /> <!-- wait for initial media gallery load, where the gallery chrome loads (and triggers loading modal) --> <waitForPageLoad stepKey="waitForMediaGalleryInitialLoad"/> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminOpenMediaGalleryFromTinyMce4IconActionGroup.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminOpenMediaGalleryFromTinyMce4IconActionGroup.xml index 3143b4ff24fb4..e4fb6aec5c152 100644 --- a/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminOpenMediaGalleryFromTinyMce4IconActionGroup.xml +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/ActionGroup/AdminOpenMediaGalleryFromTinyMce4IconActionGroup.xml @@ -11,8 +11,8 @@ <annotations> <description>Opens Enhanced MediaGallery from category page by tyniMce4 image icon</description> </annotations> - - <click selector="{{AdminCategoryContentSection.sectionHeader}}" stepKey="clickExpandContent"/> + + <conditionalClick selector="{{AdminCategoryContentSection.sectionHeader}}" dependentSelector="{{AdminCategoryContentSection.uploadButton}}" visible="false" stepKey="clickExpandContent"/> <waitForElementVisible selector="{{TinyMCESection.TinyMCE4}}" stepKey="waitForTinyMCE4" /> <click selector="{{TinyMCESection.InsertImageIcon}}" stepKey="clickInsertImageIcon" /> <waitForPageLoad stepKey="waitForPageLoad" /> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/Data/AdminEnhancedMediaGalleryImageData.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/Data/AdminEnhancedMediaGalleryImageData.xml index 4adf92b1c4c09..7948ed13be088 100644 --- a/app/code/Magento/MediaGalleryUi/Test/Mftf/Data/AdminEnhancedMediaGalleryImageData.xml +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/Data/AdminEnhancedMediaGalleryImageData.xml @@ -40,4 +40,8 @@ <data key="extension">jpg</data> <data key="keywords">magento, mediagallerymetadata</data> </entity> + <entity name="ImageUploadPngTwo" type="image"> + <data key="file">magento-logo_2.png</data> + <data key="extension">png</data> + </entity> </entities> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/Data/AdobeStockConfigData.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/Data/AdobeStockConfigData.xml index e8f394a006104..9ac743f2f9983 100644 --- a/app/code/Magento/MediaGalleryUi/Test/Mftf/Data/AdobeStockConfigData.xml +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/Data/AdobeStockConfigData.xml @@ -15,4 +15,12 @@ <data key="path">system/media_gallery/enabled</data> <data key="value">0</data> </entity> + <entity name="MediaGalleryRenditionsDataEnabled"> + <data key="path">system/media_gallery_renditions/enabled</data> + <data key="value">1</data> + </entity> + <entity name="MediaGalleryRenditionsDataDisabled"> + <data key="path">system/media_gallery_renditions/enabled</data> + <data key="value">0</data> + </entity> </entities> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/Helper/MediaGalleryUiHelper.php b/app/code/Magento/MediaGalleryUi/Test/Mftf/Helper/MediaGalleryUiHelper.php new file mode 100644 index 0000000000000..4059a8460bb51 --- /dev/null +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/Helper/MediaGalleryUiHelper.php @@ -0,0 +1,77 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\MediaGalleryUi\Test\Mftf\Helper; + +use Facebook\WebDriver\Remote\RemoteWebDriver as FacebookWebDriver; +use Facebook\WebDriver\Remote\RemoteWebElement; +use Facebook\WebDriver\WebDriverBy; +use Magento\FunctionalTestingFramework\Helper\Helper; +use Magento\FunctionalTestingFramework\Module\MagentoWebDriver; + +/** + * Class for MFTF helpers for MediaGalleryUi module. + */ +class MediaGalleryUiHelper extends Helper +{ + /** + * Delete all images using mass action. + * + * @param string $emptyRow + * @param string $deleteImagesButton + * @param string $checkImage + * @param string $deleteSelectedButton + * @param string $modalAcceptButton + * @param string $successMessageContainer + * @param string $successMessage + * + * @return void + */ + public function deleteAllImagesUsingMassAction( + string $emptyRow, + string $deleteImagesButton, + string $checkImage, + string $deleteSelectedButton, + string $modalAcceptButton, + string $successMessageContainer, + string $successMessage + ): void { + try { + /** @var MagentoWebDriver $webDriver */ + $magentoWebDriver = $this->getModule('\Magento\FunctionalTestingFramework\Module\MagentoWebDriver'); + /** @var FacebookWebDriver $webDriver */ + $webDriver = $magentoWebDriver->webDriver; + $rows = $webDriver->findElements(WebDriverBy::cssSelector($emptyRow)); + while (empty($rows)) { + $magentoWebDriver->click($deleteImagesButton); + $magentoWebDriver->waitForPageLoad(30); + $magentoWebDriver->waitForElementVisible($deleteSelectedButton, 10); + + // Check all images + /** @var RemoteWebElement[] $images */ + $imagesCheckboxes = $webDriver->findElements(WebDriverBy::cssSelector($checkImage)); + /** @var RemoteWebElement $image */ + foreach ($imagesCheckboxes as $imageCheckbox) { + $imageCheckbox->click(); + } + + $magentoWebDriver->waitForPageLoad(30); + $magentoWebDriver->click($deleteSelectedButton); + $magentoWebDriver->waitForPageLoad(30); + $magentoWebDriver->waitForElementVisible($modalAcceptButton, 10); + $magentoWebDriver->click($modalAcceptButton); + $magentoWebDriver->waitForPageLoad(60); + $magentoWebDriver->waitForElementVisible($successMessageContainer, 10); + $magentoWebDriver->see($successMessage, $successMessageContainer); + + $rows = $webDriver->findElements(WebDriverBy::cssSelector($emptyRow)); + } + } catch (\Exception $e) { + $this->fail($e->getMessage()); + } + } +} diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/Section/AdminConfigSystemSection.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/Section/AdminConfigSystemSection.xml index b7900f6664c62..f3ab74470e4e4 100644 --- a/app/code/Magento/MediaGalleryUi/Test/Mftf/Section/AdminConfigSystemSection.xml +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/Section/AdminConfigSystemSection.xml @@ -11,6 +11,8 @@ <section name="AdminConfigSystemSection"> <element name="enhancedMediaGalleryFieldset" type="block" selector="#system_media_gallery-head"/> <element name="enhancedMediaGalleryEnabledField" type="select" selector="[data-ui-id='select-groups-media-gallery-fields-enabled-value']"/> + <element name="mediaGalleryRenditionsFieldset" type="block" selector="#system_media_gallery_renditions-head"/> + <element name="mediaGalleryRenditionsEnabledField" type="select" selector="[data-ui-id='select-groups-media-gallery-renditions-fields-enabled-value']"/> <element name="saveConfig" type="button" selector="#save"/> </section> </sections> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/Section/AdminEnhancedMediaGalleryEditDetailsSection.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/Section/AdminEnhancedMediaGalleryEditDetailsSection.xml index b0bed4563003e..351367055e62b 100644 --- a/app/code/Magento/MediaGalleryUi/Test/Mftf/Section/AdminEnhancedMediaGalleryEditDetailsSection.xml +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/Section/AdminEnhancedMediaGalleryEditDetailsSection.xml @@ -16,6 +16,7 @@ <element name="addNewKeyword" type="input" selector="[data-ui-id='add-keyword']"/> <element name="removeSelectedKeyword" type="button" selector="//span[contains(text(), '{{keyword}}')]/following-sibling::button[@data-action='remove-selected-item']" parameterized="true"/> <element name="cancel" type="button" selector="#image-details-action-cancel"/> - <element name="save" type="button" selector="#image-details-action-save"/> + <element name="save" type="button" selector="#image-details-action-save" timeout="30"/> + <element name="modalTitle" type="text" selector="//aside[contains(@class, 'media-gallery-edit-image-details') and contains(@class, '_show')]//h1[contains(., 'Edit Image')]"/> </section> </sections> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/Section/AdminEnhancedMediaGalleryImageActionsSection.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/Section/AdminEnhancedMediaGalleryImageActionsSection.xml index f36fca88dc760..17c3e82144d6f 100644 --- a/app/code/Magento/MediaGalleryUi/Test/Mftf/Section/AdminEnhancedMediaGalleryImageActionsSection.xml +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/Section/AdminEnhancedMediaGalleryImageActionsSection.xml @@ -10,9 +10,9 @@ <section name="AdminEnhancedMediaGalleryImageActionsSection"> <element name="openContextMenu" type="button" selector=".three-dots"/> <element name="contextMenuItem" type="block" selector="//div[@class='media-gallery-image']//ul[@class='action-menu _active']//li//a[@class='action-menu-item']"/> - <element name="viewDetails" type="button" selector="[data-ui-id='action-image-details']"/> - <element name="delete" type="button" selector="[data-ui-id='action-delete']"/> - <element name="edit" type="button" selector="[data-ui-id='action-edit']"/> + <element name="viewDetails" type="button" selector="//ul[@class='action-menu _active']//a[text()='View Details']" timeout="30"/> + <element name="delete" type="button" selector="//ul[@class='action-menu _active']//a[text()='Delete']"/> + <element name="edit" type="button" selector="//ul[@class='action-menu _active']//a[text()='Edit']"/> <element name="imageInGrid" type="button" selector="//li[@data-ui-id='title'and text()='{{imageTitle}}']/parent::*/parent::*/parent::div//img[@class='media-gallery-image-column']" parameterized="true"/> </section> </sections> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/Section/AdminEnhancedMediaGalleryMassActionSection.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/Section/AdminEnhancedMediaGalleryMassActionSection.xml index 07f2dc23530e1..9018ccb4ddd69 100644 --- a/app/code/Magento/MediaGalleryUi/Test/Mftf/Section/AdminEnhancedMediaGalleryMassActionSection.xml +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/Section/AdminEnhancedMediaGalleryMassActionSection.xml @@ -13,5 +13,6 @@ <element name="cancelMassActionMode" type="button" selector="#cancel_massaction"/> <element name="deleteImages" type="button" selector="#delete_massaction"/> <element name="deleteSelected" type="button" selector="#delete_selected_massaction"/> + <element name="massActionCheckboxAll" type="checkbox" selector="[data-id='media-gallery-masonry-grid'] .mediagallery-massaction-checkbox input[type='checkbox']"/> </section> </sections> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/Section/AdminEnhancedMediaGalleryViewDetailsSection.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/Section/AdminEnhancedMediaGalleryViewDetailsSection.xml index d6abe464048c7..77b14c67cd944 100644 --- a/app/code/Magento/MediaGalleryUi/Test/Mftf/Section/AdminEnhancedMediaGalleryViewDetailsSection.xml +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/Section/AdminEnhancedMediaGalleryViewDetailsSection.xml @@ -8,8 +8,9 @@ <sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> <section name="AdminEnhancedMediaGalleryViewDetailsSection"> + <element name="modalTitle" type="text" selector="//aside[contains(@class, 'media-gallery-image-details') and contains(@class, '_show')]//header[contains(@class, 'modal-header')]//h1[contains(@class, 'modal-title') and contains(., 'Image Details')]"/> <element name="title" type="text" selector=".image-title"/> - <element name="contentType" type="text" selector="[data-ui-id='content-type']"/> + <element name="contentType" type="text" selector="span[data-ui-id='content-type']"/> <element name="type" type="text" selector="//div[@class='attribute']/span[contains(text(), 'Type')]/following-sibling::div"/> <element name="height" type="text" selector="//div[@class='attribute']/span[contains(text(), 'Height')]/following-sibling::div"/> <element name="description" type="text" selector=".image-details-section.description p"/> @@ -19,10 +20,10 @@ <element name="delete" type="button" selector="//div[@class='media-gallery-image-details-modal']//button[contains(@class, 'delete')]"/> <element name="confirmDelete" type="button" selector=".action-accept"/> <element name="createdAtDate" type="button" selector="//div[@class='attribute']/span[contains(text(), 'Created')]/following-sibling::div"/> - <element name="usedIn" type="button" selector="//div[@class='attribute']/span[contains(text(), 'Used In')]"/> <element name="updatedAtDate" type="button" selector="//div[@class='attribute']/span[contains(text(), 'Modified')]/following-sibling::div"/> + <element name="usedIn" type="button" selector="//div[@class='attribute']/span[contains(text(), 'Used In')]"/> <element name="addImage" type="button" selector=".add-image-action"/> - <element name="cancel" type="button" selector="#image-details-action-cancel"/> + <element name="cancel" type="button" selector="#image-details-action-cancel" timeout="10"/> <element name="usedInLink" type="button" parameterized="true" selector="//div[@class='attribute']/span[contains(text(), 'Used In')]/following-sibling::div/a[contains(text(), '{{entityName}}')]"/> </section> </sections> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/Section/AdminMediaGalleryFolderSection.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/Section/AdminMediaGalleryFolderSection.xml index 4c9e6bf362194..be12829229663 100644 --- a/app/code/Magento/MediaGalleryUi/Test/Mftf/Section/AdminMediaGalleryFolderSection.xml +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/Section/AdminMediaGalleryFolderSection.xml @@ -12,11 +12,15 @@ <element name="folderNewModalHeader" type="block" selector="//h1[contains(text(), 'New Folder Name')]"/> <element name="folderDeleteModalHeader" type="block" selector="//h1[contains(text(), 'Are you sure you want to delete this folder?')]"/> <element name="folderNewCreateButton" type="button" selector="#create_folder"/> - <element name="folderDeleteButton" type="button" selector="#delete_folder"/> - <element name="folderConfirmDeleteButton" type="button" selector="//footer//button/span[contains(text(), 'OK')]"/> + <element name="folderDeleteButton" type="button" selector="#delete_folder" timeout="30"/> + <element name="folderDeleteButtonActive" type="button" selector="#delete_folder:not(.disabled)" timeout="30"/> + <element name="folderConfirmDeleteButton" type="button" selector="//footer//button/span[contains(text(), 'OK')]" timeout="30"/> <element name="folderCancelDeleteButton" type="button" selector="//footer//button/span[contains(text(), 'Cancel')]"/> <element name="folderNameField" type="button" selector="[name=folder_name]"/> - <element name="folderConfirmCreateButton" type="button" selector="//button/span[contains(text(),'Confirm')]"/> + <element name="folderConfirmCreateButton" type="button" selector="//button/span[contains(text(),'Confirm')]" timeout="30"/> <element name="folderNameValidationMessage" type="block" selector="label.mage-error"/> + <element name="folderArrow" type="button" selector="#{{id}} > .jstree-icon" parameterized="true"/> + <element name="checkIfFolderArrowExpand" type="button" selector="//li[@id='{{id}}' and contains(@class,'jstree-closed')]" parameterized="true"/> + <element name="folderInTree" type="text" selector="//div[contains(@class, 'media-directory-container')]//ul//li//a[normalize-space(text())='{{name}}']" parameterized="true"/> </section> </sections> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/Section/AdminMediaGalleryGridSection.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/Section/AdminMediaGalleryGridSection.xml index f35a32b6d3a37..08be2e61a9d14 100644 --- a/app/code/Magento/MediaGalleryUi/Test/Mftf/Section/AdminMediaGalleryGridSection.xml +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/Section/AdminMediaGalleryGridSection.xml @@ -8,7 +8,7 @@ <sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> <section name="AdminMediaGalleryGridSection"> - <element name="noDataMessage" type="text" selector="div.no-data-message-container"/> + <element name="noDataMessage" type="text" selector="[data-id='media-gallery-masonry-grid'] .no-data-message-container"/> <element name="nthImageInGrid" type="text" selector="div[class='masonry-image-column'][data-repeat-index='{{row}}'] img" parameterized="true"/> </section> </sections> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/Section/AdminMediaGalleryMessagesSection.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/Section/AdminMediaGalleryMessagesSection.xml new file mode 100644 index 0000000000000..659fb51080225 --- /dev/null +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/Section/AdminMediaGalleryMessagesSection.xml @@ -0,0 +1,11 @@ +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<sections xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> + <section name="AdminMediaGalleryMessagesSection"> + <element name="success" type="text" selector=".media-gallery-container ul.messages div.message.message-success span"/> + </section> +</sections> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/Suite/MediaGalleryUiDisabledSuite.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/Suite/MediaGalleryUiDisabledSuite.xml index 727fbde3f17b6..d5f2abe965575 100644 --- a/app/code/Magento/MediaGalleryUi/Test/Mftf/Suite/MediaGalleryUiDisabledSuite.xml +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/Suite/MediaGalleryUiDisabledSuite.xml @@ -9,6 +9,17 @@ <suites xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Suite/etc/suiteSchema.xsd"> <suite name="MediaGalleryUiDisabledSuite"> + <before> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + <actionGroup ref="AdminMediaGalleryRenditionsEnableActionGroup" stepKey="disableMediaGalleryRenditions"> + <argument name="enabled" value="0"/> + </actionGroup> + <actionGroup ref="AdminLogoutActionGroup" stepKey="adminLogout"/> + </before> + <after> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + <actionGroup ref="AdminMediaGalleryRenditionsEnableActionGroup" stepKey="enableMediaGalleryRenditions"/> + </after> <include> <group name="media_gallery_ui_disabled"/> </include> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/Suite/MediaGalleryUiSuite.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/Suite/MediaGalleryUiSuite.xml index 4749fc4a885b0..e81dc807d0f48 100644 --- a/app/code/Magento/MediaGalleryUi/Test/Mftf/Suite/MediaGalleryUiSuite.xml +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/Suite/MediaGalleryUiSuite.xml @@ -15,6 +15,7 @@ <actionGroup ref="AdminMediaGalleryEnhancedEnableActionGroup" stepKey="enableEnhancedMediaGallery"> <argument name="enabled" value="1"/> </actionGroup> + <actionGroup ref="AdminMediaGalleryRenditionsEnableActionGroup" stepKey="enableMediaGalleryRenditions"/> <actionGroup ref="AdminLogoutActionGroup" stepKey="adminLogout"/> </before> <after> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminEnhancedMediaGalleryDuplicatedImagesTest.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminEnhancedMediaGalleryDuplicatedImagesTest.xml index 52f3a8079e962..e87354de41761 100644 --- a/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminEnhancedMediaGalleryDuplicatedImagesTest.xml +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminEnhancedMediaGalleryDuplicatedImagesTest.xml @@ -51,5 +51,6 @@ <actionGroup ref="AdminMediaGalleryAssertImageInGridActionGroup" stepKey="assertSecondImageInGrid"> <argument name="title" value="ImageUpload_1.filename"/> </actionGroup> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearGridFilter"/> </test> </tests> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminEnhancedMediaGalleryVerifyAssetFilterTest.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminEnhancedMediaGalleryVerifyAssetFilterTest.xml index 9a08f7cd0bb9c..847cc398d489d 100644 --- a/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminEnhancedMediaGalleryVerifyAssetFilterTest.xml +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminEnhancedMediaGalleryVerifyAssetFilterTest.xml @@ -7,16 +7,19 @@ --> <tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> - <test name="AdminEnhancedMediaGalleryVerifyAssetFilterTest"> + <test name="AdminEnhancedMediaGalleryVerifyAssetFilterTest" deprecated="Use AdminEnhancedMediaGalleryVerifyFilterByAssetTest instead"> <annotations> <features value="MediaGallery"/> <useCaseId value="https://github.com/magento/adobe-stock-integration/issues/1292"/> - <title value="User sees entities where asset is used in"/> + <title value="DEPRECATED. User sees entities where asset is used in"/> <stories value="Story 58: User sees entities where asset is used in"/> <testCaseId value="https://studio.cucumber.io/projects/131313/test-plan/folders/1320712/scenarios/4951024"/> <description value="User sees entities where asset is used in"/> <severity value="CRITICAL"/> <group value="media_gallery_ui"/> + <skip> + <issueId value="DEPRECATED">Use AdminEnhancedMediaGalleryVerifyFilterByAssetTest instead</issueId> + </skip> </annotations> <before> <createData entity="SimpleSubCategory" stepKey="category"/> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminEnhancedMediaGalleryVerifyFilterByAssetTest.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminEnhancedMediaGalleryVerifyFilterByAssetTest.xml new file mode 100644 index 0000000000000..a3208af0da238 --- /dev/null +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminEnhancedMediaGalleryVerifyFilterByAssetTest.xml @@ -0,0 +1,75 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminEnhancedMediaGalleryVerifyFilterByAssetTest"> + <annotations> + <features value="MediaGalleryUi"/> + <stories value="Story 58: User sees entities where asset is used in"/> + <title value="User sees entities where asset is used in"/> + <description value="User sees entities where asset is used in"/> + <severity value="CRITICAL"/> + <testCaseId value="https://studio.cucumber.io/projects/131313/test-plan/folders/1320712/scenarios/4951024"/> + <useCaseId value="https://github.com/magento/adobe-stock-integration/issues/1292"/> + <group value="media_gallery_ui"/> + </annotations> + + <before> + <createData entity="SimpleSubCategory" stepKey="category"/> + <actionGroup ref="EnabledWYSIWYGActionGroup" stepKey="enableWYSIWYG"/> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + <actionGroup ref="AdminEnhancedMediaGalleryDeletedAllImagesActionGroup" stepKey="deleteAllImages"/> + </before> + + <after> + <deleteData createDataKey="category" stepKey="deleteCategory"/> + <actionGroup ref="DisabledWYSIWYGActionGroup" stepKey="disableWYSIWYG"/> + <actionGroup ref="AdminEnhancedMediaGalleryDeletedAllImagesActionGroup" stepKey="deleteAllImages"/> + + <actionGroup ref="AdminOpenCategoryGridPageActionGroup" stepKey="openMediaGalleryCategoryGridPage"/> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearGridFilters"/> + + <actionGroup ref="AdminLogoutActionGroup" stepKey="logoutFromAdmin"/> + </after> + + <actionGroup ref="GoToAdminCategoryPageByIdActionGroup" stepKey="openCategoryPage"> + <argument name="id" value="$category.id$"/> + </actionGroup> + <actionGroup ref="AdminOpenMediaGalleryTinyMce4ActionGroup" stepKey="openMediaGalleryFromWysiwyg"/> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearGridFilters"/> + <actionGroup ref="AdminEnhancedMediaGalleryUploadImageActionGroup" stepKey="uploadFirstIMage"> + <argument name="image" value="ImageUpload3"/> + </actionGroup> + <actionGroup ref="AdminEnhancedMediaGalleryUploadImageActionGroup" stepKey="uploadSecondImage"> + <argument name="image" value="ImageUpload_1"/> + </actionGroup> + <actionGroup ref="AdminMediaGalleryClickImageInGridActionGroup" stepKey="selectCategoryImageInGrid"> + <argument name="imageName" value="{{ImageMetadata.title}}"/> + </actionGroup> + <actionGroup ref="AdminMediaGalleryClickAddSelectedActionGroup" stepKey="clickAddSelectedContentImage"/> + <actionGroup ref="AdminMediaGalleryClickOkButtonTinyMce4ActionGroup" stepKey="clickOkButton"/> + <actionGroup ref="AdminSaveCategoryFormActionGroup" stepKey="saveCategory"/> + + <actionGroup ref="AdminOpenCategoryGridPageActionGroup" stepKey="openMediaGalleryCategoryGridPage"/> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearGridFiltersAgain"/> + <actionGroup ref="AdminEnhancedMediaGalleryCategoryGridExpandFilterActionGroup" stepKey="expandFilters"/> + <actionGroup ref="AdminEnhancedMediaGallerySelectUsedInFilterActionGroup" stepKey="setUsedInFilter"> + <argument name="filterName" value="Asset"/> + <argument name="optionName" value="{{ImageMetadata.title}}"/> + </actionGroup> + <actionGroup ref="AdminEnhancedMediaGalleryCategoryGridApplyFiltersActionGroup" stepKey="applyFilters"/> + <actionGroup ref="AssertAdminCategoryGridPageNumberOfRecordsActionGroup" stepKey="assertOneRecordInGrid"> + <argument name="numberOfRecords" value="1 records found"/> + </actionGroup> + <actionGroup ref="AssertAdminCategoryGridPageImageColumnActionGroup" stepKey="assertCategoryGridPageImageColumn"/> + <actionGroup ref="AssertAdminCategoryGridPageDetailsActionGroup" stepKey="assertCategoryInGrid"> + <argument name="category" value="$category$"/> + </actionGroup> + <actionGroup ref="AssertAdminCategoryGridPageProductsInMenuEnabledColumnsActionGroup" stepKey="assertCategoryGridPageProductsInMenuEnabledColumns"/> + </test> +</tests> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminMediaGalleryAddCategoryImageTest.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminMediaGalleryAddCategoryImageTest.xml index 30f1412a5b08d..d3925fcb265e1 100644 --- a/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminMediaGalleryAddCategoryImageTest.xml +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminMediaGalleryAddCategoryImageTest.xml @@ -39,6 +39,14 @@ <argument name="category" value="$$category$$"/> </actionGroup> <actionGroup ref="AdminOpenMediaGalleryTinyMce4ActionGroup" stepKey="openMediaGalleryFromWysiwyg"/> + <waitForPageLoad stepKey="waitForPageLoad"/> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearFilter"/> + <actionGroup ref="AdminMediaGalleryExpandFolderActionGroup" stepKey="expandCatalogFolder"> + <argument name="fieldId" value="catalog"/> + </actionGroup> + <actionGroup ref="AdminMediaGalleryFolderSelectActionGroup" stepKey="selectCategoryFolder"> + <argument name="name" value="category"/> + </actionGroup> <actionGroup ref="AdminEnhancedMediaGalleryUploadImageActionGroup" stepKey="uploadImage"> <argument name="image" value="ImageUpload3"/> </actionGroup> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminMediaGalleryDeleteImageWithWarningPopupTest.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminMediaGalleryDeleteImageWithWarningPopupTest.xml index 6ae8ed7047434..84dd84a22d3c3 100644 --- a/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminMediaGalleryDeleteImageWithWarningPopupTest.xml +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminMediaGalleryDeleteImageWithWarningPopupTest.xml @@ -33,6 +33,8 @@ <argument name="category" value="$$category$$"/> </actionGroup> <actionGroup ref="AdminOpenMediaGalleryTinyMce4ActionGroup" stepKey="openMediaGalleryFromWysiwyg"/> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearFilter"/> + <actionGroup ref="ResetAdminDataGridToDefaultViewActionGroup" stepKey="resetGridToDefaultView"/> <actionGroup ref="AdminEnhancedMediaGalleryUploadImageActionGroup" stepKey="uploadCategoryImage"> <argument name="image" value="ImageUpload3"/> </actionGroup> @@ -43,11 +45,13 @@ <actionGroup ref="AdminMediaGalleryClickOkButtonTinyMce4ActionGroup" stepKey="clickOkButton"/> <actionGroup ref="AdminSaveCategoryActionGroup" stepKey="saveCategory"/> <actionGroup ref="AdminOpenMediaGalleryTinyMce4ActionGroup" stepKey="openMediaGalleryFromWysiwygToAssertMessage"/> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearFilterAgain"/> + <actionGroup ref="ResetAdminDataGridToDefaultViewActionGroup" stepKey="resetGridToDefaultViewAgain"/> <actionGroup ref="AdminEnhancedMediaGalleryEnableMassActionModeActionGroup" stepKey="enableMassActionToDeleteImages"/> <actionGroup ref="AdminEnhancedMediaGallerySelectImageForMassActionActionGroup" stepKey="selectFirstImageToDelete"> <argument name="imageName" value="{{ImageMetadata.title}}"/> </actionGroup> - <actionGroup ref="AdminEnhancedMediaGalleryClickDeleteImagesButtonActionGroup" stepKey="clikDeleteSelectedButton"/> + <actionGroup ref="AdminEnhancedMediaGalleryClickDeleteImagesButtonActionGroup" stepKey="clickDeleteSelectedButton"/> <actionGroup ref="AdminEnhancedMediaGalleryAssertWarningMessageActionGroup" stepKey="assertMessageImageUsedIn"> <argument name="messageText" value="The selected assets are used in the content of the following entities: Categories(1)"/> </actionGroup> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminMediaGalleryDisabledContentFilterTest.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminMediaGalleryDisabledContentFilterTest.xml index 5926b115afccf..6b174e8c43c97 100644 --- a/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminMediaGalleryDisabledContentFilterTest.xml +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminMediaGalleryDisabledContentFilterTest.xml @@ -9,9 +9,6 @@ <tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> <test name="AdminMediaGalleryDisabledContentFilterTest"> <annotations> - <skip> - <issueId value="https://github.com/magento/adobe-stock-integration/issues/1825"/> - </skip> <features value="MediaGallery"/> <useCaseId value="https://github.com/magento/adobe-stock-integration/issues/1464"/> <title value="User filter asset by disabled content"/> @@ -36,6 +33,16 @@ <argument name="category" value="$$category$$"/> </actionGroup> <actionGroup ref="AdminOpenMediaGalleryTinyMce4ActionGroup" stepKey="openMediaGalleryFromWysiwyg"/> + <waitForPageLoad stepKey="waitForPageLoad" /> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearFilter"/> + <actionGroup ref="AdminMediaGalleryOpenNewFolderFormActionGroup" stepKey="openNewFolderForm"/> + <actionGroup ref="AdminMediaGalleryCreateNewFolderActionGroup" stepKey="createNewFolder"> + <argument name="name" value="testImage"/> + </actionGroup> + <actionGroup ref="AdminMediaGalleryAssertFolderNameActionGroup" stepKey="assertNewFolderCreated"> + <argument name="name" value="testImage"/> + </actionGroup> + <waitForPageLoad stepKey="waitForGridToLoadAfterNewFolderCreated"/> <actionGroup ref="AdminEnhancedMediaGalleryUploadImageActionGroup" stepKey="uploadImage"> <argument name="image" value="ImageUpload3"/> </actionGroup> @@ -57,12 +64,14 @@ <actionGroup ref="AdminMediaGalleryAssertImageInGridActionGroup" stepKey="assertImageInGrid"> <argument name="title" value="ImageMetadata.title"/> </actionGroup> - <actionGroup ref="AdminEnhancedMediaGalleryEnableMassActionModeActionGroup" stepKey="enableMassActionToDeleteImages"/> - <actionGroup ref="AdminEnhancedMediaGallerySelectImageForMassActionActionGroup" stepKey="selectFirstImageToDelete"> - <argument name="imageName" value="{{ImageMetadata.title}}"/> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearFilterInMediaGalleryGrid"/> + <actionGroup ref="AdminMediaGalleryFolderSelectActionGroup" stepKey="selectFolderToDelete"> + <argument name="name" value="testImage"/> </actionGroup> - <actionGroup ref="AdminEnhancedMediaGalleryClickDeleteImagesButtonActionGroup" stepKey="clickDeleteSelectedButton"/> - <actionGroup ref="AdminEnhancedMediaGalleryConfirmDeleteImagesActionGroup" stepKey="deleteImages"/> - + <actionGroup ref="AdminMediaGalleryFolderDeleteActionGroup" stepKey="deleteFolder"/> + <actionGroup ref="AdminMediaGalleryAssertFolderDoesNotExistActionGroup" stepKey="assertFolderWasDeleted"> + <argument name="name" value="testImage"/> + </actionGroup> + <waitForLoadingMaskToDisappear stepKey="waitForLoadingMaskToDisappear"/> </test> </tests> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminMediaGalleryEditImageDetailsFromGridTest.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminMediaGalleryEditImageDetailsFromGridTest.xml new file mode 100644 index 0000000000000..91a17a7c1167c --- /dev/null +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminMediaGalleryEditImageDetailsFromGridTest.xml @@ -0,0 +1,52 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminMediaGalleryEditImageDetailsFromGridTest"> + <annotations> + <features value="MediaGalleryUi"/> + <stories value="[Story # 38] User views basic image attributes in Media Gallery"/> + <title value="User edits image meta data in media gallery"/> + <description value="User edits image meta data in Standalone Media Gallery"/> + <testCaseId value="https://studio.cucumber.io/projects/131313/test-plan/folders/1320712/scenarios/3961351"/> + <useCaseId value="https://github.com/magento/adobe-stock-integration/issues/724"/> + <severity value="CRITICAL"/> + <group value="media_gallery_ui"/> + </annotations> + + <before> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + <actionGroup ref="AdminEnhancedMediaGalleryDeletedAllImagesActionGroup" stepKey="deleteAllMediaGalleryImages"/> + </before> + + <after> + <actionGroup ref="AdminEnhancedMediaGalleryDeletedAllImagesActionGroup" stepKey="deleteAllMediaGalleryImages"/> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logoutFromAdmin"/> + </after> + + <actionGroup ref="AdminOpenStandaloneMediaGalleryActionGroup" stepKey="openStandaloneMediaGallery"/> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="resetMediaGalleryGridFilters"/> + <actionGroup ref="AdminEnhancedMediaGalleryUploadImageActionGroup" stepKey="uploadImage"> + <argument name="image" value="ImageUpload"/> + </actionGroup> + <actionGroup ref="AdminEnhancedMediaGalleryEditImageDetailsActionGroup" stepKey="editImageDetails"/> + <actionGroup ref="AdminEnhancedMediaGalleryImageDetailsSaveActionGroup" stepKey="saveImage"> + <argument name="image" value="UpdatedImageDetails"/> + </actionGroup> + <actionGroup ref="AssertImageAttributesOnEnhancedMediaGalleryActionGroup" stepKey="verifyUpdateImageOnTheGrid"> + <argument name="image" value="UpdatedImageDetails"/> + </actionGroup> + <actionGroup ref="AdminEnhancedMediaGalleryViewImageDetails" stepKey="viewImageDetails"/> + <actionGroup ref="AdminEnhancedMediaGalleryVerifyImageDetailsActionGroup" stepKey="verifyImageDetails"> + <argument name="image" value="UpdatedImageDetails"/> + </actionGroup> + <actionGroup ref="AdminEnhancedMediaGalleryVerifyImageDescriptionActionGroup" stepKey="verifyImageDescription"> + <argument name="description" value="UpdatedImageDetails.description"/> + </actionGroup> + </test> +</tests> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminMediaGalleryEditImageDetailsTest.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminMediaGalleryEditImageDetailsTest.xml index 960443998d010..34c3159ab769e 100644 --- a/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminMediaGalleryEditImageDetailsTest.xml +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminMediaGalleryEditImageDetailsTest.xml @@ -7,16 +7,19 @@ --> <tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> - <test name="AdminMediaGalleryEditImageDetailsTest"> + <test name="AdminMediaGalleryEditImageDetailsTest" deprecated="Use AdminMediaGalleryEditImageDetailsFromGridTest instead"> <annotations> <features value="MediaGallery"/> <useCaseId value="https://github.com/magento/adobe-stock-integration/issues/724"/> - <title value="User edits image meta data in media gallery"/> + <title value="DEPRECATED. User edits image meta data in media gallery"/> <stories value="[Story # 38] User views basic image attributes in Media Gallery"/> <testCaseId value="https://studio.cucumber.io/projects/131313/test-plan/folders/1320712/scenarios/3961351"/> <description value="User edits image meta data in Standalone Media Gallery"/> <severity value="CRITICAL"/> <group value="media_gallery_ui"/> + <skip> + <issueId value="DEPRECATED">Use AdminMediaGalleryEditImageDetailsFromGridTest instead</issueId> + </skip> </annotations> <before> <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminMediaGalleryStoreViewCategoryFilterTest.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminMediaGalleryStoreViewCategoryFilterTest.xml index eceda879e5597..9c72ee4ac38fe 100644 --- a/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminMediaGalleryStoreViewCategoryFilterTest.xml +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminMediaGalleryStoreViewCategoryFilterTest.xml @@ -33,6 +33,8 @@ <argument name="category" value="$$category$$"/> </actionGroup> <actionGroup ref="AdminOpenMediaGalleryTinyMce4ActionGroup" stepKey="openMediaGalleryFromWysiwyg"/> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearFilter"/> + <actionGroup ref="ResetAdminDataGridToDefaultViewActionGroup" stepKey="resetGridToDefaultView"/> <actionGroup ref="AdminEnhancedMediaGalleryUploadImageActionGroup" stepKey="uploadImage"> <argument name="image" value="ImageUpload3"/> </actionGroup> @@ -56,7 +58,7 @@ <actionGroup ref="AdminEnhancedMediaGallerySelectImageForMassActionActionGroup" stepKey="selectFirstImageToDelete"> <argument name="imageName" value="{{ImageMetadata.title}}"/> </actionGroup> - <actionGroup ref="AdminEnhancedMediaGalleryClickDeleteImagesButtonActionGroup" stepKey="clikDeleteSelectedButton"/> + <actionGroup ref="AdminEnhancedMediaGalleryClickDeleteImagesButtonActionGroup" stepKey="clickDeleteSelectedButton"/> <actionGroup ref="AdminEnhancedMediaGalleryConfirmDeleteImagesActionGroup" stepKey="deleteImages"/> </test> </tests> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminMediaGalleryUploadCategoryImageTest.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminMediaGalleryUploadCategoryImageTest.xml index 684db1d4a2627..fa43e4e17d406 100644 --- a/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminMediaGalleryUploadCategoryImageTest.xml +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminMediaGalleryUploadCategoryImageTest.xml @@ -37,8 +37,8 @@ <actionGroup ref="AdminSaveCategoryFormActionGroup" stepKey="saveCategoryForm"/> <actionGroup ref="AdminOpenMediaGalleryFromCategoryImageUploaderActionGroup" stepKey="openMediaGalleryFromImageUploader"/> <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearFilter"/> - <actionGroup ref="AdminMediaGalleryFolderSelectActionGroup" stepKey="selectCatalogFolder"> - <argument name="name" value="catalog"/> + <actionGroup ref="AdminMediaGalleryExpandFolderActionGroup" stepKey="expandCatalogFolder"> + <argument name="fieldId" value="catalog"/> </actionGroup> <actionGroup ref="AdminMediaGalleryFolderSelectActionGroup" stepKey="selectCategoryFolder"> <argument name="name" value="category"/> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminMediaGalleryViewDetailsTest.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminMediaGalleryViewDetailsTest.xml index c9447d5cc8a52..a65dbf0c0f56d 100644 --- a/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminMediaGalleryViewDetailsTest.xml +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminMediaGalleryViewDetailsTest.xml @@ -23,9 +23,27 @@ <actionGroup ref="AdminOpenStandaloneMediaGalleryActionGroup" stepKey="openMediaGallery"/> </before> <after> - <actionGroup ref="AdminEnhancedMediaGalleryImageDetailsDeleteActionGroup" stepKey="deleteImage"/> + <actionGroup ref="AdminOpenStandaloneMediaGalleryActionGroup" stepKey="openStandaloneMediaGallery"/> + <actionGroup ref="ResetAdminDataGridToDefaultViewActionGroup" stepKey="resetAdminDataGridToDefaultView"/> + <wait time="5" stepKey="waitFilterReallyCleared"/> + <actionGroup ref="AdminMediaGalleryFolderSelectActionGroup" stepKey="selectFolder"> + <argument name="name" value="assetFolder"/> + </actionGroup> + <actionGroup ref="AdminMediaGalleryFolderDeleteActionGroup" stepKey="deleteFolder"/> + <actionGroup ref="AdminMediaGalleryAssertFolderDoesNotExistActionGroup" stepKey="assertFolderWasDeleted"> + <argument name="name" value="assetFolder"/> + </actionGroup> </after> - + <actionGroup ref="ResetAdminDataGridToDefaultViewActionGroup" stepKey="resetAdminDataGridToDefaultView"/> + <wait time="5" stepKey="waitFilterReallyCleared"/> + <actionGroup ref="AdminMediaGalleryOpenNewFolderFormActionGroup" stepKey="openNewFolderForm"/> + <actionGroup ref="AdminMediaGalleryCreateNewFolderActionGroup" stepKey="createNewFolder"> + <argument name="name" value="assetFolder"/> + </actionGroup> + <actionGroup ref="AdminMediaGalleryAssertFolderNameActionGroup" stepKey="assertNewFolderCreated"> + <argument name="name" value="assetFolder"/> + </actionGroup> + <waitForPageLoad stepKey="waitForGridToLoadAfterNewFolderCreated"/> <actionGroup ref="AdminEnhancedMediaGalleryUploadImageActionGroup" stepKey="uploadImage"> <argument name="image" value="ImageUpload"/> </actionGroup> @@ -36,5 +54,6 @@ <actionGroup ref="AdminEnhancedMediaGalleryVerifyImageFilenameActionGroup" stepKey="verifyFilename"> <argument name="filename" value="{{ImageUpload.file}}"/> </actionGroup> + <actionGroup ref="AdminEnhancedMediaGalleryCloseViewDetailsActionGroup" stepKey="closeDetails"/> </test> </tests> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminStandaloneMediaGalleryEditImageDetailsFromDialogTest.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminStandaloneMediaGalleryEditImageDetailsFromDialogTest.xml new file mode 100644 index 0000000000000..250b42c5510a7 --- /dev/null +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminStandaloneMediaGalleryEditImageDetailsFromDialogTest.xml @@ -0,0 +1,61 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminStandaloneMediaGalleryEditImageDetailsFromDialogTest"> + <annotations> + <features value="MediaGalleryUi"/> + <stories value="[Story # 38] User views basic image attributes in Media Gallery"/> + <useCaseId value="https://github.com/magento/adobe-stock-integration/issues/724"/> + <title value="User edits image meta data in standalone media gallery"/> + <description value="User edits image meta data in Standalone Media Gallery"/> + <testCaseId value="https://studio.cucumber.io/projects/131313/test-plan/folders/1320712/scenarios/3961351"/> + <severity value="CRITICAL"/> + <group value="media_gallery_ui"/> + </annotations> + + <before> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + <actionGroup ref="AdminEnhancedMediaGalleryDeletedAllImagesActionGroup" stepKey="deleteAllMediaGalleryImages"/> + </before> + + <after> + <actionGroup ref="AdminEnhancedMediaGalleryDeletedAllImagesActionGroup" stepKey="deleteAllMediaGalleryImages"/> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logoutFromAdmin"/> + </after> + + <actionGroup ref="AdminOpenStandaloneMediaGalleryActionGroup" stepKey="openStandaloneMediaGallery"/> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="resetMediaGalleryGridFilters"/> + <actionGroup ref="AdminEnhancedMediaGalleryUploadImageActionGroup" stepKey="uploadImage"> + <argument name="image" value="ImageUpload"/> + </actionGroup> + <generateDate date="now" format="s" stepKey="secondsFromMinuteStart"/> + <actionGroup ref="AdminEnhancedMediaGalleryViewImageDetails" stepKey="clickViewDetails"/> + <actionGroup ref="AssertAdminEnhancedMediaGalleryUploadedImageDateTimeEqualsActionGroup" stepKey="verifyCreatedAndUpdatedAtDate" /> + + <executeJS function="return 60 - {$secondsFromMinuteStart} + 5" stepKey="calcWaitPeriod"/> + <wait time="$calcWaitPeriod" stepKey="waitTillEndOfAMinute"/> + + <actionGroup ref="AdminEnhancedMediaGalleryCloseViewDetailsActionGroup" stepKey="closeViewDetails"/> + <actionGroup ref="AdminEnhancedMediaGalleryEditImageDetailsActionGroup" stepKey="editImageDetails"/> + <actionGroup ref="AdminEnhancedMediaGalleryImageDetailsSaveActionGroup" stepKey="saveImage"> + <argument name="image" value="UpdatedImageDetails"/> + </actionGroup> + <actionGroup ref="AssertImageAttributesOnEnhancedMediaGalleryActionGroup" stepKey="verifyUpdateImageOnTheGrid"> + <argument name="image" value="UpdatedImageDetails"/> + </actionGroup> + <actionGroup ref="AdminEnhancedMediaGalleryViewImageDetails" stepKey="viewImageDetails"/> + <actionGroup ref="AdminEnhancedMediaGalleryVerifyImageDetailsActionGroup" stepKey="verifyImageDetails"> + <argument name="image" value="UpdatedImageDetails"/> + </actionGroup> + <actionGroup ref="AssertAdminEnhancedMediaGalleryImageCreatedAtNotEqualsUpdatedAtTimeActionGroup" stepKey="assertUpdatedAtTimeChanged" /> + <actionGroup ref="AdminEnhancedMediaGalleryVerifyImageDescriptionActionGroup" stepKey="verifyImageDescription"> + <argument name="description" value="UpdatedImageDetails.description"/> + </actionGroup> + </test> +</tests> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminStandaloneMediaGalleryEditImageDetailsTest.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminStandaloneMediaGalleryEditImageDetailsTest.xml index 58c6f32b8d72f..039e9212945e2 100644 --- a/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminStandaloneMediaGalleryEditImageDetailsTest.xml +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminStandaloneMediaGalleryEditImageDetailsTest.xml @@ -7,16 +7,19 @@ --> <tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> - <test name="AdminStandaloneMediaGalleryEditImageDetailsTest"> + <test name="AdminStandaloneMediaGalleryEditImageDetailsTest" deprecated="Use AdminStandaloneMediaGalleryEditImageDetailsFromDialogTest instead"> <annotations> <features value="MediaGallery"/> <useCaseId value="https://github.com/magento/adobe-stock-integration/issues/724"/> - <title value="User edits image meta data in standalone media gallery"/> + <title value="DEPRECATED. User edits image meta data in standalone media gallery"/> <stories value="[Story # 38] User views basic image attributes in Media Gallery"/> <testCaseId value="https://studio.cucumber.io/projects/131313/test-plan/folders/1320712/scenarios/3961351"/> <description value="User edits image meta data in Standalone Media Gallery"/> <severity value="CRITICAL"/> <group value="media_gallery_ui"/> + <skip> + <issueId value="DEPRECATED">Use AdminStandaloneMediaGalleryEditImageDetailsFromDialogTest instead</issueId> + </skip> </annotations> <before> <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> diff --git a/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminStandaloneMediaGalleryViewDetailsTest.xml b/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminStandaloneMediaGalleryViewDetailsTest.xml index bb7071497ce24..0f4078d914b3a 100644 --- a/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminStandaloneMediaGalleryViewDetailsTest.xml +++ b/app/code/Magento/MediaGalleryUi/Test/Mftf/Test/AdminStandaloneMediaGalleryViewDetailsTest.xml @@ -23,9 +23,27 @@ <actionGroup ref="AdminOpenStandaloneMediaGalleryActionGroup" stepKey="openStandaloneMediaGallery"/> </before> <after> - <actionGroup ref="AdminEnhancedMediaGalleryImageDetailsDeleteActionGroup" stepKey="deleteImage"/> + <actionGroup ref="AdminOpenStandaloneMediaGalleryActionGroup" stepKey="openStandaloneMediaGallery"/> + <actionGroup ref="ResetAdminDataGridToDefaultViewActionGroup" stepKey="resetAdminDataGridToDefaultView"/> + <wait time="5" stepKey="waitFilterReallyCleared"/> + <actionGroup ref="AdminMediaGalleryFolderSelectActionGroup" stepKey="selectFolder"> + <argument name="name" value="assetTestFolder"/> + </actionGroup> + <actionGroup ref="AdminMediaGalleryFolderDeleteActionGroup" stepKey="deleteFolder"/> + <actionGroup ref="AdminMediaGalleryAssertFolderDoesNotExistActionGroup" stepKey="assertFolderWasDeleted"> + <argument name="name" value="assetTestFolder"/> + </actionGroup> </after> - + <actionGroup ref="ResetAdminDataGridToDefaultViewActionGroup" stepKey="resetAdminDataGridToDefaultView"/> + <wait time="5" stepKey="waitFilterReallyCleared"/> + <actionGroup ref="AdminMediaGalleryOpenNewFolderFormActionGroup" stepKey="openNewFolderForm"/> + <actionGroup ref="AdminMediaGalleryCreateNewFolderActionGroup" stepKey="createNewFolder"> + <argument name="name" value="assetTestFolder"/> + </actionGroup> + <actionGroup ref="AdminMediaGalleryAssertFolderNameActionGroup" stepKey="assertNewFolderCreated"> + <argument name="name" value="assetTestFolder"/> + </actionGroup> + <waitForPageLoad stepKey="waitForGridToLoadAfterNewFolderCreated"/> <actionGroup ref="AdminEnhancedMediaGalleryUploadImageActionGroup" stepKey="uploadImage"> <argument name="image" value="ImageUpload"/> </actionGroup> @@ -36,5 +54,6 @@ <actionGroup ref="AdminEnhancedMediaGalleryVerifyImageFilenameActionGroup" stepKey="verifyFilename"> <argument name="filename" value="{{ImageUpload.file}}"/> </actionGroup> + <actionGroup ref="AdminEnhancedMediaGalleryCloseViewDetailsActionGroup" stepKey="closeDetails"/> </test> </tests> diff --git a/app/code/Magento/MediaGalleryUi/Test/Unit/Model/UploadImageTest.php b/app/code/Magento/MediaGalleryUi/Test/Unit/Model/UploadImageTest.php index fc8a0756a7b55..4946cd1092ff7 100644 --- a/app/code/Magento/MediaGalleryUi/Test/Unit/Model/UploadImageTest.php +++ b/app/code/Magento/MediaGalleryUi/Test/Unit/Model/UploadImageTest.php @@ -129,7 +129,7 @@ public function executeDataProvider(): array [ 'targetFolder' => 'media/catalog', 'type' => 'image', - 'absolutePath' => 'root/pub/media/catalog/test-image.jpeg' + 'absolutePath' => 'root/media/catalog/test-image.jpeg' ] ]; } diff --git a/app/code/Magento/MediaGalleryUi/Ui/Component/Listing/Columns/Url.php b/app/code/Magento/MediaGalleryUi/Ui/Component/Listing/Columns/Url.php index 0d48a0d0ff0e1..a55c6397a31fa 100644 --- a/app/code/Magento/MediaGalleryUi/Ui/Component/Listing/Columns/Url.php +++ b/app/code/Magento/MediaGalleryUi/Ui/Component/Listing/Columns/Url.php @@ -115,8 +115,8 @@ public function prepare(): void (array)$this->getData('config'), [ 'allowedActions' => $this->getAllowedActions(), - 'onInsertUrl' => $this->urlInterface->getUrl('cms/wysiwyg_images/oninsert'), - 'storeId' => $this->storeManager->getStore()->getId() + 'onInsertUrl' => $this->urlInterface->getUrl('media_gallery/image/oninsert'), + 'storeId' => $this->storeManager->getStore()->getId(), ] ) ); diff --git a/app/code/Magento/MediaGalleryUi/etc/di.xml b/app/code/Magento/MediaGalleryUi/etc/di.xml index 6ed3a98bbf03a..964ac92399738 100644 --- a/app/code/Magento/MediaGalleryUi/etc/di.xml +++ b/app/code/Magento/MediaGalleryUi/etc/di.xml @@ -7,6 +7,7 @@ --> <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd"> <preference for="Magento\MediaGalleryUiApi\Api\ConfigInterface" type="Magento\MediaGalleryUi\Model\Config"/> + <preference for="Magento\MediaGalleryUiApi\Api\Data\InsertImageDataInterface" type="\Magento\MediaGalleryUi\Model\InsertImageData"/> <type name="Magento\Framework\View\Element\UiComponent\DataProvider\CollectionFactory"> <arguments> <argument name="collections" xsi:type="array"> diff --git a/app/code/Magento/MediaGalleryUi/view/adminhtml/templates/image_details.phtml b/app/code/Magento/MediaGalleryUi/view/adminhtml/templates/image_details.phtml index 5df5c1a6c4cbd..783ff5a9c05bd 100644 --- a/app/code/Magento/MediaGalleryUi/view/adminhtml/templates/image_details.phtml +++ b/app/code/Magento/MediaGalleryUi/view/adminhtml/templates/image_details.phtml @@ -22,15 +22,8 @@ use Magento\Framework\Escaper; title: '<?= $escaper->escapeHtmlAttr(__('Image Details')); ?>' } }"> - <div class="page-main-actions"> - <div class="page-actions"> - <div class="page-actions-inner"> - <div class="page-action-buttons" id="media-gallery-image-actions" - data-bind="scope: 'mediaGalleryImageActions'"> - <!-- ko template: getTemplate() --><!-- /ko --> - </div> - </div> - </div> + <div class="page-main-actions" data-bind="scope: 'mediaGalleryImageActions'"> + <!-- ko template: getTemplate() --><!-- /ko --> </div> <div id="media-gallery-image-details-messages" data-bind="scope: 'mediaGalleryImageDetailsMessages'"> <!-- ko template: getTemplate() --><!-- /ko --> @@ -51,22 +44,10 @@ use Magento\Framework\Escaper; "modalSelector": ".media-gallery-image-details-modal", "modalWindowSelector": ".media-gallery-image-details", "mediaGridMessages": "media_gallery_listing.media_gallery_listing.messages" - } - } - } - }, - "#media-gallery-image-details-messages": { - "Magento_Ui/js/core/app": { - "components": { + }, "mediaGalleryImageDetailsMessages": { "component": "Magento_MediaGalleryUi/js/grid/messages" - } - } - } - }, - "#media-gallery-image-actions": { - "Magento_Ui/js/core/app": { - "components": { + }, "mediaGalleryImageActions": { "component": "Magento_MediaGalleryUi/js/image/image-actions", "modalSelector": ".media-gallery-image-details-modal", diff --git a/app/code/Magento/MediaGalleryUi/view/adminhtml/templates/image_details_standalone.phtml b/app/code/Magento/MediaGalleryUi/view/adminhtml/templates/image_details_standalone.phtml index fdae0a549606c..a4a096939eea4 100644 --- a/app/code/Magento/MediaGalleryUi/view/adminhtml/templates/image_details_standalone.phtml +++ b/app/code/Magento/MediaGalleryUi/view/adminhtml/templates/image_details_standalone.phtml @@ -18,15 +18,8 @@ title: '<?= $escaper->escapeHtmlAttr(__('Image Details')); ?>' } }"> - <div class="page-main-actions"> - <div class="page-actions"> - <div class="page-actions-inner"> - <div class="page-action-buttons" id="media-gallery-image-actions" - data-bind="scope: 'mediaGalleryImageActions'"> - <!-- ko template: getTemplate() --><!-- /ko --> - </div> - </div> - </div> + <div class="page-main-actions" data-bind="scope: 'mediaGalleryImageActions'"> + <!-- ko template: getTemplate() --><!-- /ko --> </div> <div id="media-gallery-image-details-messages" data-bind="scope: 'mediaGalleryImageDetailsMessages'"> <!-- ko template: getTemplate() --><!-- /ko --> @@ -47,22 +40,7 @@ "modalSelector": ".media-gallery-image-details-modal", "modalWindowSelector": ".media-gallery-image-details", "mediaGridMessages": "standalone_media_gallery_listing.standalone_media_gallery_listing.messages" - } - } - } - }, - "#media-gallery-image-details-messages": { - "Magento_Ui/js/core/app": { - "components": { - "mediaGalleryImageDetailsMessages": { - "component": "Magento_MediaGalleryUi/js/grid/messages" - } - } - } - }, - "#media-gallery-image-actions": { - "Magento_Ui/js/core/app": { - "components": { + }, "mediaGalleryImageActions": { "component": "Magento_MediaGalleryUi/js/image/image-actions", "modalSelector": ".media-gallery-image-details-modal", @@ -70,6 +48,9 @@ "mediaGalleryImageDetailsName": "mediaGalleryImageDetails", "imageModelName" : "standalone_media_gallery_listing.standalone_media_gallery_listing.media_gallery_columns.thumbnail_url", "actionsList": <?= /* @noEscape */ $block->getActionsJson() ?> + }, + "mediaGalleryImageDetailsMessages": { + "component": "Magento_MediaGalleryUi/js/grid/messages" } } } diff --git a/app/code/Magento/MediaGalleryUi/view/adminhtml/templates/image_edit_details.phtml b/app/code/Magento/MediaGalleryUi/view/adminhtml/templates/image_edit_details.phtml index c2b7e66cc89bd..bda0dccb9ae4b 100644 --- a/app/code/Magento/MediaGalleryUi/view/adminhtml/templates/image_edit_details.phtml +++ b/app/code/Magento/MediaGalleryUi/view/adminhtml/templates/image_edit_details.phtml @@ -20,15 +20,8 @@ use Magento\Backend\Block\Template; title: '<?= $escaper->escapeHtmlAttr(__('Edit Image')); ?>' } }"> - <div class="page-main-actions"> - <div class="page-actions"> - <div class="page-actions-inner"> - <div class="page-action-buttons" id="media-gallery-edit-image-actions" - data-bind="scope: 'mediaGalleryImageEditActions'"> - <!-- ko template: getTemplate() --><!-- /ko --> - </div> - </div> - </div> + <div class="page-main-actions" data-bind="scope: 'mediaGalleryImageEditActions'"> + <!-- ko template: getTemplate() --><!-- /ko --> </div> <div id="media-gallery-image-edit-details-messages" data-bind="scope: 'mediaGalleryEditDetailsMessages'"> <!-- ko template: getTemplate() --><!-- /ko --> @@ -50,25 +43,10 @@ use Magento\Backend\Block\Template; "imageEditDetailsUrl": "<?= $escaper->escapeJs($block->getData('imageEditDetailsUrl')); ?>", "saveDetailsUrl": "<?= $escaper->escapeJs($block->getData('saveDetailsUrl')); ?>", "mediaGridMessages": "standalone_media_gallery_listing.standalone_media_gallery_listing.messages" - } - } - }, - "Magento_MediaGalleryUi/js/validation/validate-image-title": {}, - "Magento_MediaGalleryUi/js/validation/validate-image-description": {}, - "Magento_MediaGalleryUi/js/validation/validate-image-keyword": {} - }, - "#media-gallery-image-edit-details-messages": { - "Magento_Ui/js/core/app": { - "components": { + }, "mediaGalleryEditDetailsMessages": { "component": "Magento_MediaGalleryUi/js/grid/messages" - } - } - } - }, - "#media-gallery-edit-image-actions": { - "Magento_Ui/js/core/app": { - "components": { + }, "mediaGalleryImageEditActions": { "component": "Magento_MediaGalleryUi/js/image/image-actions", "modalSelector": ".media-gallery-edit-image-details-modal", @@ -91,7 +69,10 @@ use Magento\Backend\Block\Template; ] } } - } + }, + "Magento_MediaGalleryUi/js/validation/validate-image-title": {}, + "Magento_MediaGalleryUi/js/validation/validate-image-description": {}, + "Magento_MediaGalleryUi/js/validation/validate-image-keyword": {} } } </script> diff --git a/app/code/Magento/MediaGalleryUi/view/adminhtml/templates/image_edit_details_standalone.phtml b/app/code/Magento/MediaGalleryUi/view/adminhtml/templates/image_edit_details_standalone.phtml index ec48ed8bb9053..9a8f01b1c2939 100644 --- a/app/code/Magento/MediaGalleryUi/view/adminhtml/templates/image_edit_details_standalone.phtml +++ b/app/code/Magento/MediaGalleryUi/view/adminhtml/templates/image_edit_details_standalone.phtml @@ -20,15 +20,8 @@ use Magento\Backend\Block\Template; title: '<?= $escaper->escapeHtmlAttr(__('Edit Image')); ?>' } }"> - <div class="page-main-actions"> - <div class="page-actions"> - <div class="page-actions-inner"> - <div class="page-action-buttons" id="media-gallery-edit-image-actions" - data-bind="scope: 'mediaGalleryImageEditActions'"> - <!-- ko template: getTemplate() --><!-- /ko --> - </div> - </div> - </div> + <div class="page-main-actions" data-bind="scope: 'mediaGalleryImageEditActions'"> + <!-- ko template: getTemplate() --><!-- /ko --> </div> <div id="media-gallery-image-edit-details-messages" data-bind="scope: 'mediaGalleryEditDetailsMessages'"> <!-- ko template: getTemplate() --><!-- /ko --> @@ -50,25 +43,10 @@ use Magento\Backend\Block\Template; "imageEditDetailsUrl": "<?= $escaper->escapeJs($block->getData('imageEditDetailsUrl')); ?>", "saveDetailsUrl": "<?= $escaper->escapeJs($block->getData('saveDetailsUrl')); ?>", "mediaGridMessages": "standalone_media_gallery_listing.standalone_media_gallery_listing.messages" - } - } - }, - "Magento_MediaGalleryUi/js/validation/validate-image-title": {}, - "Magento_MediaGalleryUi/js/validation/validate-image-description": {}, - "Magento_MediaGalleryUi/js/validation/validate-image-keyword": {} - }, - "#media-gallery-image-edit-details-messages": { - "Magento_Ui/js/core/app": { - "components": { + }, "mediaGalleryEditDetailsMessages": { "component": "Magento_MediaGalleryUi/js/grid/messages" - } - } - } - }, - "#media-gallery-edit-image-actions": { - "Magento_Ui/js/core/app": { - "components": { + }, "mediaGalleryImageEditActions": { "component": "Magento_MediaGalleryUi/js/image/image-actions", "modalSelector": ".media-gallery-edit-image-details-modal", @@ -91,7 +69,10 @@ use Magento\Backend\Block\Template; ] } } - } + }, + "Magento_MediaGalleryUi/js/validation/validate-image-title": {}, + "Magento_MediaGalleryUi/js/validation/validate-image-description": {}, + "Magento_MediaGalleryUi/js/validation/validate-image-keyword": {} } } </script> diff --git a/app/code/Magento/MediaGalleryUi/view/adminhtml/web/js/grid/columns/image/insertImageAction.js b/app/code/Magento/MediaGalleryUi/view/adminhtml/web/js/grid/columns/image/insertImageAction.js index f72a05b6d2709..322b29c92ca5b 100644 --- a/app/code/Magento/MediaGalleryUi/view/adminhtml/web/js/grid/columns/image/insertImageAction.js +++ b/app/code/Magento/MediaGalleryUi/view/adminhtml/web/js/grid/columns/image/insertImageAction.js @@ -47,13 +47,13 @@ define([ showLoader: true }).done($.proxy(function (data) { if (targetElement.is('textarea')) { - this.insertAtCursor(targetElement.get(0), data); + this.insertAtCursor(targetElement.get(0), data.content); targetElement.focus(); $(targetElement).change(); } else { - targetElement.val(data) - .data('size', record.size) - .data('mime-type', record['content_type']) + targetElement.val(data.content) + .data('size', data.size) + .data('mime-type', data.type) .trigger('change'); } }, this)); diff --git a/app/code/Magento/MediaGalleryUi/view/adminhtml/web/template/image/actions.html b/app/code/Magento/MediaGalleryUi/view/adminhtml/web/template/image/actions.html index 8ecaf0bd2a019..3a80116c9225e 100644 --- a/app/code/Magento/MediaGalleryUi/view/adminhtml/web/template/image/actions.html +++ b/app/code/Magento/MediaGalleryUi/view/adminhtml/web/template/image/actions.html @@ -4,9 +4,16 @@ * See COPYING.txt for license details. */ --> -<each args="{ data: actionsList, as: 'action' }"> - <button type="button" click="$parent[action.handler].bind($parent)" - attr="{class: action.classes, id: 'image-details-action-' + action.name, title: $t(action.title)}"> - <span translate="action.title"></span> - </button> -</each> +<div class="page-actions"> + <div class="page-actions-inner"> + <div class="page-action-buttons"> + <each args="{ data: actionsList, as: 'action' }"> + <button type="button" click="$parent[action.handler].bind($parent)" + attr="{class: action.classes, id: 'image-details-action-' + action.name, title: $t(action.title)}"> + <span translate="action.title"></span> + </button> + </each> + </div> + </div> +</div> + diff --git a/app/code/Magento/MediaStorage/App/Media.php b/app/code/Magento/MediaStorage/App/Media.php index ca5ff458c52e9..34c20aab40bcb 100644 --- a/app/code/Magento/MediaStorage/App/Media.php +++ b/app/code/Magento/MediaStorage/App/Media.php @@ -11,6 +11,7 @@ use Closure; use Exception; use LogicException; +use Magento\Catalog\Model\Config\CatalogMediaConfig; use Magento\Catalog\Model\View\Asset\PlaceholderFactory; use Magento\Framework\App; use Magento\Framework\App\Area; @@ -18,6 +19,7 @@ use Magento\Framework\App\ResponseInterface; use Magento\Framework\App\State; use Magento\Framework\AppInterface; +use Magento\Framework\Exception\NotFoundException; use Magento\Framework\Filesystem; use Magento\Framework\Filesystem\Directory\WriteInterface; use Magento\Framework\Filesystem\Driver\File; @@ -103,6 +105,11 @@ class Media implements AppInterface */ private $imageResize; + /** + * @var string + */ + private $mediaUrlFormat; + /** * @param ConfigFactory $configFactory * @param SynchronizationFactory $syncFactory @@ -116,6 +123,8 @@ class Media implements AppInterface * @param State $state * @param ImageResize $imageResize * @param File $file + * @param CatalogMediaConfig $catalogMediaConfig + * @throws \Magento\Framework\Exception\FileSystemException * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -130,12 +139,19 @@ public function __construct( PlaceholderFactory $placeholderFactory, State $state, ImageResize $imageResize, - File $file + File $file, + CatalogMediaConfig $catalogMediaConfig = null ) { $this->response = $response; $this->isAllowed = $isAllowed; - $this->directoryPub = $filesystem->getDirectoryWrite(DirectoryList::PUB); - $this->directoryMedia = $filesystem->getDirectoryWrite(DirectoryList::MEDIA); + $this->directoryPub = $filesystem->getDirectoryWrite( + DirectoryList::PUB, + Filesystem\DriverPool::FILE + ); + $this->directoryMedia = $filesystem->getDirectoryWrite( + DirectoryList::MEDIA, + Filesystem\DriverPool::FILE + ); $mediaDirectory = trim($mediaDirectory); if (!empty($mediaDirectory)) { // phpcs:ignore Magento2.Functions.DiscouragedFunction @@ -148,6 +164,9 @@ public function __construct( $this->placeholderFactory = $placeholderFactory; $this->appState = $state; $this->imageResize = $imageResize; + + $catalogMediaConfig = $catalogMediaConfig ?: App\ObjectManager::getInstance()->get(CatalogMediaConfig::class); + $this->mediaUrlFormat = $catalogMediaConfig->getMediaUrlFormat(); } /** @@ -174,10 +193,8 @@ public function launch(): ResponseInterface } try { - /** @var Synchronization $sync */ - $sync = $this->syncFactory->create(['directory' => $this->directoryPub]); - $sync->synchronize($this->relativeFileName); - $this->imageResize->resizeFromImageName($this->getOriginalImage($this->relativeFileName)); + $this->createLocalCopy(); + if ($this->directoryPub->isReadable($this->relativeFileName)) { $this->response->setFilePath($this->directoryPub->getAbsolutePath($this->relativeFileName)); } else { @@ -190,6 +207,25 @@ public function launch(): ResponseInterface return $this->response; } + /** + * Create local copy of file and perform resizing if necessary. + * + * @throws NotFoundException + */ + private function createLocalCopy(): void + { + $this->syncFactory->create(['directory' => $this->directoryPub]) + ->synchronize($this->relativeFileName); + + if ($this->directoryPub->isReadable($this->relativeFileName)) { + return; + } + + if ($this->mediaUrlFormat === CatalogMediaConfig::HASH) { + $this->imageResize->resizeFromImageName($this->getOriginalImage($this->relativeFileName)); + } + } + /** * Check if media directory changed * @@ -219,7 +255,7 @@ private function setPlaceholderImage(): void */ private function getOriginalImage(string $resizedImagePath): string { - return preg_replace('|^.*((?:/[^/]+){3})$|', '$1', $resizedImagePath); + return preg_replace('|^.*?((?:/([^/])/([^/])/\2\3)?/?[^/]+$)|', '$1', $resizedImagePath); } /** diff --git a/app/code/Magento/MediaStorage/Model/File/Storage.php b/app/code/Magento/MediaStorage/Model/File/Storage.php index 861f2d82c7e7b..f5ebda4a8d55c 100644 --- a/app/code/Magento/MediaStorage/Model/File/Storage.php +++ b/app/code/Magento/MediaStorage/Model/File/Storage.php @@ -83,12 +83,17 @@ class Storage extends AbstractModel protected $_databaseFactory; /** - * Filesystem instance - * * @var Filesystem + * + * @deprecated */ protected $filesystem; + /** + * @var Filesystem\Directory\ReadInterface + */ + private $localMediaDirectory; + /** * @param \Magento\Framework\Model\Context $context * @param \Magento\Framework\Registry $registry @@ -125,6 +130,11 @@ public function __construct( $this->_fileFactory = $fileFactory; $this->_databaseFactory = $databaseFactory; $this->filesystem = $filesystem; + + $this->localMediaDirectory = $filesystem->getDirectoryRead( + DirectoryList::MEDIA, + Filesystem\DriverPool::FILE + ); parent::__construct($context, $registry, $resource, $resourceCollection, $data); } @@ -286,7 +296,7 @@ public function synchronize($storage) public function getScriptConfig() { $config = []; - $config['media_directory'] = $this->filesystem->getDirectoryRead(DirectoryList::MEDIA)->getAbsolutePath(); + $config['media_directory'] = $this->localMediaDirectory->getAbsolutePath(); $allowedResources = $this->_coreConfig->getValue(self::XML_PATH_MEDIA_RESOURCE_WHITELIST, 'default'); foreach ($allowedResources as $allowedResource) { diff --git a/app/code/Magento/MediaStorage/Test/Unit/App/MediaTest.php b/app/code/Magento/MediaStorage/Test/Unit/App/MediaTest.php index 7f70f5ba48e5c..068732a7225cd 100644 --- a/app/code/Magento/MediaStorage/Test/Unit/App/MediaTest.php +++ b/app/code/Magento/MediaStorage/Test/Unit/App/MediaTest.php @@ -10,21 +10,23 @@ use Exception; use LogicException; +use Magento\Catalog\Model\Config\CatalogMediaConfig; use Magento\Catalog\Model\View\Asset\Placeholder; use Magento\Catalog\Model\View\Asset\PlaceholderFactory; use Magento\Framework\App\Bootstrap; use Magento\Framework\App\Filesystem\DirectoryList; +use Magento\Framework\App\State; use Magento\Framework\Filesystem; use Magento\Framework\Filesystem\Directory\Read; use Magento\Framework\Filesystem\Directory\WriteInterface; use Magento\Framework\Filesystem\DriverPool; -use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; use Magento\MediaStorage\App\Media; use Magento\MediaStorage\Model\File\Storage\Config; use Magento\MediaStorage\Model\File\Storage\ConfigFactory; use Magento\MediaStorage\Model\File\Storage\Response; use Magento\MediaStorage\Model\File\Storage\Synchronization; use Magento\MediaStorage\Model\File\Storage\SynchronizationFactory; +use Magento\MediaStorage\Service\ImageResize; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -80,122 +82,106 @@ class MediaTest extends TestCase private $directoryMediaMock; /** - * @var \Magento\Framework\Filesystem\Directory\Read|MockObject + * @var Read|MockObject */ private $directoryPubMock; + /** + * @inheritDoc + */ protected function setUp(): void { $this->configMock = $this->createMock(Config::class); $this->sync = $this->createMock(Synchronization::class); - $this->configFactoryMock = $this->createPartialMock( - ConfigFactory::class, - ['create'] - ); - $this->configFactoryMock->expects($this->any()) - ->method('create') + $this->configFactoryMock = $this->createPartialMock(ConfigFactory::class, ['create']); + $this->responseMock = $this->createMock(Response::class); + $this->syncFactoryMock = $this->createPartialMock(SynchronizationFactory::class, ['create']); + $this->filesystemMock = $this->createMock(Filesystem::class); + $this->directoryPubMock = $this->getMockForAbstractClass(WriteInterface::class); + $this->directoryMediaMock = $this->getMockForAbstractClass(WriteInterface::class); + + $this->configFactoryMock->method('create') ->willReturn($this->configMock); - $this->syncFactoryMock = $this->createPartialMock( - SynchronizationFactory::class, - ['create'] - ); - $this->syncFactoryMock->expects($this->any()) - ->method('create') + $this->syncFactoryMock->method('create') ->willReturn($this->sync); - - $this->filesystemMock = $this->createMock(Filesystem::class); - $this->directoryPubMock = $this->getMockForAbstractClass( - WriteInterface::class, - [], - '', - false, - true, - true, - ['isReadable', 'getAbsolutePath'] - ); - $this->directoryMediaMock = $this->getMockForAbstractClass( - WriteInterface::class, - [], - '', - false, - true, - true, - ['getAbsolutePath'] - ); - $this->filesystemMock->expects($this->any()) - ->method('getDirectoryWrite') + $this->filesystemMock->method('getDirectoryWrite') ->willReturnMap([ [DirectoryList::PUB, DriverPool::FILE, $this->directoryPubMock], [DirectoryList::MEDIA, DriverPool::FILE, $this->directoryMediaMock], ]); - - $this->responseMock = $this->createMock(Response::class); } - protected function tearDown(): void + public function testProcessRequestCreatesConfigFileMediaDirectoryIsNotProvided(): void { - unset($this->mediaModel); - } - - public function testProcessRequestCreatesConfigFileMediaDirectoryIsNotProvided() - { - $this->mediaModel = $this->getMediaModel(); - $filePath = '/absolute/path/to/test/file.png'; - $this->directoryMediaMock->expects($this->once()) + $this->directoryMediaMock->expects(self::once()) ->method('getAbsolutePath') ->with(null) ->willReturn(self::MEDIA_DIRECTORY); - $this->directoryPubMock->expects($this->once()) + $this->directoryPubMock->expects(self::once()) ->method('getAbsolutePath') ->with(self::RELATIVE_FILE_PATH) ->willReturn($filePath); - $this->configMock->expects($this->once())->method('save'); - $this->sync->expects($this->once())->method('synchronize')->with(self::RELATIVE_FILE_PATH); - $this->directoryPubMock->expects($this->once()) + $this->configMock->expects(self::once()) + ->method('save'); + $this->sync->expects(self::once()) + ->method('synchronize') + ->with(self::RELATIVE_FILE_PATH); + $this->directoryPubMock->expects(self::exactly(2)) ->method('isReadable') ->with(self::RELATIVE_FILE_PATH) ->willReturn(true); - $this->responseMock->expects($this->once())->method('setFilePath')->with($filePath); - $this->mediaModel->launch(); + $this->responseMock->expects(self::once()) + ->method('setFilePath') + ->with($filePath); + + $this->createMediaModel()->launch(); } - public function testProcessRequestReturnsFileIfItsProperlySynchronized() + public function testProcessRequestReturnsFileIfItsProperlySynchronized(): void { - $this->mediaModel = $this->getMediaModel(); + $this->mediaModel = $this->createMediaModel(); $filePath = '/absolute/path/to/test/file.png'; - $this->sync->expects($this->once())->method('synchronize')->with(self::RELATIVE_FILE_PATH); - $this->directoryMediaMock->expects($this->once()) + $this->sync->expects(self::once()) + ->method('synchronize') + ->with(self::RELATIVE_FILE_PATH); + $this->directoryMediaMock->expects(self::once()) ->method('getAbsolutePath') ->with(null) ->willReturn(self::MEDIA_DIRECTORY); - $this->directoryPubMock->expects($this->once()) + $this->directoryPubMock->expects(self::exactly(2)) ->method('isReadable') ->with(self::RELATIVE_FILE_PATH) ->willReturn(true); - $this->directoryPubMock->expects($this->once()) + $this->directoryPubMock->expects(self::once()) ->method('getAbsolutePath') ->with(self::RELATIVE_FILE_PATH) ->willReturn($filePath); - $this->responseMock->expects($this->once())->method('setFilePath')->with($filePath); - $this->assertSame($this->responseMock, $this->mediaModel->launch()); + $this->responseMock->expects(self::once()) + ->method('setFilePath') + ->with($filePath); + + self::assertSame($this->responseMock, $this->mediaModel->launch()); } - public function testProcessRequestReturnsNotFoundIfFileIsNotSynchronized() + public function testProcessRequestReturnsNotFoundIfFileIsNotSynchronized(): void { - $this->mediaModel = $this->getMediaModel(); + $this->mediaModel = $this->createMediaModel(); - $this->sync->expects($this->once())->method('synchronize')->with(self::RELATIVE_FILE_PATH); - $this->directoryMediaMock->expects($this->once()) + $this->sync->expects(self::once()) + ->method('synchronize') + ->with(self::RELATIVE_FILE_PATH); + $this->directoryMediaMock->expects(self::once()) ->method('getAbsolutePath') ->with(null) ->willReturn(self::MEDIA_DIRECTORY); - $this->directoryPubMock->expects($this->once()) + $this->directoryPubMock->expects(self::exactly(2)) ->method('isReadable') ->with(self::RELATIVE_FILE_PATH) ->willReturn(false); - $this->assertSame($this->responseMock, $this->mediaModel->launch()); + + self::assertSame($this->responseMock, $this->mediaModel->launch()); } /** @@ -204,7 +190,7 @@ public function testProcessRequestReturnsNotFoundIfFileIsNotSynchronized() * * @dataProvider catchExceptionDataProvider */ - public function testCatchException($isDeveloper, $setBodyCalls) + public function testCatchException(bool $isDeveloper, int $setBodyCalls): void { /** @var Bootstrap|MockObject $bootstrap */ $bootstrap = $this->createMock(Bootstrap::class); @@ -212,41 +198,39 @@ public function testCatchException($isDeveloper, $setBodyCalls) /** @var Exception|MockObject $exception */ $exception = $this->createMock(Exception::class); - $this->responseMock->expects($this->once()) + $this->responseMock->expects(self::once()) ->method('setHttpResponseCode') ->with(404); - $bootstrap->expects($this->once()) + $bootstrap->expects(self::once()) ->method('isDeveloperMode') ->willReturn($isDeveloper); - $this->responseMock->expects($this->exactly($setBodyCalls)) + $this->responseMock->expects(self::exactly($setBodyCalls)) ->method('setBody'); - $this->responseMock->expects($this->once()) + $this->responseMock->expects(self::once()) ->method('sendResponse'); - $this->mediaModel = $this->getMediaModel(); - - $this->mediaModel->catchException($bootstrap, $exception); + $this->createMediaModel()->catchException($bootstrap, $exception); } - public function testExceptionWhenIsAllowedReturnsFalse() + public function testExceptionWhenIsAllowedReturnsFalse(): void { - $this->mediaModel = $this->getMediaModel(false); - $this->directoryMediaMock->expects($this->once()) + $this->directoryMediaMock->expects(self::once()) ->method('getAbsolutePath') ->with(null) ->willReturn(self::MEDIA_DIRECTORY); - $this->configMock->expects($this->once())->method('save'); + $this->configMock->expects(self::once()) + ->method('save'); $this->expectException(LogicException::class); $this->expectExceptionMessage('The path is not allowed: ' . self::RELATIVE_FILE_PATH); - $this->mediaModel->launch(); + $this->createMediaModel(false)->launch(); } /** * @return array */ - public function catchExceptionDataProvider() + public function catchExceptionDataProvider(): array { return [ 'default mode' => [false, 0], @@ -260,35 +244,30 @@ public function catchExceptionDataProvider() * @param bool $isAllowed * @return Media */ - protected function getMediaModel(bool $isAllowed = true): Media + protected function createMediaModel(bool $isAllowed = true): Media { - $objectManager = new ObjectManager($this); - $isAllowedCallback = function () use ($isAllowed) { return $isAllowed; }; - /** @var Media $mediaClass */ - $mediaClass = $objectManager->getObject( - Media::class, - [ - 'configFactory' => $this->configFactoryMock, - 'syncFactory' => $this->syncFactoryMock, - 'response' => $this->responseMock, - 'isAllowed' => $isAllowedCallback, - 'mediaDirectory' => false, - 'configCacheFile' => self::CACHE_FILE_PATH, - 'relativeFileName' => self::RELATIVE_FILE_PATH, - 'filesystem' => $this->filesystemMock, - 'placeholderFactory' => $this->createConfiguredMock( - PlaceholderFactory::class, - [ - 'create' => $this->createMock(Placeholder::class) - ] - ), - ] - ); + $placeholderFactory = $this->createMock(PlaceholderFactory::class); + $placeholderFactory->method('create') + ->willReturn($this->createMock(Placeholder::class)); - return $mediaClass; + return new Media( + $this->configFactoryMock, + $this->syncFactoryMock, + $this->responseMock, + $isAllowedCallback, + false, + self::CACHE_FILE_PATH, + self::RELATIVE_FILE_PATH, + $this->filesystemMock, + $placeholderFactory, + $this->createMock(State::class), + $this->createMock(ImageResize::class), + $this->createMock(Filesystem\Driver\File::class), + $this->createMock(CatalogMediaConfig::class) + ); } } diff --git a/app/code/Magento/MessageQueue/Model/Cron/ConsumersRunner.php b/app/code/Magento/MessageQueue/Model/Cron/ConsumersRunner.php index fd61f96b300d6..472d1cc631290 100644 --- a/app/code/Magento/MessageQueue/Model/Cron/ConsumersRunner.php +++ b/app/code/Magento/MessageQueue/Model/Cron/ConsumersRunner.php @@ -131,7 +131,7 @@ public function run() ]; if ($maxMessages) { - $arguments[] = '--max-messages=' . $maxMessages; + $arguments[] = '--max-messages=' . min($consumer->getMaxMessages() ?? $maxMessages, $maxMessages); } $command = $php . ' ' . BP . '/bin/magento queue:consumers:start %s %s' diff --git a/app/code/Magento/Multishipping/Block/Checkout/Overview.php b/app/code/Magento/Multishipping/Block/Checkout/Overview.php index 1ea2dc2618778..e4d2efd50de5a 100644 --- a/app/code/Magento/Multishipping/Block/Checkout/Overview.php +++ b/app/code/Magento/Multishipping/Block/Checkout/Overview.php @@ -6,10 +6,14 @@ namespace Magento\Multishipping\Block\Checkout; +use Magento\Captcha\Block\Captcha; +use Magento\Checkout\Model\CaptchaPaymentProcessingRateLimiter; use Magento\Framework\Pricing\PriceCurrencyInterface; use Magento\Quote\Model\Quote\Address; use Magento\Checkout\Helper\Data as CheckoutHelper; use Magento\Framework\App\ObjectManager; +use Magento\Quote\Model\Quote\Address\Total\Collector; +use Magento\Store\Model\ScopeInterface; /** * Multishipping checkout overview information @@ -123,6 +127,20 @@ protected function _prepareLayout() $this->pageConfig->getTitle()->set( __('Review Order - %1', $this->pageConfig->getTitle()->getDefault()) ); + if (!$this->getChildBlock('captcha')) { + $this->addChild( + 'captcha', + Captcha::class, + [ + 'cacheable' => false, + 'after' => '-', + 'form_id' => CaptchaPaymentProcessingRateLimiter::CAPTCHA_FORM, + 'image_width' => 230, + 'image_height' => 230 + ] + ); + } + return parent::_prepareLayout(); } @@ -429,9 +447,12 @@ public function getBillingAddressTotals() */ public function renderTotals($totals, $colspan = null) { - //check if the shipment is multi shipment + // check if the shipment is multi shipment $totals = $this->getMultishippingTotals($totals); + // sort totals by configuration settings + $totals = $this->sortTotals($totals); + if ($colspan === null) { $colspan = 3; } @@ -481,4 +502,38 @@ protected function _getRowItemRenderer($type) } return $renderer; } + + /** + * Sort total information based on configuration settings. + * + * @param array $totals + * @return array + */ + private function sortTotals($totals): array + { + $sortedTotals = []; + $sorts = $this->_scopeConfig->getValue( + Collector::XML_PATH_SALES_TOTALS_SORT, + ScopeInterface::SCOPE_STORES + ); + + $sorted = []; + foreach ($sorts as $code => $sortOrder) { + $sorted[$sortOrder] = $code; + } + ksort($sorted); + + foreach ($sorted as $code) { + if (isset($totals[$code])) { + $sortedTotals[$code] = $totals[$code]; + } + } + + $notSorted = array_diff(array_keys($totals), array_keys($sortedTotals)); + foreach ($notSorted as $code) { + $sortedTotals[$code] = $totals[$code]; + } + + return $sortedTotals; + } } diff --git a/app/code/Magento/Multishipping/Controller/Checkout/OverviewPost.php b/app/code/Magento/Multishipping/Controller/Checkout/OverviewPost.php index 762b0f5cca59c..f4dfd60d81980 100644 --- a/app/code/Magento/Multishipping/Controller/Checkout/OverviewPost.php +++ b/app/code/Magento/Multishipping/Controller/Checkout/OverviewPost.php @@ -10,6 +10,8 @@ use Magento\Checkout\Api\AgreementsValidatorInterface; use Magento\Customer\Api\AccountManagementInterface; use Magento\Customer\Api\CustomerRepositoryInterface; +use Magento\Checkout\Api\PaymentProcessingRateLimiterInterface; +use Magento\Framework\App\ObjectManager; use Magento\Customer\Model\Session; use Magento\Framework\App\Action\Context; use Magento\Framework\App\Action\HttpPostActionInterface; @@ -21,12 +23,15 @@ use Psr\Log\LoggerInterface; /** + * Placing orders. + * * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class OverviewPost extends Checkout implements HttpPostActionInterface { /** * @var Validator + * @deprecated Form key validation is handled on the framework level. */ protected $formKeyValidator; @@ -45,6 +50,11 @@ class OverviewPost extends Checkout implements HttpPostActionInterface */ private $session; + /** + * @var PaymentProcessingRateLimiterInterface + */ + private $paymentRateLimiter; + /** * @param Context $context * @param Session $customerSession @@ -54,6 +64,7 @@ class OverviewPost extends Checkout implements HttpPostActionInterface * @param LoggerInterface $logger * @param AgreementsValidatorInterface $agreementValidator * @param SessionManagerInterface $session + * @param PaymentProcessingRateLimiterInterface|null $paymentRateLimiter */ public function __construct( Context $context, @@ -63,12 +74,15 @@ public function __construct( Validator $formKeyValidator, LoggerInterface $logger, AgreementsValidatorInterface $agreementValidator, - SessionManagerInterface $session + SessionManagerInterface $session, + ?PaymentProcessingRateLimiterInterface $paymentRateLimiter = null ) { $this->formKeyValidator = $formKeyValidator; $this->logger = $logger; $this->agreementsValidator = $agreementValidator; $this->session = $session; + $this->paymentRateLimiter = $paymentRateLimiter + ?? ObjectManager::getInstance()->get(PaymentProcessingRateLimiterInterface::class); parent::__construct( $context, @@ -86,15 +100,12 @@ public function __construct( */ public function execute() { - if (!$this->formKeyValidator->validate($this->getRequest())) { - $this->_forward('backToAddresses'); - return; - } - if (!$this->_validateMinimumAmount()) { - return; - } - try { + $this->paymentRateLimiter->limit(); + if (!$this->_validateMinimumAmount()) { + return; + } + if (!$this->agreementsValidator->isValid(array_keys($this->getRequest()->getPost('agreement', [])))) { $this->messageManager->addErrorMessage( __('Please agree to all Terms and Conditions before placing the order.') diff --git a/app/code/Magento/Multishipping/Model/DisableMultishipping.php b/app/code/Magento/Multishipping/Model/DisableMultishipping.php new file mode 100644 index 0000000000000..a871cee715538 --- /dev/null +++ b/app/code/Magento/Multishipping/Model/DisableMultishipping.php @@ -0,0 +1,38 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Multishipping\Model; + +use Magento\Quote\Api\Data\CartInterface; + +/** + * Turn Off Multishipping mode if enabled. + */ +class DisableMultishipping +{ + /** + * Disable Multishipping mode for provided Quote and return TRUE if it was changed. + * + * @param CartInterface $quote + * @return bool + */ + public function execute(CartInterface $quote): bool + { + $modeChanged = false; + if ($quote->getIsMultiShipping()) { + $quote->setIsMultiShipping(0); + $extensionAttributes = $quote->getExtensionAttributes(); + if ($extensionAttributes && $extensionAttributes->getShippingAssignments()) { + $extensionAttributes->setShippingAssignments([]); + } + + $modeChanged = true; + } + + return $modeChanged; + } +} diff --git a/app/code/Magento/Multishipping/Observer/DisableMultishippingObserver.php b/app/code/Magento/Multishipping/Observer/DisableMultishippingObserver.php new file mode 100644 index 0000000000000..a72bce87965a4 --- /dev/null +++ b/app/code/Magento/Multishipping/Observer/DisableMultishippingObserver.php @@ -0,0 +1,46 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Multishipping\Observer; + +use Magento\Framework\Event\ObserverInterface; +use Magento\Framework\Event\Observer as EventObserver; +use Magento\Multishipping\Model\DisableMultishipping; +use Magento\Quote\Api\Data\CartInterface; + +/** + * Observer for disabling Multishipping mode. + */ +class DisableMultishippingObserver implements ObserverInterface +{ + /** + * @var DisableMultishipping + */ + private $disableMultishipping; + + /** + * @param DisableMultishipping $disableMultishipping + */ + public function __construct( + DisableMultishipping $disableMultishipping + ) { + $this->disableMultishipping = $disableMultishipping; + } + + /** + * Disable Multishipping mode before saving Quote. + * + * @param EventObserver $observer + * @return void + */ + public function execute(EventObserver $observer): void + { + /** @var CartInterface $quote */ + $quote = $observer->getEvent()->getCart()->getQuote(); + $this->disableMultishipping->execute($quote); + } +} diff --git a/app/code/Magento/Multishipping/Plugin/DisableMultishippingMode.php b/app/code/Magento/Multishipping/Plugin/DisableMultishippingMode.php index fff2346d76240..f4e6928173f60 100644 --- a/app/code/Magento/Multishipping/Plugin/DisableMultishippingMode.php +++ b/app/code/Magento/Multishipping/Plugin/DisableMultishippingMode.php @@ -9,6 +9,7 @@ use Magento\Checkout\Model\Cart; use Magento\Framework\App\Action\Action; +use Magento\Multishipping\Model\DisableMultishipping; /** * Turns Off Multishipping mode for Quote. @@ -20,13 +21,21 @@ class DisableMultishippingMode */ private $cart; + /** + * @var DisableMultishipping + */ + private $disableMultishipping; + /** * @param Cart $cart + * @param DisableMultishipping $disableMultishipping */ public function __construct( - Cart $cart + Cart $cart, + DisableMultishipping $disableMultishipping ) { $this->cart = $cart; + $this->disableMultishipping = $disableMultishipping; } /** @@ -36,16 +45,16 @@ public function __construct( * @return void * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ - public function beforeExecute(Action $subject) + public function beforeExecute(Action $subject): void { $quote = $this->cart->getQuote(); - if ($quote->getIsMultiShipping()) { - $quote->setIsMultiShipping(0); - $extensionAttributes = $quote->getExtensionAttributes(); - if ($extensionAttributes && $extensionAttributes->getShippingAssignments()) { - $extensionAttributes->setShippingAssignments([]); - } + $modChanged = $this->disableMultishipping->execute($quote); + if ($modChanged) { + $totalsCollectedBefore = $quote->getTotalsCollectedFlag(); $this->cart->saveQuote(); + if (!$totalsCollectedBefore) { + $quote->setTotalsCollectedFlag(false); + } } } } diff --git a/app/code/Magento/Multishipping/Test/Mftf/Section/MultishippingSection/MultishippingSection.xml b/app/code/Magento/Multishipping/Test/Mftf/Section/MultishippingSection/MultishippingSection.xml index 9c89ffa3cd405..1b2f0c31b2cf3 100644 --- a/app/code/Magento/Multishipping/Test/Mftf/Section/MultishippingSection/MultishippingSection.xml +++ b/app/code/Magento/Multishipping/Test/Mftf/Section/MultishippingSection/MultishippingSection.xml @@ -15,5 +15,6 @@ <element name="shippingAddressOptions" type="select" selector="#multiship-addresses-table tbody tr:nth-of-type({{addressPosition}}) .col.address select option:nth-of-type({{optionIndex}})" parameterized="true"/> <element name="selectShippingAddress" type="select" selector="(//table[@id='multiship-addresses-table'] //div[@class='field address'] //select)[{{sequenceNumber}}]" parameterized="true"/> <element name="removeItemButton" type="button" selector="//a[contains(@title, 'Remove Item')][{{var}}]" parameterized="true"/> + <element name="back" type="button" selector=".action.back"/> </section> </sections> diff --git a/app/code/Magento/Multishipping/Test/Mftf/Test/StorefrontCheckoutSubtotalAfterQuantityUpdateTest.xml b/app/code/Magento/Multishipping/Test/Mftf/Test/StorefrontCheckoutSubtotalAfterQuantityUpdateTest.xml new file mode 100644 index 0000000000000..31ea8c500e867 --- /dev/null +++ b/app/code/Magento/Multishipping/Test/Mftf/Test/StorefrontCheckoutSubtotalAfterQuantityUpdateTest.xml @@ -0,0 +1,65 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="StorefrontCheckoutSubtotalAfterQuantityUpdateTest"> + <annotations> + <features value="Multishipping"/> + <stories value="Multiple Shipping"/> + <title value="Check subtotals after products quantity updated"/> + <description value="Check cart subtotals updates after product quantity updates"/> + <severity value="MAJOR"/> + <testCaseId value="MC-38994"/> + <group value="Multishipment"/> + </annotations> + <before> + <createData entity="SimpleSubCategory" stepKey="createCategory"/> + <createData entity="SimpleProduct" stepKey="createdSimpleProduct"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="Customer_US_UK_DE" stepKey="createCustomerWithMultipleAddresses"/> + </before> + <after> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> + <deleteData createDataKey="createdSimpleProduct" stepKey="deleteCreatedSimpleProduct"/> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + <deleteData createDataKey="createCustomerWithMultipleAddresses" stepKey="deleteCustomer"/> + </after> + <!-- Login to the Storefront as created customer --> + <actionGroup ref="LoginToStorefrontActionGroup" stepKey="loginAsCustomer"> + <argument name="Customer" value="$$createCustomerWithMultipleAddresses$$"/> + </actionGroup> + <!-- Open the simple product page --> + <actionGroup ref="StorefrontOpenProductPageActionGroup" stepKey="goToCreatedSimpleProductPage"> + <argument name="productUrl" value="$$createdSimpleProduct.custom_attributes[url_key]$$"/> + </actionGroup> + <!-- Add the simple product to the Shopping Cart --> + <actionGroup ref="AddProductWithQtyToCartFromStorefrontProductPageActionGroup" stepKey="addCreatedSimpleProductToCart"> + <argument name="productName" value="$$createdSimpleProduct.name$$"/> + <argument name="productQty" value="1"/> + </actionGroup> + <!-- Go to Cart --> + <actionGroup ref="StorefrontOpenCartFromMinicartActionGroup" stepKey="openCart"/> + <!-- Check Out with Multiple Addresses --> + <actionGroup ref="StorefrontCheckoutWithMultipleAddressesActionGroup" stepKey="checkoutWithMultipleAddresses"/> + <!-- Go back to the cart --> + <click selector="{{MultishippingSection.back}}" stepKey="backToCart"/> + <!-- Update products quantity --> + <fillField selector="{{CheckoutCartProductSection.qty($createdSimpleProduct.name$)}}" userInput="2" stepKey="updateProductQty"/> + <click selector="{{CheckoutCartProductSection.updateShoppingCartButton}}" stepKey="clickUpdateShoppingCart"/> + <waitForAjaxLoad stepKey="waitForAjaxLoad"/> + <!-- Check subtotals --> + <grabTextFrom selector="{{CheckoutCartProductSection.productSubtotalByName($$createdSimpleProduct.name$$)}}" stepKey="grabTextFromProductsSubtotalField"/> + <grabTextFrom selector="{{CheckoutCartSummarySection.subTotal}}" stepKey="grabTextFromCartSubtotalField"/> + <assertEquals message="Subtotals should be equal" stepKey="assertSubtotalsFields"> + <expectedResult type="variable">$grabTextFromProductsSubtotalField</expectedResult> + <actualResult type="variable">$grabTextFromCartSubtotalField</actualResult> + </assertEquals> + </test> +</tests> diff --git a/app/code/Magento/Multishipping/Test/Unit/Block/Checkout/OverviewTest.php b/app/code/Magento/Multishipping/Test/Unit/Block/Checkout/OverviewTest.php index 7da77030f308a..2d044afd32c70 100644 --- a/app/code/Magento/Multishipping/Test/Unit/Block/Checkout/OverviewTest.php +++ b/app/code/Magento/Multishipping/Test/Unit/Block/Checkout/OverviewTest.php @@ -8,6 +8,7 @@ namespace Magento\Multishipping\Test\Unit\Block\Checkout; +use Magento\Framework\App\Config\ScopeConfigInterface; use Magento\Framework\Pricing\PriceCurrencyInterface; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; use Magento\Framework\UrlInterface; @@ -67,6 +68,11 @@ class OverviewTest extends TestCase */ private $urlBuilderMock; + /** + * @var MockObject + */ + private $scopeConfigMock; + protected function setUp(): void { $objectManager = new ObjectManager($this); @@ -85,6 +91,7 @@ protected function setUp(): void $this->createMock(Multishipping::class); $this->quoteMock = $this->createMock(Quote::class); $this->urlBuilderMock = $this->getMockForAbstractClass(UrlInterface::class); + $this->scopeConfigMock = $this->getMockForAbstractClass(ScopeConfigInterface::class); $this->model = $objectManager->getObject( Overview::class, [ @@ -92,7 +99,8 @@ protected function setUp(): void 'totalsCollector' => $this->totalsCollectorMock, 'totalsReader' => $this->totalsReaderMock, 'multishipping' => $this->checkoutMock, - 'urlBuilder' => $this->urlBuilderMock + 'urlBuilder' => $this->urlBuilderMock, + '_scopeConfig' => $this->scopeConfigMock ] ); } @@ -187,4 +195,44 @@ public function testGetVirtualProductEditUrl() $this->urlBuilderMock->expects($this->once())->method('getUrl')->with('checkout/cart', [])->willReturn($url); $this->assertEquals($url, $this->model->getVirtualProductEditUrl()); } + + /** + * Test sort total information + * + * @return void + */ + public function testSortCollectors(): void + { + $sorts = [ + 'discount' => 40, + 'subtotal' => 10, + 'tax' => 20, + 'shipping' => 30, + ]; + + $this->scopeConfigMock->method('getValue') + ->with('sales/totals_sort', 'stores') + ->willReturn($sorts); + + $totalsNotSorted = [ + 'subtotal' => [], + 'shipping' => [], + 'tax' => [], + ]; + + $totalsExpected = [ + 'subtotal' => [], + 'tax' => [], + 'shipping' => [], + ]; + + $method = new \ReflectionMethod($this->model, 'sortTotals'); + $method->setAccessible(true); + $result = $method->invoke($this->model, $totalsNotSorted); + + $this->assertEquals( + $totalsExpected, + $result + ); + } } diff --git a/app/code/Magento/Multishipping/Test/Unit/Model/DisableMultishippingTest.php b/app/code/Magento/Multishipping/Test/Unit/Model/DisableMultishippingTest.php new file mode 100644 index 0000000000000..9882f8d1441aa --- /dev/null +++ b/app/code/Magento/Multishipping/Test/Unit/Model/DisableMultishippingTest.php @@ -0,0 +1,116 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Multishipping\Test\Unit\Model; + +use Magento\Multishipping\Model\DisableMultishipping; +use Magento\Quote\Api\Data\CartExtensionInterface; +use Magento\Quote\Api\Data\CartInterface; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * 'Disable Multishipping' model unit tests. + */ +class DisableMultishippingTest extends TestCase +{ + /** + * @var CartInterface|MockObject + */ + private $quoteMock; + + /** + * @var DisableMultishipping + */ + private $disableMultishippingModel; + + /** + * @inheritdoc + */ + protected function setUp(): void + { + $this->quoteMock = $this->getMockBuilder(CartInterface::class) + ->addMethods(['getIsMultiShipping', 'setIsMultiShipping']) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $this->disableMultishippingModel = new DisableMultishipping(); + } + + /** + * Test 'execute' method if 'MultiShipping' mode is enabled. + * + * @param bool $hasShippingAssignments + * @return void + * @dataProvider executeWithMultishippingModeEnabledDataProvider + */ + public function testExecuteWithMultishippingModeEnabled(bool $hasShippingAssignments): void + { + $shippingAssignments = $hasShippingAssignments ? ['example_shipping_assigment'] : null; + + $this->quoteMock->expects($this->once()) + ->method('getIsMultiShipping') + ->willReturn(true); + + $this->quoteMock->expects($this->once()) + ->method('setIsMultiShipping') + ->with(0); + + /** @var CartExtensionInterface|MockObject $extensionAttributesMock */ + $extensionAttributesMock = $this->getMockBuilder(CartExtensionInterface::class) + ->addMethods(['getShippingAssignments', 'setShippingAssignments']) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + + $extensionAttributesMock->expects($this->once()) + ->method('getShippingAssignments') + ->willReturn($shippingAssignments); + + $extensionAttributesMock->expects($hasShippingAssignments ? $this->once() : $this->never()) + ->method('setShippingAssignments') + ->with([]) + ->willReturnSelf(); + + $this->quoteMock->expects($this->once()) + ->method('getExtensionAttributes') + ->willReturn($extensionAttributesMock); + + $this->assertTrue($this->disableMultishippingModel->execute($this->quoteMock)); + } + + /** + * DataProvider for testExecuteWithMultishippingModeEnabled(). + * + * @return array + */ + public function executeWithMultishippingModeEnabledDataProvider(): array + { + return [ + 'check_with_shipping_assignments' => [true], + 'check_without_shipping_assignments' => [false] + ]; + } + + /** + * Test 'execute' method if 'Multishipping' mode is disabled. + * + * @return void + */ + public function testExecuteWithMultishippingModeDisabled(): void + { + $this->quoteMock->expects($this->once()) + ->method('getIsMultiShipping') + ->willReturn(false); + + $this->quoteMock->expects($this->never()) + ->method('setIsMultiShipping'); + + $this->quoteMock->expects($this->never()) + ->method('getExtensionAttributes'); + + $this->assertFalse($this->disableMultishippingModel->execute($this->quoteMock)); + } +} diff --git a/app/code/Magento/Multishipping/Test/Unit/Plugin/DisableMultishippingModeTest.php b/app/code/Magento/Multishipping/Test/Unit/Plugin/DisableMultishippingModeTest.php index fb16bd251706c..64cbcbf147d48 100644 --- a/app/code/Magento/Multishipping/Test/Unit/Plugin/DisableMultishippingModeTest.php +++ b/app/code/Magento/Multishipping/Test/Unit/Plugin/DisableMultishippingModeTest.php @@ -10,10 +10,9 @@ use Magento\Checkout\Controller\Index\Index; use Magento\Checkout\Model\Cart; +use Magento\Multishipping\Model\DisableMultishipping as DisableMultishippingModel; use Magento\Multishipping\Plugin\DisableMultishippingMode; -use Magento\Quote\Api\Data\CartExtensionInterface; -use Magento\Quote\Api\Data\ShippingAssignmentInterface; -use Magento\Quote\Model\Quote; +use Magento\Quote\Api\Data\CartInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -23,15 +22,20 @@ class DisableMultishippingModeTest extends TestCase { /** - * @var MockObject + * @var Cart|MockObject */ private $cartMock; /** - * @var MockObject + * @var CartInterface|MockObject */ private $quoteMock; + /** + * @var DisableMultishippingModel|MockObject + */ + private $disableMultishippingMock; + /** * @var DisableMultishippingMode */ @@ -43,43 +47,40 @@ class DisableMultishippingModeTest extends TestCase protected function setUp(): void { $this->cartMock = $this->createMock(Cart::class); - $this->quoteMock = $this->getMockBuilder(Quote::class) - ->addMethods(['setIsMultiShipping', 'getIsMultiShipping']) - ->onlyMethods(['__wakeUp', 'getExtensionAttributes']) + $this->quoteMock = $this->getMockBuilder(CartInterface::class) + ->addMethods(['setTotalsCollectedFlag', 'getTotalsCollectedFlag']) ->disableOriginalConstructor() - ->getMock(); + ->getMockForAbstractClass(); $this->cartMock->expects($this->once()) ->method('getQuote') ->willReturn($this->quoteMock); - $this->object = new DisableMultishippingMode($this->cartMock); + $this->disableMultishippingMock = $this->createMock(DisableMultishippingModel::class); + $this->object = new DisableMultishippingMode( + $this->cartMock, + $this->disableMultishippingMock + ); } /** - * Tests turn off multishipping on multishipping quote. + * Test 'Disable Multishipping' plugin if 'Multishipping' mode is changed. * + * @param bool $totalsCollectedBefore * @return void + * @dataProvider pluginWithChangedMultishippingModeDataProvider */ - public function testExecuteTurnsOffMultishippingModeOnMultishippingQuote(): void + public function testPluginWithChangedMultishippingMode(bool $totalsCollectedBefore): void { $subject = $this->createMock(Index::class); - $extensionAttributes = $this->getMockBuilder(CartExtensionInterface::class) - ->disableOriginalConstructor() - ->setMethods(['setShippingAssignments', 'getShippingAssignments']) - ->getMockForAbstractClass(); - $extensionAttributes->method('getShippingAssignments') - ->willReturn( - $this->getMockForAbstractClass(ShippingAssignmentInterface::class) - ); - $extensionAttributes->expects($this->once()) - ->method('setShippingAssignments') - ->with([]); - $this->quoteMock->method('getExtensionAttributes') - ->willReturn($extensionAttributes); + $this->disableMultishippingMock->expects($this->once()) + ->method('execute') + ->with($this->quoteMock) + ->willReturn(true); $this->quoteMock->expects($this->once()) - ->method('getIsMultiShipping')->willReturn(1); - $this->quoteMock->expects($this->once()) - ->method('setIsMultiShipping') - ->with(0); + ->method('getTotalsCollectedFlag') + ->willReturn($totalsCollectedBefore); + $this->quoteMock->expects($totalsCollectedBefore ? $this->never() : $this->once()) + ->method('setTotalsCollectedFlag') + ->with(false); $this->cartMock->expects($this->once()) ->method('saveQuote'); @@ -87,16 +88,37 @@ public function testExecuteTurnsOffMultishippingModeOnMultishippingQuote(): void } /** - * Tests turn off multishipping on non-multishipping quote. + * DataProvider for testPluginWithChangedMultishippingMode(). + * + * @return array + */ + public function pluginWithChangedMultishippingModeDataProvider(): array + { + return [ + 'check_when_totals_are_collected' => [true], + 'check_when_totals_are_not_collected' => [false] + ]; + } + + /** + * Test 'Disable Multishipping' plugin if 'Multishipping' mode is NOT changed. * * @return void */ - public function testExecuteTurnsOffMultishippingModeOnNotMultishippingQuote(): void + public function testPluginWithNotChangedMultishippingMode(): void { $subject = $this->createMock(Index::class); - $this->quoteMock->expects($this->once())->method('getIsMultiShipping')->willReturn(0); - $this->quoteMock->expects($this->never())->method('setIsMultiShipping'); - $this->cartMock->expects($this->never())->method('saveQuote'); + $this->disableMultishippingMock->expects($this->once()) + ->method('execute') + ->with($this->quoteMock) + ->willReturn(false); + $this->quoteMock->expects($this->never()) + ->method('getTotalsCollectedFlag'); + $this->quoteMock->expects($this->never()) + ->method('setTotalsCollectedFlag'); + $this->cartMock->expects($this->never()) + ->method('saveQuote'); + $this->object->beforeExecute($subject); } } diff --git a/app/code/Magento/Multishipping/composer.json b/app/code/Magento/Multishipping/composer.json index 85f60985fe1b0..8834603562332 100644 --- a/app/code/Magento/Multishipping/composer.json +++ b/app/code/Magento/Multishipping/composer.json @@ -15,7 +15,8 @@ "magento/module-sales": "*", "magento/module-store": "*", "magento/module-tax": "*", - "magento/module-theme": "*" + "magento/module-theme": "*", + "magento/module-captcha": "*" }, "type": "magento2-module", "license": [ diff --git a/app/code/Magento/Multishipping/etc/frontend/di.xml b/app/code/Magento/Multishipping/etc/frontend/di.xml index 481b95280a4a4..c4fc42e92a2c6 100644 --- a/app/code/Magento/Multishipping/etc/frontend/di.xml +++ b/app/code/Magento/Multishipping/etc/frontend/di.xml @@ -48,4 +48,7 @@ <type name="Magento\Quote\Model\Quote"> <plugin name="multishipping_reset_shipping_assigment" type="Magento\Multishipping\Plugin\ResetShippingAssigment"/> </type> + <type name="Magento\Checkout\Controller\Cart\UpdateItemQty"> + <plugin name="multishipping_disabler" type="Magento\Multishipping\Plugin\DisableMultishippingMode" sortOrder="10" /> + </type> </config> diff --git a/app/code/Magento/Multishipping/etc/frontend/events.xml b/app/code/Magento/Multishipping/etc/frontend/events.xml new file mode 100644 index 0000000000000..219e358528ca2 --- /dev/null +++ b/app/code/Magento/Multishipping/etc/frontend/events.xml @@ -0,0 +1,12 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd"> + <event name="checkout_cart_save_before"> + <observer name="magento_multishipping_disabler" instance="Magento\Multishipping\Observer\DisableMultishippingObserver"/> + </event> +</config> diff --git a/app/code/Magento/Multishipping/view/frontend/templates/checkout/overview.phtml b/app/code/Magento/Multishipping/view/frontend/templates/checkout/overview.phtml index 3b72679bfc34e..35032b874374d 100644 --- a/app/code/Magento/Multishipping/view/frontend/templates/checkout/overview.phtml +++ b/app/code/Magento/Multishipping/view/frontend/templates/checkout/overview.phtml @@ -213,6 +213,7 @@ $checkoutHelper = $block->getData('checkoutHelper'); </div> <div class="actions-toolbar" id="review-buttons-container"> <div class="primary"> + <?= $block->getChildHtml('captcha') ?> <button type="submit" class="action primary submit" id="review-button"><span><?= $block->escapeHtml(__('Place Order')); ?></span> diff --git a/app/code/Magento/NewRelicReporting/Test/Unit/Model/Cron/ReportNewRelicCronTest.php b/app/code/Magento/NewRelicReporting/Test/Unit/Model/Cron/ReportNewRelicCronTest.php index 2a86a9d3018dd..b7ab4bda8da53 100644 --- a/app/code/Magento/NewRelicReporting/Test/Unit/Model/Cron/ReportNewRelicCronTest.php +++ b/app/code/Magento/NewRelicReporting/Test/Unit/Model/Cron/ReportNewRelicCronTest.php @@ -17,7 +17,6 @@ use Magento\NewRelicReporting\Model\Module\Collect; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Psr\Log\LoggerInterface; class ReportNewRelicCronTest extends TestCase { @@ -61,11 +60,6 @@ class ReportNewRelicCronTest extends TestCase */ protected $deploymentsModel; - /** - * @var LoggerInterface|MockObject - */ - private $logger; - /** * Setup * @@ -117,15 +111,13 @@ protected function setUp(): void $this->deploymentsFactory->expects($this->any()) ->method('create') ->willReturn($this->deploymentsModel); - $this->logger = $this->getMockForAbstractClass(LoggerInterface::class); $this->model = new ReportNewRelicCron( $this->config, $this->collect, $this->counter, $this->cronEventFactory, - $this->deploymentsFactory, - $this->logger + $this->deploymentsFactory ); } @@ -215,7 +207,6 @@ public function testReportNewRelicCronRequestFailed() ->method('sendRequest'); $this->cronEventModel->expects($this->once())->method('sendRequest')->willThrowException(new \Exception()); - $this->logger->expects($this->never())->method('critical'); $this->deploymentsModel->expects($this->any()) ->method('setDeployment'); diff --git a/app/code/Magento/Newsletter/Block/Adminhtml/Queue/Preview.php b/app/code/Magento/Newsletter/Block/Adminhtml/Queue/Preview.php index 69512775f4e93..2ede548b5ebd8 100644 --- a/app/code/Magento/Newsletter/Block/Adminhtml/Queue/Preview.php +++ b/app/code/Magento/Newsletter/Block/Adminhtml/Queue/Preview.php @@ -56,6 +56,7 @@ protected function loadTemplate(\Magento\Newsletter\Model\Template $template, $i $template->setTemplateType($queue->getNewsletterType()); $template->setTemplateText($queue->getNewsletterText()); $template->setTemplateStyles($queue->getNewsletterStyles()); + $template->setData('is_legacy', false); return $this; } diff --git a/app/code/Magento/Newsletter/Block/Adminhtml/Template/Preview.php b/app/code/Magento/Newsletter/Block/Adminhtml/Template/Preview.php index 58a904248e978..1eb1c0ff4a15a 100644 --- a/app/code/Magento/Newsletter/Block/Adminhtml/Template/Preview.php +++ b/app/code/Magento/Newsletter/Block/Adminhtml/Template/Preview.php @@ -65,6 +65,7 @@ protected function _toHtml() $template->setTemplateType($previewData['type']); $template->setTemplateText($previewData['text']); $template->setTemplateStyles($previewData['styles']); + $template->setData('is_legacy', false); } \Magento\Framework\Profiler::start($this->profilerName); diff --git a/app/code/Magento/Newsletter/Model/ResourceModel/Subscriber/Grid/Collection.php b/app/code/Magento/Newsletter/Model/ResourceModel/Subscriber/Grid/Collection.php index e16effb8c7e12..d0da06553a5f3 100644 --- a/app/code/Magento/Newsletter/Model/ResourceModel/Subscriber/Grid/Collection.php +++ b/app/code/Magento/Newsletter/Model/ResourceModel/Subscriber/Grid/Collection.php @@ -17,7 +17,8 @@ class Collection extends \Magento\Newsletter\Model\ResourceModel\Subscriber\Coll protected function _initSelect() { parent::_initSelect(); - $this->showCustomerInfo(true)->addSubscriberTypeField()->showStoreInfo(); + $this->showCustomerInfo()->addSubscriberTypeField()->showStoreInfo(); + return $this; } } diff --git a/app/code/Magento/Newsletter/view/adminhtml/templates/preview/iframeswitcher.phtml b/app/code/Magento/Newsletter/view/adminhtml/templates/preview/iframeswitcher.phtml index 62b368b8911f8..99342fd9d81ba 100644 --- a/app/code/Magento/Newsletter/view/adminhtml/templates/preview/iframeswitcher.phtml +++ b/app/code/Magento/Newsletter/view/adminhtml/templates/preview/iframeswitcher.phtml @@ -22,7 +22,7 @@ frameborder="0" title="<?= $block->escapeHtmlAttr(__('Preview')) ?>" width="100%" - sandbox="allow-forms allow-pointer-lock" + sandbox="allow-forms allow-pointer-lock allow-same-origin" > </iframe> diff --git a/app/code/Magento/OfflinePayments/Model/Purchaseorder.php b/app/code/Magento/OfflinePayments/Model/Purchaseorder.php index fe30570aba50d..21790f3ac20bb 100644 --- a/app/code/Magento/OfflinePayments/Model/Purchaseorder.php +++ b/app/code/Magento/OfflinePayments/Model/Purchaseorder.php @@ -10,6 +10,7 @@ /** * Class Purchaseorder * + * Update additional payments fields and validate the payment data * @method \Magento\Quote\Api\Data\PaymentMethodExtensionInterface getExtensionAttributes() * * @api @@ -68,10 +69,6 @@ public function validate() { parent::validate(); - if (empty($this->getInfoInstance()->getPoNumber())) { - throw new LocalizedException(__('Purchase order number is a required field.')); - } - return $this; } } diff --git a/app/code/Magento/OfflinePayments/Plugin/ValidatePurchaseOrderNumber.php b/app/code/Magento/OfflinePayments/Plugin/ValidatePurchaseOrderNumber.php new file mode 100644 index 0000000000000..18e80864f434b --- /dev/null +++ b/app/code/Magento/OfflinePayments/Plugin/ValidatePurchaseOrderNumber.php @@ -0,0 +1,43 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\OfflinePayments\Plugin; + +use Magento\Framework\Exception\LocalizedException; +use Magento\OfflinePayments\Model\Purchaseorder; +use Magento\Quote\Model\Quote; +use Magento\Quote\Model\QuoteManagement; + +/** + * Class ValidatePurchaseOrderNumber + * + * Validate purchase order number before submit order + */ +class ValidatePurchaseOrderNumber +{ + /** + * Before submitOrder plugin. + * + * @param QuoteManagement $subject + * @param Quote $quote + * @param array $orderData + * @return void + * @throws LocalizedException + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function beforeSubmit( + QuoteManagement $subject, + Quote $quote, + array $orderData = [] + ): void { + $payment = $quote->getPayment(); + if ($payment->getMethod() === Purchaseorder::PAYMENT_METHOD_PURCHASEORDER_CODE + && empty($payment->getPoNumber())) { + throw new LocalizedException(__('Purchase order number is a required field.')); + } + } +} diff --git a/app/code/Magento/OfflinePayments/Test/Unit/Model/PurchaseorderTest.php b/app/code/Magento/OfflinePayments/Test/Unit/Model/PurchaseorderTest.php index c4c717550dbae..2bbaad03d4b87 100644 --- a/app/code/Magento/OfflinePayments/Test/Unit/Model/PurchaseorderTest.php +++ b/app/code/Magento/OfflinePayments/Test/Unit/Model/PurchaseorderTest.php @@ -66,9 +66,6 @@ public function testAssignData() public function testValidate() { - $this->expectException(LocalizedException::class); - $this->expectExceptionMessage('Purchase order number is a required field.'); - $data = new DataObject([]); $addressMock = $this->getMockForAbstractClass(OrderAddressInterface::class); @@ -84,6 +81,7 @@ public function testValidate() $this->object->setData('info_instance', $instance); $this->object->assignData($data); - $this->object->validate(); + $result = $this->object->validate(); + $this->assertEquals($result, $this->object); } } diff --git a/app/code/Magento/OfflinePayments/composer.json b/app/code/Magento/OfflinePayments/composer.json index 56c7eb2778c48..237812a205130 100644 --- a/app/code/Magento/OfflinePayments/composer.json +++ b/app/code/Magento/OfflinePayments/composer.json @@ -8,7 +8,8 @@ "php": "~7.3.0||~7.4.0", "magento/framework": "*", "magento/module-checkout": "*", - "magento/module-payment": "*" + "magento/module-payment": "*", + "magento/module-quote": "*" }, "suggest": { "magento/module-config": "*" diff --git a/app/code/Magento/OfflinePayments/etc/di.xml b/app/code/Magento/OfflinePayments/etc/di.xml index 1e3d7cba3b86a..e0a2e250eadcd 100644 --- a/app/code/Magento/OfflinePayments/etc/di.xml +++ b/app/code/Magento/OfflinePayments/etc/di.xml @@ -13,4 +13,7 @@ </argument> </arguments> </type> + <type name="\Magento\Quote\Model\QuoteManagement"> + <plugin name="validate_purchase_order_number" type="Magento\OfflinePayments\Plugin\ValidatePurchaseOrderNumber"/> + </type> </config> diff --git a/app/code/Magento/OfflinePayments/view/frontend/web/template/payment/purchaseorder-form.html b/app/code/Magento/OfflinePayments/view/frontend/web/template/payment/purchaseorder-form.html index 89d16bd732e7c..3a42a84b620b8 100644 --- a/app/code/Magento/OfflinePayments/view/frontend/web/template/payment/purchaseorder-form.html +++ b/app/code/Magento/OfflinePayments/view/frontend/web/template/payment/purchaseorder-form.html @@ -42,27 +42,29 @@ </div> </div> </fieldset> - </form> - <div class="checkout-agreements-block"> - <!-- ko foreach: $parent.getRegion('before-place-order') --> - <!-- ko template: getTemplate() --><!-- /ko --> - <!--/ko--> - </div> - <div class="actions-toolbar" id="review-buttons-container"> - <div class="primary"> - <button class="action primary checkout" - type="submit" - data-bind=" - click: placeOrder, - attr: {title: $t('Place Order')}, - enable: (getCode() == isChecked()), - css: {disabled: !isPlaceOrderActionAllowed()} - " - data-role="review-save"> - <span data-bind="i18n: 'Place Order'"></span> - </button> + + <div class="checkout-agreements-block"> + <!-- ko foreach: $parent.getRegion('before-place-order') --> + <!-- ko template: getTemplate() --><!-- /ko --> + <!--/ko--> </div> - </div> + + <div class="actions-toolbar" id="review-buttons-container"> + <div class="primary"> + <button class="action primary checkout" + type="submit" + data-bind=" + click: placeOrder, + attr: {title: $t('Place Order')}, + enable: (getCode() == isChecked()), + css: {disabled: !isPlaceOrderActionAllowed()} + " + data-role="review-save"> + <span data-bind="i18n: 'Place Order'"></span> + </button> + </div> + </div> + </form> </div> </div> - + diff --git a/app/code/Magento/OfflineShipping/Model/Carrier/Tablerate.php b/app/code/Magento/OfflineShipping/Model/Carrier/Tablerate.php index bbc199c91263a..112accbae8070 100644 --- a/app/code/Magento/OfflineShipping/Model/Carrier/Tablerate.php +++ b/app/code/Magento/OfflineShipping/Model/Carrier/Tablerate.php @@ -140,10 +140,9 @@ public function collectRates(RateRequest $request) $freePackageValue += $item->getBaseRowTotal(); } } - $oldValue = $request->getPackageValue(); - $newPackageValue = $oldValue - $freePackageValue; - $request->setPackageValue($newPackageValue); - $request->setPackageValueWithDiscount($newPackageValue); + + $request->setPackageValue($request->getPackageValue() - $freePackageValue); + $request->setPackageValueWithDiscount($request->getPackageValueWithDiscount() - $freePackageValue); } if (!$request->getConditionName()) { diff --git a/app/code/Magento/OfflineShipping/Test/Mftf/Test/SalesRuleDiscountIsAppliedOnPackageValueForTableRateTest.xml b/app/code/Magento/OfflineShipping/Test/Mftf/Test/SalesRuleDiscountIsAppliedOnPackageValueForTableRateTest.xml new file mode 100644 index 0000000000000..d225e5fa28f97 --- /dev/null +++ b/app/code/Magento/OfflineShipping/Test/Mftf/Test/SalesRuleDiscountIsAppliedOnPackageValueForTableRateTest.xml @@ -0,0 +1,101 @@ +<?xml version="1.0" encoding="UTF-8"?><!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="SalesRuleDiscountIsAppliedOnPackageValueForTableRateTest"> + <annotations> + <features value="Shipping"/> + <stories value="Offline Shipping Methods"/> + <title value="SalesRule Discount Is Applied On PackageValue For TableRate"/> + <description value="SalesRule Discount Is Applied On PackageValue For TableRate"/> + <severity value="AVERAGE"/> + <testCaseId value="MC-38271"/> + <group value="shipping"/> + </annotations> + <before> + <!-- Add simple product --> + <createData entity="SimpleProduct2" stepKey="createSimpleProduct"> + <field key="price">13.00</field> + </createData> + + <!-- Create cart price rule --> + <createData entity="ActiveSalesRuleForNotLoggedIn" stepKey="createCartPriceRule"/> + <createData entity="SimpleSalesRuleCoupon" stepKey="createCouponForCartPriceRule"> + <requiredEntity createDataKey="createCartPriceRule"/> + </createData> + + <!-- Login as admin --> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + + <!-- Go to Stores > Configuration > Sales > Shipping Methods --> + <actionGroup ref="AdminOpenShippingMethodsConfigPageActionGroup" stepKey="openShippingMethodConfigPage"/> + + <!-- Switch to Website scope --> + <actionGroup ref="AdminSwitchWebsiteActionGroup" stepKey="AdminSwitchStoreView"> + <argument name="website" value="_defaultWebsite"/> + </actionGroup> + + <!-- Enable Table Rate method and save config --> + <actionGroup ref="AdminChangeTableRatesShippingMethodStatusActionGroup" stepKey="enableTableRatesShippingMethod"/> + + <!-- Uncheck Use Default checkbox for Default Condition --> + <uncheckOption selector="{{AdminShippingMethodTableRatesSection.carriersTableRateConditionName}}" stepKey="disableUseDefaultCondition"/> + + <!-- Make sure you have Condition Price vs. Destination --> + <selectOption selector="{{AdminShippingMethodTableRatesSection.condition}}" userInput="{{TableRateShippingMethodConfig.package_value_with_discount}}" stepKey="setCondition"/> + + <!-- Import file and save config --> + <attachFile selector="{{AdminShippingMethodTableRatesSection.importFile}}" userInput="usa_tablerates.csv" stepKey="attachFileForImport"/> + <actionGroup ref="AdminSaveConfigActionGroup" stepKey="saveConfigs"/> + </before> + <after> + <!-- Go to Stores > Configuration > Sales > Shipping Methods --> + <actionGroup ref="AdminOpenShippingMethodsConfigPageActionGroup" stepKey="openShippingMethodConfigPage"/> + + <!-- Switch to Website scope --> + <actionGroup ref="AdminSwitchWebsiteActionGroup" stepKey="AdminSwitchStoreView"> + <argument name="website" value="_defaultWebsite"/> + </actionGroup> + + <!-- Check Use Default checkbox for Default Condition and Active --> + <checkOption selector="{{AdminShippingMethodTableRatesSection.carriersTableRateConditionName}}" stepKey="enableUseDefaultCondition"/> + <checkOption selector="{{AdminShippingMethodTableRatesSection.enabledUseSystemValue}}" stepKey="enableUseDefaultActive"/> + + <actionGroup ref="AdminSaveConfigActionGroup" stepKey="saveConfigs"/> + + <!-- Log out --> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> + + <!-- Remove simple product--> + <deleteData createDataKey="createSimpleProduct" stepKey="deleteSimpleProduct"/> + + <!-- Delete sales rule --> + <deleteData createDataKey="createCartPriceRule" stepKey="deleteCartPriceRule"/> + + </after> + <!-- Add simple product to cart --> + <actionGroup ref="AddSimpleProductToCartActionGroup" stepKey="addProductToCart"> + <argument name="product" value="$$createSimpleProduct$$"/> + </actionGroup> + + <!-- Assert that table rate value is correct for US --> + <actionGroup ref="StorefrontCartPageOpenActionGroup" stepKey="goToCheckout"/> + <waitForElement time="30" selector="{{CheckoutCartSummarySection.estimateShippingAndTaxForm}}" stepKey="waitForEstimateShippingAndTaxForm"/> + <waitForElement time="30" selector="{{CheckoutCartSummarySection.shippingMethodForm}}" stepKey="waitForShippingMethodForm"/> + <conditionalClick selector="{{CheckoutCartSummarySection.estimateShippingAndTax}}" dependentSelector="{{CheckoutCartSummarySection.country}}" visible="false" stepKey="expandEstimateShippingandTax" /> + <selectOption selector="{{CheckoutCartSummarySection.country}}" userInput="United States" stepKey="selectUSCountry"/> + <waitForPageLoad stepKey="waitForSelectCountry"/> + <see selector="{{CheckoutCartSummarySection.shippingPrice}}" userInput="$5.99" stepKey="seeShippingForUS"/> + + <!-- Apply Coupon --> + <actionGroup ref="StorefrontApplyCouponActionGroup" stepKey="applyDiscount"> + <argument name="coupon" value="$$createCouponForCartPriceRule$$"/> + </actionGroup> + + <see selector="{{CheckoutCartSummarySection.shippingPrice}}" userInput="$7.99" stepKey="seeShippingForUSWithDiscount"/> + </test> +</tests> diff --git a/app/code/Magento/PageCache/Model/Config.php b/app/code/Magento/PageCache/Model/Config.php index bf144cc46637e..10ae41be21d4d 100644 --- a/app/code/Magento/PageCache/Model/Config.php +++ b/app/code/Magento/PageCache/Model/Config.php @@ -121,7 +121,7 @@ public function __construct( */ public function getType() { - return (int)$this->_scopeConfig->getValue(self::XML_PAGECACHE_TYPE); + return $this->_scopeConfig->getValue(self::XML_PAGECACHE_TYPE); } /** diff --git a/app/code/Magento/PageCache/Model/Layout/LayoutPlugin.php b/app/code/Magento/PageCache/Model/Layout/LayoutPlugin.php index 1b64f3b635c03..6aff8aef2c2d9 100644 --- a/app/code/Magento/PageCache/Model/Layout/LayoutPlugin.php +++ b/app/code/Magento/PageCache/Model/Layout/LayoutPlugin.php @@ -84,7 +84,7 @@ public function afterGenerateElements(Layout $subject) public function afterGetOutput(Layout $subject, $result) { if ($subject->isCacheable() && $this->config->isEnabled()) { - $tags = [[]]; + $tags = []; $isVarnish = $this->config->getType() === Config::VARNISH; foreach ($subject->getAllBlocks() as $block) { @@ -96,7 +96,7 @@ public function afterGetOutput(Layout $subject, $result) $tags[] = $block->getIdentities(); } } - $tags = array_unique(array_merge(...$tags)); + $tags = array_unique(array_merge([], ...$tags)); $tags = $this->pageCacheTagsPreprocessor->process($tags); $this->response->setHeader('X-Magento-Tags', implode(',', $tags)); } diff --git a/app/code/Magento/PageCache/Test/Mftf/Test/AdminFrontendAreaSessionMustNotAffectAdminAreaTest.xml b/app/code/Magento/PageCache/Test/Mftf/Test/AdminFrontendAreaSessionMustNotAffectAdminAreaTest.xml index 1c280acd63a7b..e026bee87dcd4 100644 --- a/app/code/Magento/PageCache/Test/Mftf/Test/AdminFrontendAreaSessionMustNotAffectAdminAreaTest.xml +++ b/app/code/Magento/PageCache/Test/Mftf/Test/AdminFrontendAreaSessionMustNotAffectAdminAreaTest.xml @@ -76,10 +76,10 @@ <!-- 5. Open admin tab with page with products. Reload this page twice. --> <switchToPreviousTab stepKey="switchToPreviousTab"/> - <reloadPage stepKey="reloadAdminCatalogPageFirst"/> - <waitForPageLoad stepKey="waitForReloadFirst"/> - <reloadPage stepKey="reloadAdminCatalogPageSecond"/> - <waitForPageLoad stepKey="waitForReloadSecond"/> + <actionGroup ref="ReloadPageActionGroup" stepKey="reloadAdminCatalogPageFirst"/> + <comment userInput="Replacing reload action and preserve Backward Compatibility" stepKey="waitForReloadFirst"/> + <actionGroup ref="ReloadPageActionGroup" stepKey="reloadAdminCatalogPageSecond"/> + <comment userInput="Replacing reload action and preserve Backward Compatibility" stepKey="waitForReloadSecond"/> <seeInTitle userInput="Products / Inventory / Catalog / Magento Admin" stepKey="seeAdminProductsPageTitle"/> <see userInput="Products" selector="{{AdminHeaderSection.pageTitle}}" stepKey="seeAdminProductsPageHeader"/> diff --git a/app/code/Magento/PageCache/etc/varnish4.vcl b/app/code/Magento/PageCache/etc/varnish4.vcl index f5e25ce36e973..1e51b6e110728 100644 --- a/app/code/Magento/PageCache/etc/varnish4.vcl +++ b/app/code/Magento/PageCache/etc/varnish4.vcl @@ -57,8 +57,8 @@ sub vcl_recv { return (pass); } - # Bypass shopping cart, checkout and search requests - if (req.url ~ "/checkout" || req.url ~ "/catalogsearch") { + # Bypass shopping cart and checkout + if (req.url ~ "/checkout") { return (pass); } @@ -121,13 +121,6 @@ sub vcl_hash { hash_data(regsub(req.http.cookie, "^.*?X-Magento-Vary=([^;]+);*.*$", "\1")); } - # For multi site configurations to not cache each other's content - if (req.http.host) { - hash_data(req.http.host); - } else { - hash_data(server.ip); - } - if (req.url ~ "/graphql") { call process_graphql_headers; } diff --git a/app/code/Magento/PageCache/etc/varnish5.vcl b/app/code/Magento/PageCache/etc/varnish5.vcl index 92bb3394486fc..7adededf33036 100644 --- a/app/code/Magento/PageCache/etc/varnish5.vcl +++ b/app/code/Magento/PageCache/etc/varnish5.vcl @@ -58,8 +58,8 @@ sub vcl_recv { return (pass); } - # Bypass shopping cart, checkout and search requests - if (req.url ~ "/checkout" || req.url ~ "/catalogsearch") { + # Bypass shopping cart and checkout + if (req.url ~ "/checkout") { return (pass); } @@ -122,13 +122,6 @@ sub vcl_hash { hash_data(regsub(req.http.cookie, "^.*?X-Magento-Vary=([^;]+);*.*$", "\1")); } - # For multi site configurations to not cache each other's content - if (req.http.host) { - hash_data(req.http.host); - } else { - hash_data(server.ip); - } - # To make sure http users don't see ssl warning if (req.http./* {{ ssl_offloaded_header }} */) { hash_data(req.http./* {{ ssl_offloaded_header }} */); diff --git a/app/code/Magento/PageCache/etc/varnish6.vcl b/app/code/Magento/PageCache/etc/varnish6.vcl index b23bec4c45fb8..bce89fe263573 100644 --- a/app/code/Magento/PageCache/etc/varnish6.vcl +++ b/app/code/Magento/PageCache/etc/varnish6.vcl @@ -62,8 +62,8 @@ sub vcl_recv { return (pass); } - # Bypass shopping cart, checkout and search requests - if (req.url ~ "/checkout" || req.url ~ "/catalogsearch") { + # Bypass shopping cart and checkout + if (req.url ~ "/checkout") { return (pass); } @@ -126,13 +126,6 @@ sub vcl_hash { hash_data(regsub(req.http.cookie, "^.*?X-Magento-Vary=([^;]+);*.*$", "\1")); } - # For multi site configurations to not cache each other's content - if (req.http.host) { - hash_data(req.http.host); - } else { - hash_data(server.ip); - } - # To make sure http users don't see ssl warning if (req.http./* {{ ssl_offloaded_header }} */) { hash_data(req.http./* {{ ssl_offloaded_header }} */); diff --git a/app/code/Magento/Payment/Gateway/Validator/ValidatorComposite.php b/app/code/Magento/Payment/Gateway/Validator/ValidatorComposite.php index 8c8d13300849e..af42554484117 100644 --- a/app/code/Magento/Payment/Gateway/Validator/ValidatorComposite.php +++ b/app/code/Magento/Payment/Gateway/Validator/ValidatorComposite.php @@ -59,8 +59,8 @@ public function __construct( public function validate(array $validationSubject) { $isValid = true; - $failsDescriptionAggregate = [[]]; - $errorCodesAggregate = [[]]; + $failsDescriptionAggregate = []; + $errorCodesAggregate = []; foreach ($this->validators as $key => $validator) { $result = $validator->validate($validationSubject); if (!$result->isValid()) { @@ -76,8 +76,8 @@ public function validate(array $validationSubject) return $this->createResult( $isValid, - array_merge(...$failsDescriptionAggregate), - array_merge(...$errorCodesAggregate) + array_merge([], ...$failsDescriptionAggregate), + array_merge([], ...$errorCodesAggregate) ); } } diff --git a/app/code/Magento/Payment/Model/PaymentMethodList.php b/app/code/Magento/Payment/Model/PaymentMethodList.php index 4e400dbf0c906..b27d02bbdff4b 100644 --- a/app/code/Magento/Payment/Model/PaymentMethodList.php +++ b/app/code/Magento/Payment/Model/PaymentMethodList.php @@ -6,53 +6,57 @@ namespace Magento\Payment\Model; use Magento\Payment\Api\Data\PaymentMethodInterface; +use Magento\Payment\Api\Data\PaymentMethodInterfaceFactory; +use Magento\Payment\Api\PaymentMethodListInterface; +use Magento\Payment\Helper\Data; +use UnexpectedValueException; -/** - * Payment method list class. - */ -class PaymentMethodList implements \Magento\Payment\Api\PaymentMethodListInterface +class PaymentMethodList implements PaymentMethodListInterface { /** - * @var \Magento\Payment\Api\Data\PaymentMethodInterfaceFactory + * @var PaymentMethodInterfaceFactory */ private $methodFactory; /** - * @var \Magento\Payment\Helper\Data + * @var Data */ private $helper; /** - * @param \Magento\Payment\Api\Data\PaymentMethodInterfaceFactory $methodFactory - * @param \Magento\Payment\Helper\Data $helper + * @param PaymentMethodInterfaceFactory $methodFactory + * @param Data $helper */ public function __construct( - \Magento\Payment\Api\Data\PaymentMethodInterfaceFactory $methodFactory, - \Magento\Payment\Helper\Data $helper + PaymentMethodInterfaceFactory $methodFactory, + Data $helper ) { $this->methodFactory = $methodFactory; $this->helper = $helper; } /** - * {@inheritdoc} + * @inheritDoc */ public function getList($storeId) { $methodsCodes = array_keys($this->helper->getPaymentMethods()); - $methodsInstances = array_map( function ($code) { - return $this->helper->getMethodInstance($code); + try { + return $this->helper->getMethodInstance($code); + } catch (UnexpectedValueException $e) { + return null; + } }, $methodsCodes ); - $methodsInstances = array_filter($methodsInstances, function (MethodInterface $method) { - return !($method instanceof \Magento\Payment\Model\Method\Substitution); + $methodsInstances = array_filter($methodsInstances, function ($method) { + return $method && !($method instanceof \Magento\Payment\Model\Method\Substitution); }); - @uasort( + uasort( $methodsInstances, function (MethodInterface $a, MethodInterface $b) use ($storeId) { return (int)$a->getConfigData('sort_order', $storeId) - (int)$b->getConfigData('sort_order', $storeId); @@ -76,7 +80,7 @@ function (MethodInterface $methodInstance) use ($storeId) { } /** - * {@inheritdoc} + * @inheritDoc */ public function getActiveList($storeId) { diff --git a/app/code/Magento/Paypal/Controller/Express/AbstractExpress/PlaceOrder.php b/app/code/Magento/Paypal/Controller/Express/AbstractExpress/PlaceOrder.php index 29d4a5bd1f25c..95dc8ee487edf 100644 --- a/app/code/Magento/Paypal/Controller/Express/AbstractExpress/PlaceOrder.php +++ b/app/code/Magento/Paypal/Controller/Express/AbstractExpress/PlaceOrder.php @@ -174,6 +174,7 @@ protected function _processPaypalApiError($exception) $this->_redirectSameToken(); break; case ApiProcessableException::API_ADDRESS_MATCH_FAIL: + case ApiProcessableException::API_TRANSACTION_HAS_BEEN_COMPLETED: $this->redirectToOrderReviewPageAndShowError($exception->getUserMessage()); break; case ApiProcessableException::API_UNABLE_TRANSACTION_COMPLETE: diff --git a/app/code/Magento/Paypal/Model/Api/Nvp.php b/app/code/Magento/Paypal/Model/Api/Nvp.php index b35f783482e06..30bfb660aa6f1 100644 --- a/app/code/Magento/Paypal/Model/Api/Nvp.php +++ b/app/code/Magento/Paypal/Model/Api/Nvp.php @@ -1286,15 +1286,6 @@ protected function _handleCallErrors($response) ); $this->_logger->critical($exceptionLogMessage); - /** - * The response code 10415 'Transaction has already been completed for this token' - * must not fails place order. The old Paypal interface does not lock 'Send' button - * it may result to re-send data. - */ - if (in_array((string)ProcessableException::API_TRANSACTION_HAS_BEEN_COMPLETED, $this->_callErrors)) { - return; - } - $exceptionPhrase = __('PayPal gateway has rejected request. %1', $errorMessages); /** @var \Magento\Framework\Exception\LocalizedException $exception */ diff --git a/app/code/Magento/Paypal/Model/Api/ProcessableException.php b/app/code/Magento/Paypal/Model/Api/ProcessableException.php index 40ee6d98c4381..12a11ff442418 100644 --- a/app/code/Magento/Paypal/Model/Api/ProcessableException.php +++ b/app/code/Magento/Paypal/Model/Api/ProcessableException.php @@ -67,6 +67,12 @@ public function getUserMessage() . ' Please contact us so we can assist you.' ); break; + case self::API_TRANSACTION_HAS_BEEN_COMPLETED: + $message = __( + 'A successful payment transaction has already been completed.' + . ' Please, check if the order has been placed.' + ); + break; case self::API_ADDRESS_MATCH_FAIL: $message = __( 'A match of the Shipping Address City, State, and Postal Code failed.' diff --git a/app/code/Magento/Paypal/Model/Config/Structure/PaymentSectionModifier.php b/app/code/Magento/Paypal/Model/Config/Structure/PaymentSectionModifier.php index 61410499e956e..a3cef539dc17b 100644 --- a/app/code/Magento/Paypal/Model/Config/Structure/PaymentSectionModifier.php +++ b/app/code/Magento/Paypal/Model/Config/Structure/PaymentSectionModifier.php @@ -84,7 +84,7 @@ public function modify(array $initialStructure) */ private function getMoveInstructions($section, $data) { - $moved = [[]]; + $moved = []; if (array_key_exists('children', $data)) { foreach ($data['children'] as $childSection => $childData) { @@ -106,6 +106,6 @@ private function getMoveInstructions($section, $data) ]; } - return array_merge(...$moved); + return array_merge([], ...$moved); } } diff --git a/app/code/Magento/Paypal/Model/Express.php b/app/code/Magento/Paypal/Model/Express.php index 946c0fd4c66ca..39b1c6f7e3c28 100644 --- a/app/code/Magento/Paypal/Model/Express.php +++ b/app/code/Magento/Paypal/Model/Express.php @@ -276,6 +276,7 @@ protected function _setApiProcessableErrors() ApiProcessableException::API_MAXIMUM_AMOUNT_FILTER_DECLINE, ApiProcessableException::API_OTHER_FILTER_DECLINE, ApiProcessableException::API_ADDRESS_MATCH_FAIL, + ApiProcessableException::API_TRANSACTION_HAS_BEEN_COMPLETED, self::$authorizationExpiredCode ] ); diff --git a/app/code/Magento/Paypal/Test/Mftf/ActionGroup/AdminPayPalExpressCheckoutDisableActionGroup.xml b/app/code/Magento/Paypal/Test/Mftf/ActionGroup/AdminPayPalExpressCheckoutDisableActionGroup.xml new file mode 100644 index 0000000000000..c927bfc50120e --- /dev/null +++ b/app/code/Magento/Paypal/Test/Mftf/ActionGroup/AdminPayPalExpressCheckoutDisableActionGroup.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminPayPalExpressCheckoutDisableActionGroup"> + <annotations> + <description>Goes to the 'Configuration' page for 'Payment Methods'. Disables PayPal Express Checkout solution. Clicks on Save.</description> + </annotations> + <arguments> + <argument name="countryCode" type="string" defaultValue="us"/> + </arguments> + <amOnPage url="{{AdminConfigPaymentMethodsPage.url}}" stepKey="navigateToPaymentConfigurationPage"/> + <waitForPageLoad stepKey="waitForPageLoad1"/> + <click selector="{{PayPalExpressCheckoutConfigSection.configureBtn(countryCode)}}" stepKey="clickPayPalConfigureBtn"/> + <waitForElementVisible selector="{{PayPalAdvancedSettingConfigSection.advancedSettingTab(countryCode)}}" stepKey="waitForAdvancedSettingTab"/> + <selectOption selector="{{PayPalExpressCheckoutConfigSection.enableSolution(countryCode)}}" userInput="No" stepKey="enableSolution"/> + <click selector="{{AdminConfigSection.saveButton}}" stepKey="saveConfig"/> + <waitForPageLoad stepKey="waitForPageLoad2"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Paypal/Test/Mftf/ActionGroup/AdminPayPalExpressCheckoutEnableActionGroup.xml b/app/code/Magento/Paypal/Test/Mftf/ActionGroup/AdminPayPalExpressCheckoutEnableActionGroup.xml new file mode 100644 index 0000000000000..b6b44abd7b794 --- /dev/null +++ b/app/code/Magento/Paypal/Test/Mftf/ActionGroup/AdminPayPalExpressCheckoutEnableActionGroup.xml @@ -0,0 +1,34 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminPayPalExpressCheckoutEnableActionGroup"> + <annotations> + <description>Goes to the 'Configuration' page for 'Payment Methods'. Fills in the provided Sample PayPal credentials and other details. Clicks on Save.</description> + </annotations> + <arguments> + <argument name="credentials" defaultValue="SamplePaypalExpressConfig"/> + <argument name="countryCode" type="string" defaultValue="us"/> + </arguments> + <amOnPage url="{{AdminConfigPaymentMethodsPage.url}}" stepKey="navigateToPaymentConfigurationPage"/> + <waitForPageLoad stepKey="waitForPageLoad1"/> + <click selector="{{PayPalExpressCheckoutConfigSection.configureBtn(countryCode)}}" stepKey="clickPayPalConfigureBtn"/> + <waitForElementVisible selector="{{PayPalAdvancedSettingConfigSection.advancedSettingTab(countryCode)}}" stepKey="waitForAdvancedSettingTab"/> + <fillField selector ="{{PayPalExpressCheckoutConfigSection.email(countryCode)}}" userInput="{{credentials.paypal_express_email}}" stepKey="inputEmailAssociatedWithPayPalMerchantAccount"/> + <selectOption selector ="{{PayPalExpressCheckoutConfigSection.apiMethod(countryCode)}}" userInput="API Signature" stepKey="inputAPIAuthenticationMethods"/> + <fillField selector ="{{PayPalExpressCheckoutConfigSection.username(countryCode)}}" userInput="{{credentials.paypal_express_api_username}}" stepKey="inputAPIUsername"/> + <fillField selector ="{{PayPalExpressCheckoutConfigSection.password(countryCode)}}" userInput="{{credentials.paypal_express_api_password}}" stepKey="inputAPIPassword"/> + <fillField selector ="{{PayPalExpressCheckoutConfigSection.signature(countryCode)}}" userInput="{{credentials.paypal_express_api_signature}}" stepKey="inputAPISignature"/> + <selectOption selector ="{{PayPalExpressCheckoutConfigSection.sandboxMode(countryCode)}}" userInput="Yes" stepKey="enableSandboxMode"/> + <selectOption selector="{{PayPalExpressCheckoutConfigSection.enableSolution(countryCode)}}" userInput="Yes" stepKey="enableSolution"/> + <fillField selector ="{{PayPalExpressCheckoutConfigSection.merchantID(countryCode)}}" userInput="{{credentials.paypal_express_merchantID}}" stepKey="inputMerchantID"/> + <click selector="{{AdminConfigSection.saveButton}}" stepKey="saveConfig"/> + <waitForPageLoad stepKey="waitForPageLoad2"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Paypal/Test/Mftf/ActionGroup/SampleConfigPayPalExpressCheckoutActionGroup.xml b/app/code/Magento/Paypal/Test/Mftf/ActionGroup/SampleConfigPayPalExpressCheckoutActionGroup.xml index 23d956c8e9b8f..a7ccf0a19263b 100644 --- a/app/code/Magento/Paypal/Test/Mftf/ActionGroup/SampleConfigPayPalExpressCheckoutActionGroup.xml +++ b/app/code/Magento/Paypal/Test/Mftf/ActionGroup/SampleConfigPayPalExpressCheckoutActionGroup.xml @@ -8,7 +8,7 @@ <actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> - <actionGroup name="SampleConfigPayPalExpressCheckoutActionGroup"> + <actionGroup name="SampleConfigPayPalExpressCheckoutActionGroup" deprecated="Use AdminPayPalExpressCheckoutEnableActionGroup instead"> <annotations> <description>Goes to the 'Configuration' page for 'Payment Methods'. Fills in the provided Sample PayPal credentials and other details. Clicks on Save.</description> </annotations> diff --git a/app/code/Magento/Paypal/Test/Mftf/Test/AdminConfigPaymentsConflictResolutionForPayPalTest/AdminConfigPaymentsConflictResolutionForPayPalInUnitedKingdomTest.xml b/app/code/Magento/Paypal/Test/Mftf/Test/AdminConfigPaymentsConflictResolutionForPayPalTest/AdminConfigPaymentsConflictResolutionForPayPalInUnitedKingdomTest.xml index ebdfb9e91ecf1..a616c0bb2c68b 100644 --- a/app/code/Magento/Paypal/Test/Mftf/Test/AdminConfigPaymentsConflictResolutionForPayPalTest/AdminConfigPaymentsConflictResolutionForPayPalInUnitedKingdomTest.xml +++ b/app/code/Magento/Paypal/Test/Mftf/Test/AdminConfigPaymentsConflictResolutionForPayPalTest/AdminConfigPaymentsConflictResolutionForPayPalInUnitedKingdomTest.xml @@ -19,7 +19,7 @@ </annotations> <before> <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> - <actionGroup ref="SampleConfigPayPalExpressCheckoutActionGroup" stepKey="ConfigPayPalExpress"> + <actionGroup ref="AdminPayPalExpressCheckoutEnableActionGroup" stepKey="ConfigPayPalExpress"> <argument name="credentials" value="SamplePaypalExpressConfig"/> </actionGroup> </before> diff --git a/app/code/Magento/Paypal/Test/Unit/Model/Api/NvpTest.php b/app/code/Magento/Paypal/Test/Unit/Model/Api/NvpTest.php index 42b99ae8e7459..69aa9b99bc9e7 100644 --- a/app/code/Magento/Paypal/Test/Unit/Model/Api/NvpTest.php +++ b/app/code/Magento/Paypal/Test/Unit/Model/Api/NvpTest.php @@ -305,8 +305,7 @@ public function testGetDebugReplacePrivateDataKeys() /** * Tests case if obtained response with code 10415 'Transaction has already - * been completed for this token'. It must does not throws the exception and - * must returns response array. + * been completed for this token'. It must throw the ProcessableException. */ public function testCallTransactionHasBeenCompleted() { @@ -317,15 +316,10 @@ public function testCallTransactionHasBeenCompleted() ->method('read') ->willReturn($response); $this->model->setProcessableErrors($processableErrors); - $this->customLoggerMock->expects($this->once()) - ->method('debug'); - $expectedResponse = [ - 'ACK' => 'Failure', - 'L_ERRORCODE0' => '10415', - 'L_SHORTMESSAGE0' => 'Message.', - 'L_LONGMESSAGE0' => 'Long Message.' - ]; - $this->assertEquals($expectedResponse, $this->model->call('some method', ['data' => 'some data'])); + $this->expectExceptionMessageMatches('/PayPal gateway has rejected request/'); + $this->expectException(ProcessableException::class); + + $this->model->call('DoExpressCheckout', ['data' => 'some data']); } } diff --git a/app/code/Magento/Paypal/Test/Unit/Model/Config/Structure/PaymentSectionModifierTest.php b/app/code/Magento/Paypal/Test/Unit/Model/Config/Structure/PaymentSectionModifierTest.php index dc54b71324a9b..a6a18418e92ac 100644 --- a/app/code/Magento/Paypal/Test/Unit/Model/Config/Structure/PaymentSectionModifierTest.php +++ b/app/code/Magento/Paypal/Test/Unit/Model/Config/Structure/PaymentSectionModifierTest.php @@ -162,14 +162,14 @@ public function testMovedToTargetSpecialGroup() */ private function fetchAllAvailableGroups($structure) { - $availableGroups = [[]]; + $availableGroups = []; foreach ($structure as $group => $data) { $availableGroups[] = [$group]; if (isset($data['children'])) { $availableGroups[] = $this->fetchAllAvailableGroups($data['children']); } } - $availableGroups = array_merge(...$availableGroups); + $availableGroups = array_merge([], ...$availableGroups); $availableGroups = array_values(array_unique($availableGroups)); sort($availableGroups); return $availableGroups; diff --git a/app/code/Magento/Paypal/Test/Unit/Model/ExpressTest.php b/app/code/Magento/Paypal/Test/Unit/Model/ExpressTest.php index 8cf2fb91a8452..14dcc4fc4229d 100644 --- a/app/code/Magento/Paypal/Test/Unit/Model/ExpressTest.php +++ b/app/code/Magento/Paypal/Test/Unit/Model/ExpressTest.php @@ -53,6 +53,7 @@ class ExpressTest extends TestCase ApiProcessableException::API_MAXIMUM_AMOUNT_FILTER_DECLINE, ApiProcessableException::API_OTHER_FILTER_DECLINE, ApiProcessableException::API_ADDRESS_MATCH_FAIL, + ApiProcessableException::API_TRANSACTION_HAS_BEEN_COMPLETED ]; /** diff --git a/app/code/Magento/Paypal/i18n/en_US.csv b/app/code/Magento/Paypal/i18n/en_US.csv index 8db6285dc157e..a8f26b422dc7c 100644 --- a/app/code/Magento/Paypal/i18n/en_US.csv +++ b/app/code/Magento/Paypal/i18n/en_US.csv @@ -737,3 +737,4 @@ User,User "Please enter at least 0 and at most 65535","Please enter at least 0 and at most 65535" "Order is suspended as an account verification transaction is suspected to be fraudulent.","Order is suspended as an account verification transaction is suspected to be fraudulent." "Payment can't be accepted since transaction was rejected by merchant.","Payment can't be accepted since transaction was rejected by merchant." +"A successful payment transaction has already been completed. Please, check if the order has been placed.","A successful payment transaction has already been completed. Please, check if the order has been placed." diff --git a/app/code/Magento/Persistent/Model/QuoteManager.php b/app/code/Magento/Persistent/Model/QuoteManager.php index b6504d528fbe4..35b07ebdb7c44 100644 --- a/app/code/Magento/Persistent/Model/QuoteManager.php +++ b/app/code/Magento/Persistent/Model/QuoteManager.php @@ -5,6 +5,7 @@ */ namespace Magento\Persistent\Model; +use Magento\Customer\Api\Data\CustomerInterfaceFactory; use Magento\Customer\Api\Data\GroupInterface; use Magento\Framework\App\ObjectManager; use Magento\Persistent\Helper\Data; @@ -64,6 +65,11 @@ class QuoteManager */ private $cartExtensionFactory; + /** + * @var CustomerInterfaceFactory + */ + private $customerDataFactory; + /** * @param \Magento\Persistent\Helper\Session $persistentSession * @param Data $persistentData @@ -71,6 +77,7 @@ class QuoteManager * @param CartRepositoryInterface $quoteRepository * @param CartExtensionFactory|null $cartExtensionFactory * @param ShippingAssignmentProcessor|null $shippingAssignmentProcessor + * @param CustomerInterfaceFactory|null $customerDataFactory */ public function __construct( \Magento\Persistent\Helper\Session $persistentSession, @@ -78,7 +85,8 @@ public function __construct( \Magento\Checkout\Model\Session $checkoutSession, CartRepositoryInterface $quoteRepository, ?CartExtensionFactory $cartExtensionFactory = null, - ?ShippingAssignmentProcessor $shippingAssignmentProcessor = null + ?ShippingAssignmentProcessor $shippingAssignmentProcessor = null, + ?CustomerInterfaceFactory $customerDataFactory = null ) { $this->persistentSession = $persistentSession; $this->persistentData = $persistentData; @@ -88,6 +96,8 @@ public function __construct( ?? ObjectManager::getInstance()->get(CartExtensionFactory::class); $this->shippingAssignmentProcessor = $shippingAssignmentProcessor ?? ObjectManager::getInstance()->get(ShippingAssignmentProcessor::class); + $this->customerDataFactory = $customerDataFactory + ?? ObjectManager::getInstance()->get(CustomerInterfaceFactory::class); } /** @@ -109,14 +119,11 @@ public function setGuest($checkQuote = false) $quote->getPaymentsCollection()->walk('delete'); $quote->getAddressesCollection()->walk('delete'); $this->_setQuotePersistent = false; + $this->cleanCustomerData($quote); $quote->setIsActive(true) - ->setCustomerId(null) - ->setCustomerEmail(null) - ->setCustomerFirstname(null) - ->setCustomerLastname(null) - ->setCustomerGroupId(GroupInterface::NOT_LOGGED_IN_ID) ->setIsPersistent(false) ->removeAllAddresses(); + //Create guest addresses $quote->getShippingAddress(); $quote->getBillingAddress(); @@ -129,6 +136,27 @@ public function setGuest($checkQuote = false) $this->persistentSession->setSession(null); } + /** + * Clear customer data in quote + * + * @param Quote $quote + */ + private function cleanCustomerData($quote) + { + /** + * Set empty customer object in quote to avoid restore customer id + * @see Quote::beforeSave() + */ + if ($quote->getCustomerId()) { + $quote->setCustomer($this->customerDataFactory->create()); + } + $quote->setCustomerId(null) + ->setCustomerEmail(null) + ->setCustomerFirstname(null) + ->setCustomerLastname(null) + ->setCustomerGroupId(GroupInterface::NOT_LOGGED_IN_ID); + } + /** * Emulate guest cart with persistent cart * diff --git a/app/code/Magento/Persistent/Observer/MakePersistentQuoteGuestObserver.php b/app/code/Magento/Persistent/Observer/MakePersistentQuoteGuestObserver.php index f2f9b96fa82e4..98c9c3df27852 100644 --- a/app/code/Magento/Persistent/Observer/MakePersistentQuoteGuestObserver.php +++ b/app/code/Magento/Persistent/Observer/MakePersistentQuoteGuestObserver.php @@ -1,16 +1,14 @@ <?php /** - * * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ - namespace Magento\Persistent\Observer; use Magento\Framework\Event\ObserverInterface; /** - * Make persistent quote to be guest + * Make persistent quote to be guest * * @SuppressWarnings(PHPMD.CookieAndSessionMisuse) */ @@ -38,26 +36,26 @@ class MakePersistentQuoteGuestObserver implements ObserverInterface protected $_persistentData = null; /** - * @var \Magento\Persistent\Model\QuoteManager + * @var \Magento\Checkout\Model\Session */ - protected $quoteManager; + private $checkoutSession; /** * @param \Magento\Persistent\Helper\Session $persistentSession * @param \Magento\Persistent\Helper\Data $persistentData * @param \Magento\Customer\Model\Session $customerSession - * @param \Magento\Persistent\Model\QuoteManager $quoteManager + * @param \Magento\Checkout\Model\Session $checkoutSession */ public function __construct( \Magento\Persistent\Helper\Session $persistentSession, \Magento\Persistent\Helper\Data $persistentData, \Magento\Customer\Model\Session $customerSession, - \Magento\Persistent\Model\QuoteManager $quoteManager + \Magento\Checkout\Model\Session $checkoutSession ) { $this->_persistentSession = $persistentSession; $this->_persistentData = $persistentData; $this->_customerSession = $customerSession; - $this->quoteManager = $quoteManager; + $this->checkoutSession = $checkoutSession; } /** @@ -74,7 +72,7 @@ public function execute(\Magento\Framework\Event\Observer $observer) if (($this->_persistentSession->isPersistent() && !$this->_customerSession->isLoggedIn()) || $this->_persistentData->isShoppingCartPersist() ) { - $this->quoteManager->setGuest(true); + $this->checkoutSession->clearQuote()->clearStorage(); } } } diff --git a/app/code/Magento/Persistent/Observer/RemoveGuestPersistenceOnEmptyCartObserver.php b/app/code/Magento/Persistent/Observer/RemoveGuestPersistenceOnEmptyCartObserver.php index fe754711c910b..efc9ecd4c1a59 100644 --- a/app/code/Magento/Persistent/Observer/RemoveGuestPersistenceOnEmptyCartObserver.php +++ b/app/code/Magento/Persistent/Observer/RemoveGuestPersistenceOnEmptyCartObserver.php @@ -10,6 +10,8 @@ /** * Observer to remove persistent session if guest empties persistent cart previously created and added to by customer. + * + * @SuppressWarnings(PHPMD.CookieAndSessionMisuse) */ class RemoveGuestPersistenceOnEmptyCartObserver implements ObserverInterface { @@ -96,6 +98,8 @@ public function execute(\Magento\Framework\Event\Observer $observer) } if (!$cart || $cart->getItemsCount() == 0) { + $this->customerSession->setCustomerId(null) + ->setCustomerGroupId(null); $this->quoteManager->setGuest(); } } diff --git a/app/code/Magento/Persistent/Test/Mftf/Test/CheckShoppingCartBehaviorAfterSessionExpiredTest.xml b/app/code/Magento/Persistent/Test/Mftf/Test/CheckShoppingCartBehaviorAfterSessionExpiredTest.xml index 18e19c4276548..5b023e12bc55d 100644 --- a/app/code/Magento/Persistent/Test/Mftf/Test/CheckShoppingCartBehaviorAfterSessionExpiredTest.xml +++ b/app/code/Magento/Persistent/Test/Mftf/Test/CheckShoppingCartBehaviorAfterSessionExpiredTest.xml @@ -55,8 +55,8 @@ <actionGroup ref="ClickViewAndEditCartFromMiniCartActionGroup" stepKey="goToShoppingCartFromMinicart"/> <!--Reset cookies and refresh the page--> <resetCookie userInput="PHPSESSID" stepKey="resetCookieForCart"/> - <reloadPage stepKey="reloadPage"/> - <waitForPageLoad stepKey="waitForPageLoad"/> + <actionGroup ref="ReloadPageActionGroup" stepKey="reloadPage"/> + <comment userInput="Replacing reload action and preserve Backward Compatibility" stepKey="waitForPageLoad"/> <!--Check product exists in cart--> <see userInput="$$createProduct.name$$" stepKey="ProductExistsInCart"/> </test> diff --git a/app/code/Magento/Persistent/Test/Mftf/Test/StorefrontVerifyShoppingCartPersistenceUnderLongTermCookieTest.xml b/app/code/Magento/Persistent/Test/Mftf/Test/StorefrontVerifyShoppingCartPersistenceUnderLongTermCookieTest.xml index b3bf4e4dd009f..159b5b6b9e79b 100644 --- a/app/code/Magento/Persistent/Test/Mftf/Test/StorefrontVerifyShoppingCartPersistenceUnderLongTermCookieTest.xml +++ b/app/code/Magento/Persistent/Test/Mftf/Test/StorefrontVerifyShoppingCartPersistenceUnderLongTermCookieTest.xml @@ -131,7 +131,7 @@ <see selector="{{StorefrontMinicartSection.productCount}}" userInput="2" stepKey="miniCartContainsTwoProductForGuest"/> <!-- 10. Go to My Account section --> - <amOnPage url="{{StorefrontCustomerDashboardPage.url}}" stepKey="amOnCustomerAccountPage"/> + <actionGroup ref="StorefrontOpenMyAccountPageActionGroup" stepKey="amOnCustomerAccountPage"/> <seeInCurrentUrl url="{{StorefrontCustomerSignInPage.url}}" stepKey="redirectToCustomerAccountLoginPage"/> <seeElement selector="{{StorefrontCustomerSignInFormSection.customerLoginBlock}}" stepKey="checkSystemRequiresToLogIn"/> diff --git a/app/code/Magento/Persistent/Test/Unit/Model/QuoteManagerTest.php b/app/code/Magento/Persistent/Test/Unit/Model/QuoteManagerTest.php index 0c183084edca2..03d6ab02beb3c 100644 --- a/app/code/Magento/Persistent/Test/Unit/Model/QuoteManagerTest.php +++ b/app/code/Magento/Persistent/Test/Unit/Model/QuoteManagerTest.php @@ -9,6 +9,8 @@ namespace Magento\Persistent\Test\Unit\Model; use Magento\Checkout\Model\Session; +use Magento\Customer\Api\Data\CustomerInterface; +use Magento\Customer\Api\Data\CustomerInterfaceFactory; use Magento\Customer\Model\GroupManagement; use Magento\Eav\Model\Entity\Collection\AbstractCollection; use Magento\Persistent\Helper\Data; @@ -78,6 +80,11 @@ class QuoteManagerTest extends TestCase */ private $shippingAssignmentProcessor; + /** + * @var CustomerInterfaceFactory|MockObject + */ + private $customerDataFactory; + protected function setUp(): void { $this->persistentSessionMock = $this->createMock(\Magento\Persistent\Helper\Session::class); @@ -124,13 +131,15 @@ protected function setUp(): void 'getItemsQty', 'getExtensionAttributes', 'setExtensionAttributes', - '__wakeup' + '__wakeup', + 'setCustomer' ]) ->disableOriginalConstructor() ->getMock(); $this->cartExtensionFactory = $this->createPartialMock(CartExtensionFactory::class, ['create']); $this->shippingAssignmentProcessor = $this->createPartialMock(ShippingAssignmentProcessor::class, ['create']); + $this->customerDataFactory = $this->createMock(CustomerInterfaceFactory::class); $this->model = new QuoteManager( $this->persistentSessionMock, @@ -138,7 +147,8 @@ protected function setUp(): void $this->checkoutSessionMock, $this->quoteRepositoryMock, $this->cartExtensionFactory, - $this->shippingAssignmentProcessor + $this->shippingAssignmentProcessor, + $this->customerDataFactory ); } @@ -189,6 +199,7 @@ public function testSetGuestWhenShoppingCartAndQuoteAreNotPersistent() public function testSetGuest() { + $customerId = 22; $this->checkoutSessionMock->expects($this->once()) ->method('getQuote')->willReturn($this->quoteMock); $this->quoteMock->expects($this->once())->method('getId')->willReturn(11); @@ -220,6 +231,7 @@ public function testSetGuest() ->method('getShippingAddress')->willReturn($quoteAddressMock); $this->quoteMock->expects($this->once()) ->method('getBillingAddress')->willReturn($quoteAddressMock); + $this->quoteMock->method('getCustomerId')->willReturn($customerId); $this->quoteMock->expects($this->once())->method('collectTotals')->willReturn($this->quoteMock); $this->quoteRepositoryMock->expects($this->once())->method('save')->with($this->quoteMock); $this->persistentSessionMock->expects($this->once()) @@ -229,7 +241,6 @@ public function testSetGuest() $this->quoteMock->expects($this->once())->method('isVirtual')->willReturn(false); $this->quoteMock->expects($this->once())->method('getItemsQty')->willReturn(1); $extensionAttributes = $this->getMockBuilder(CartExtensionInterface::class) - ->addMethods(['getShippingAssignments', 'setShippingAssignments']) ->getMockForAbstractClass(); $shippingAssignment = $this->createMock(ShippingAssignmentInterface::class); $extensionAttributes->expects($this->once()) @@ -248,6 +259,11 @@ public function testSetGuest() $this->quoteMock->expects($this->once()) ->method('setExtensionAttributes') ->with($extensionAttributes); + $customerMock = $this->createMock(CustomerInterface::class); + $this->customerDataFactory->method('create')->willReturn($customerMock); + $this->quoteMock->expects($this->once()) + ->method('setCustomer') + ->with($customerMock); $this->model->setGuest(false); } diff --git a/app/code/Magento/Persistent/Test/Unit/Observer/MakePersistentQuoteGuestObserverTest.php b/app/code/Magento/Persistent/Test/Unit/Observer/MakePersistentQuoteGuestObserverTest.php index 3622fe66099a4..bb78447cf852f 100644 --- a/app/code/Magento/Persistent/Test/Unit/Observer/MakePersistentQuoteGuestObserverTest.php +++ b/app/code/Magento/Persistent/Test/Unit/Observer/MakePersistentQuoteGuestObserverTest.php @@ -1,6 +1,5 @@ <?php /** - * * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ @@ -8,12 +7,12 @@ namespace Magento\Persistent\Test\Unit\Observer; +use Magento\Checkout\Model\Session as CheckoutSession; use Magento\Framework\Event; use Magento\Framework\Event\Observer; use Magento\Persistent\Controller\Index; use Magento\Persistent\Helper\Data; use Magento\Persistent\Helper\Session; -use Magento\Persistent\Model\QuoteManager; use Magento\Persistent\Observer\MakePersistentQuoteGuestObserver; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -48,10 +47,10 @@ class MakePersistentQuoteGuestObserverTest extends TestCase /** * @var MockObject */ - protected $quoteManagerMock; + protected $checkoutSession; /** - * @var MockObject + * @var CheckoutSession|MockObject */ protected $eventManagerMock; @@ -60,6 +59,9 @@ class MakePersistentQuoteGuestObserverTest extends TestCase */ protected $actionMock; + /** + * @inheritdoc + */ protected function setUp(): void { $this->actionMock = $this->createMock(Index::class); @@ -67,7 +69,7 @@ protected function setUp(): void $this->sessionHelperMock = $this->createMock(Session::class); $this->helperMock = $this->createMock(Data::class); $this->customerSessionMock = $this->createMock(\Magento\Customer\Model\Session::class); - $this->quoteManagerMock = $this->createMock(QuoteManager::class); + $this->checkoutSession = $this->createMock(CheckoutSession::class); $this->eventManagerMock = $this->getMockBuilder(Event::class) ->addMethods(['getControllerAction']) @@ -81,7 +83,7 @@ protected function setUp(): void $this->sessionHelperMock, $this->helperMock, $this->customerSessionMock, - $this->quoteManagerMock + $this->checkoutSession ); } @@ -94,7 +96,8 @@ public function testExecute() $this->sessionHelperMock->expects($this->once())->method('isPersistent')->willReturn(true); $this->customerSessionMock->expects($this->once())->method('isLoggedIn')->willReturn(false); $this->helperMock->expects($this->never())->method('isShoppingCartPersist'); - $this->quoteManagerMock->expects($this->once())->method('setGuest')->with(true); + $this->checkoutSession->expects($this->once())->method('clearQuote')->willReturnSelf(); + $this->checkoutSession->expects($this->once())->method('clearStorage')->willReturnSelf(); $this->model->execute($this->observerMock); } @@ -107,7 +110,8 @@ public function testExecuteWhenShoppingCartIsPersist() $this->sessionHelperMock->expects($this->once())->method('isPersistent')->willReturn(true); $this->customerSessionMock->expects($this->once())->method('isLoggedIn')->willReturn(true); $this->helperMock->expects($this->once())->method('isShoppingCartPersist')->willReturn(true); - $this->quoteManagerMock->expects($this->once())->method('setGuest')->with(true); + $this->checkoutSession->expects($this->once())->method('clearQuote')->willReturnSelf(); + $this->checkoutSession->expects($this->once())->method('clearStorage')->willReturnSelf(); $this->model->execute($this->observerMock); } @@ -120,7 +124,8 @@ public function testExecuteWhenShoppingCartIsNotPersist() $this->sessionHelperMock->expects($this->once())->method('isPersistent')->willReturn(true); $this->customerSessionMock->expects($this->once())->method('isLoggedIn')->willReturn(true); $this->helperMock->expects($this->once())->method('isShoppingCartPersist')->willReturn(false); - $this->quoteManagerMock->expects($this->never())->method('setGuest'); + $this->checkoutSession->expects($this->never())->method('clearQuote')->willReturnSelf(); + $this->checkoutSession->expects($this->never())->method('clearStorage')->willReturnSelf(); $this->model->execute($this->observerMock); } } diff --git a/app/code/Magento/Persistent/Test/Unit/Observer/RemoveGuestPersistenceOnEmptyCartObserverTest.php b/app/code/Magento/Persistent/Test/Unit/Observer/RemoveGuestPersistenceOnEmptyCartObserverTest.php index 4adc806fed415..7bef8feaaacc5 100644 --- a/app/code/Magento/Persistent/Test/Unit/Observer/RemoveGuestPersistenceOnEmptyCartObserverTest.php +++ b/app/code/Magento/Persistent/Test/Unit/Observer/RemoveGuestPersistenceOnEmptyCartObserverTest.php @@ -137,6 +137,13 @@ public function testExecuteWithEmptyCart() ->with($customerId) ->willReturn($quoteMock); $quoteMock->expects($this->once())->method('getItemsCount')->willReturn($emptyCount); + $this->customerSessionMock->expects($this->once()) + ->method('setCustomerId') + ->with(null) + ->willReturnSelf(); + $this->customerSessionMock->expects($this->once()) + ->method('setCustomerGroupId') + ->with(null); $this->quoteManagerMock->expects($this->once())->method('setGuest'); $this->model->execute($this->observerMock); @@ -160,6 +167,13 @@ public function testExecuteWithNonexistentCart() ->method('getActiveForCustomer') ->with($customerId) ->willThrowException($exception); + $this->customerSessionMock->expects($this->once()) + ->method('setCustomerId') + ->with(null) + ->willReturnSelf(); + $this->customerSessionMock->expects($this->once()) + ->method('setCustomerGroupId') + ->with(null); $this->quoteManagerMock->expects($this->once())->method('setGuest'); $this->model->execute($this->observerMock); diff --git a/app/code/Magento/Persistent/view/frontend/templates/additional.phtml b/app/code/Magento/Persistent/view/frontend/templates/additional.phtml index 40c8674bc025a..61feeae04369d 100644 --- a/app/code/Magento/Persistent/view/frontend/templates/additional.phtml +++ b/app/code/Magento/Persistent/view/frontend/templates/additional.phtml @@ -4,13 +4,9 @@ * See COPYING.txt for license details. */ ?> -<?php if ($block->getCustomerId()) :?> - <span> - <a <?= /* @noEscape */ $block->getLinkAttributes()?>><?= $block->escapeHtml(__('Not you?'));?></a> - </span> -<?php endif;?> <script type="application/javascript"> - window.persistent = <?= /* @noEscape */ $block->getConfig(); ?>; + window.persistent = <?=/* @noEscape */ $block->getConfig()?>; + window.notYouLink = '<?=/* @noEscape */ $block->getLinkAttributes()?>'; </script> <script type="text/x-magento-init"> { diff --git a/app/code/Magento/Persistent/view/frontend/web/js/view/additional-welcome.js b/app/code/Magento/Persistent/view/frontend/web/js/view/additional-welcome.js index 7ace6e60d1c39..8e69325860167 100644 --- a/app/code/Magento/Persistent/view/frontend/web/js/view/additional-welcome.js +++ b/app/code/Magento/Persistent/view/frontend/web/js/view/additional-welcome.js @@ -40,6 +40,7 @@ define([ $(this).attr('data-bind', html); $(this).html(html); + $(this).after(' <span><a ' + window.notYouLink + '>' + $t('Not you?') + '</a></span>'); }); } } diff --git a/app/code/Magento/ProductVideo/Test/Unit/Block/Product/View/GalleryTest.php b/app/code/Magento/ProductVideo/Test/Unit/Block/Product/View/GalleryTest.php index 6a65fff7c5ebc..30d0573b62d87 100644 --- a/app/code/Magento/ProductVideo/Test/Unit/Block/Product/View/GalleryTest.php +++ b/app/code/Magento/ProductVideo/Test/Unit/Block/Product/View/GalleryTest.php @@ -94,7 +94,7 @@ public function testGetMediaGalleryDataJson() $data = [ [ 'media_type' => 'external-video', - 'video_url' => 'http://magento.ce/pub/media/catalog/product/9/b/9br6ujuthnc.jpg', + 'video_url' => 'http://magento.ce/media/catalog/product/9/b/9br6ujuthnc.jpg', 'is_base' => true, ], [ diff --git a/app/code/Magento/ProductVideo/Test/Unit/Controller/Adminhtml/Product/Gallery/RetrieveImageTest.php b/app/code/Magento/ProductVideo/Test/Unit/Controller/Adminhtml/Product/Gallery/RetrieveImageTest.php index 519a8cba014f2..770eaf1d3d342 100644 --- a/app/code/Magento/ProductVideo/Test/Unit/Controller/Adminhtml/Product/Gallery/RetrieveImageTest.php +++ b/app/code/Magento/ProductVideo/Test/Unit/Controller/Adminhtml/Product/Gallery/RetrieveImageTest.php @@ -99,11 +99,20 @@ class RetrieveImageTest extends TestCase */ private $fileDriverMock; - /** - * Set up - */ + private function setupObjectManagerForCheckImageExist($return) + { + $objectManagerMock = $this->getMockForAbstractClass(ObjectManagerInterface::class); + $mockFileSystem = $this->createMock(Filesystem::class); + $mockRead = $this->createMock(ReadInterface::class); + $objectManagerMock->method($this->logicalOr('get', 'create'))->willReturn($mockFileSystem); + $mockFileSystem->method('getDirectoryRead')->willReturn($mockRead); + $mockRead->method('isExist')->willReturn($return); + \Magento\Framework\App\ObjectManager::setInstance($objectManagerMock); + } + protected function setUp(): void { + $this->setupObjectManagerForCheckImageExist(false); $objectManager = new ObjectManager($this); $this->contextMock = $this->createMock(Context::class); $this->validatorMock = $this @@ -181,20 +190,8 @@ public function testExecuteInvalidFileImage() $this->request->expects($this->any())->method('getParam')->willReturn( 'https://example.com/test.jpg' ); - $readInterface = $this->createMock( - ReadInterface::class, - [], - [], - '', - false - ); - $writeInterface = $this->createMock( - WriteInterface::class, - [], - [], - '', - false - ); + $readInterface = $this->createMock(ReadInterface::class); + $writeInterface = $this->createMock(WriteInterface::class); $this->filesystemMock->expects($this->any())->method('getDirectoryRead')->willReturn($readInterface); $readInterface->expects($this->any())->method('getAbsolutePath')->willReturn(''); $this->abstractAdapter->expects($this->any()) @@ -217,20 +214,8 @@ public function testExecuteInvalidFileType() $this->request->expects($this->any())->method('getParam')->willReturn( 'https://example.com/test.php' ); - $readInterface = $this->createMock( - ReadInterface::class, - [], - [], - '', - false - ); - $writeInterface = $this->createMock( - WriteInterface::class, - [], - [], - '', - false - ); + $readInterface = $this->createMock(ReadInterface::class); + $writeInterface = $this->createMock(WriteInterface::class); $this->filesystemMock->expects($this->any())->method('getDirectoryRead')->willReturn($readInterface); $readInterface->expects($this->any())->method('getAbsolutePath')->willReturn(''); $this->abstractAdapter->expects($this->never())->method('validateUploadFile'); diff --git a/app/code/Magento/ProductVideo/view/adminhtml/templates/helper/gallery.phtml b/app/code/Magento/ProductVideo/view/adminhtml/templates/helper/gallery.phtml index b729eadf122c5..b75b59eeacce2 100644 --- a/app/code/Magento/ProductVideo/view/adminhtml/templates/helper/gallery.phtml +++ b/app/code/Magento/ProductVideo/view/adminhtml/templates/helper/gallery.phtml @@ -268,7 +268,8 @@ $elementToggleCode = $element->getToggleCode() ? $element->getToggleCode(): </fieldset> </div> </script> - <div id="new_video_<?= /* @noEscape */ $block->getNewVideoBlockName() ?>"> + <?php $videoBlockId = "new_video_" . $block->getHtmlId() . rand(); ?> + <div id="<?= /* @noEscape */ $videoBlockId ?>"> <?= $block->getFormHtml() ?> <div id="video-player-preview-location" class="video-player-sidebar"> <div class="video-player-container"></div> @@ -288,7 +289,7 @@ $elementToggleCode = $element->getToggleCode() ? $element->getToggleCode(): </div> <?= /* @noEscape */ $secureRenderer->renderStyleAsTag( 'display:none', - 'div#new_video_' . /* @noEscape */ $block->getNewVideoBlockName() + 'div#' . $videoBlockId ) ?> <?= $block->getChildHtml('new-video') ?> 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 5756356d4ff24..cb56a085304a7 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 @@ -129,7 +129,9 @@ define([ * Abstract destroying command */ destroy: function () { - this._player.destroy(); + if (this._player) { + this._player.destroy(); + } }, /** @@ -288,7 +290,10 @@ define([ */ destroy: function () { this.stop(); - this._player.destroy(); + + if (this._player) { + this._player.destroy(); + } } }); diff --git a/app/code/Magento/ProductVideo/view/adminhtml/web/js/new-video-dialog.js b/app/code/Magento/ProductVideo/view/adminhtml/web/js/new-video-dialog.js index 9cc731dde4b0c..562bff2e1d472 100644 --- a/app/code/Magento/ProductVideo/view/adminhtml/web/js/new-video-dialog.js +++ b/app/code/Magento/ProductVideo/view/adminhtml/web/js/new-video-dialog.js @@ -580,7 +580,13 @@ define([ * @private */ _onImageLoaded: function (result, file, oldFile, callback) { - var data = JSON.parse(result); + var data; + + try { + data = JSON.parse(result); + } catch (e) { + data = result; + } if (this.element.find('#video_url').parent().find('.image-upload-error').length > 0) { this.element.find('.image-upload-error').remove(); diff --git a/app/code/Magento/Quote/Model/Cart/BuyRequest/BuyRequestBuilder.php b/app/code/Magento/Quote/Model/Cart/BuyRequest/BuyRequestBuilder.php index 13b19e4f79c9a..7e8b4d916334f 100644 --- a/app/code/Magento/Quote/Model/Cart/BuyRequest/BuyRequestBuilder.php +++ b/app/code/Magento/Quote/Model/Cart/BuyRequest/BuyRequestBuilder.php @@ -56,6 +56,6 @@ public function build(CartItem $cartItem): DataObject $requestData[] = $provider->execute($cartItem); } - return $this->dataObjectFactory->create(['data' => array_merge(...$requestData)]); + return $this->dataObjectFactory->create(['data' => array_merge([], ...$requestData)]); } } diff --git a/app/code/Magento/Quote/Model/Cart/Totals/ItemConverter.php b/app/code/Magento/Quote/Model/Cart/Totals/ItemConverter.php index 678c92250f531..ccc4735ad1763 100644 --- a/app/code/Magento/Quote/Model/Cart/Totals/ItemConverter.php +++ b/app/code/Magento/Quote/Model/Cart/Totals/ItemConverter.php @@ -68,7 +68,7 @@ public function __construct( } /** - * Converts a specified rate model to a shipping method data object. + * Converts a specified quote item model to a totals item data object. * * @param \Magento\Quote\Model\Quote\Item $item * @return \Magento\Quote\Api\Data\TotalsItemInterface diff --git a/app/code/Magento/Quote/Model/Quote/Address.php b/app/code/Magento/Quote/Model/Quote/Address.php index 5476915d9d649..aee86eb1f8935 100644 --- a/app/code/Magento/Quote/Model/Quote/Address.php +++ b/app/code/Magento/Quote/Model/Quote/Address.php @@ -139,6 +139,8 @@ class Address extends AbstractAddress implements const ADDRESS_TYPE_BILLING = 'billing'; const ADDRESS_TYPE_SHIPPING = 'shipping'; + + private const CACHED_ITEMS_ALL = 'cached_items_all'; /** * Prefix of model events @@ -636,8 +638,7 @@ public function getItemsCollection() public function getAllItems() { // We calculate item list once and cache it in three arrays - all items - $key = 'cached_items_all'; - if (!$this->hasData($key)) { + if (!$this->hasData(self::CACHED_ITEMS_ALL)) { $quoteItems = $this->getQuote()->getItemsCollection(); $addressItems = $this->getItemsCollection(); @@ -676,10 +677,10 @@ public function getAllItems() } // Cache calculated lists - $this->setData('cached_items_all', $items); + $this->setData(self::CACHED_ITEMS_ALL, $items); } - $items = $this->getData($key); + $items = $this->getData(self::CACHED_ITEMS_ALL); return $items; } diff --git a/app/code/Magento/Quote/Model/QuoteIdMask.php b/app/code/Magento/Quote/Model/QuoteIdMask.php index 47b02db7650df..8fa0b1fbba80c 100644 --- a/app/code/Magento/Quote/Model/QuoteIdMask.php +++ b/app/code/Magento/Quote/Model/QuoteIdMask.php @@ -10,7 +10,7 @@ * QuoteIdMask model * * @method string getMaskedId() - * @method QuoteIdMask setMaskedId() + * @method QuoteIdMask setMaskedId(string $id) */ class QuoteIdMask extends \Magento\Framework\Model\AbstractModel { diff --git a/app/code/Magento/Quote/Model/QuoteManagement.php b/app/code/Magento/Quote/Model/QuoteManagement.php index b0aef022dcd25..1d4b8feba07f5 100644 --- a/app/code/Magento/Quote/Model/QuoteManagement.php +++ b/app/code/Magento/Quote/Model/QuoteManagement.php @@ -8,6 +8,7 @@ namespace Magento\Quote\Model; use Magento\Authorization\Model\UserContextInterface; +use Magento\Customer\Api\Data\GroupInterface; use Magento\Framework\App\ObjectManager; use Magento\Framework\Event\ManagerInterface as EventManager; use Magento\Framework\Exception\CouldNotSaveException; @@ -396,7 +397,8 @@ public function placeOrder($cartId, PaymentInterface $paymentMethod = null) } } $quote->setCustomerIsGuest(true); - $quote->setCustomerGroupId(\Magento\Customer\Api\Data\GroupInterface::NOT_LOGGED_IN_ID); + $groupId = $quote->getCustomer()->getGroupId() ?: GroupInterface::NOT_LOGGED_IN_ID; + $quote->setCustomerGroupId($groupId); } $remoteAddress = $this->remoteAddress->getRemoteAddress(); diff --git a/app/code/Magento/Quote/Model/QuoteRepository.php b/app/code/Magento/Quote/Model/QuoteRepository.php index 0dd2b00a596ea..1533194023e3e 100644 --- a/app/code/Magento/Quote/Model/QuoteRepository.php +++ b/app/code/Magento/Quote/Model/QuoteRepository.php @@ -25,7 +25,7 @@ use Magento\Store\Model\StoreManagerInterface; /** - * Quote repository. + * Repository for quote entity. * * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ @@ -146,10 +146,16 @@ public function get($cartId, array $sharedStoreIds = []) public function getForCustomer($customerId, array $sharedStoreIds = []) { if (!isset($this->quotesByCustomerId[$customerId])) { - $quote = $this->loadQuote('loadByCustomer', 'customerId', $customerId, $sharedStoreIds); - $this->getLoadHandler()->load($quote); - $this->quotesById[$quote->getId()] = $quote; - $this->quotesByCustomerId[$customerId] = $quote; + $customerQuote = $this->loadQuote('loadByCustomer', 'customerId', $customerId, $sharedStoreIds); + $customerQuoteId = $customerQuote->getId(); + //prevent loading quote items for same quote + if (isset($this->quotesById[$customerQuoteId])) { + $customerQuote = $this->quotesById[$customerQuoteId]; + } else { + $this->getLoadHandler()->load($customerQuote); + } + $this->quotesById[$customerQuoteId] = $customerQuote; + $this->quotesByCustomerId[$customerId] = $customerQuote; } return $this->quotesByCustomerId[$customerId]; } diff --git a/app/code/Magento/Quote/Observer/Frontend/Quote/Address/CollectTotalsObserver.php b/app/code/Magento/Quote/Observer/Frontend/Quote/Address/CollectTotalsObserver.php index c19dbc2c429ae..d938ad7d638f1 100644 --- a/app/code/Magento/Quote/Observer/Frontend/Quote/Address/CollectTotalsObserver.php +++ b/app/code/Magento/Quote/Observer/Frontend/Quote/Address/CollectTotalsObserver.php @@ -5,7 +5,16 @@ */ namespace Magento\Quote\Observer\Frontend\Quote\Address; +use Magento\Customer\Api\AddressRepositoryInterface; +use Magento\Customer\Api\Data\CustomerInterfaceFactory; +use Magento\Customer\Api\GroupManagementInterface; +use Magento\Customer\Helper\Address; +use Magento\Customer\Model\Session; +use Magento\Customer\Model\Vat; +use Magento\Framework\Event\Observer; use Magento\Framework\Event\ObserverInterface; +use Magento\Quote\Api\Data\ShippingAssignmentInterface; +use Magento\Quote\Model\Quote; /** * Handle customer VAT number on collect_totals_before event of quote address. @@ -15,22 +24,22 @@ class CollectTotalsObserver implements ObserverInterface { /** - * @var \Magento\Customer\Api\AddressRepositoryInterface + * @var AddressRepositoryInterface */ private $addressRepository; /** - * @var \Magento\Customer\Model\Session + * @var Session */ private $customerSession; /** - * @var \Magento\Customer\Helper\Address + * @var Address */ protected $customerAddressHelper; /** - * @var \Magento\Customer\Model\Vat + * @var Vat */ protected $customerVat; @@ -40,36 +49,36 @@ class CollectTotalsObserver implements ObserverInterface protected $vatValidator; /** - * @var \Magento\Customer\Api\Data\CustomerInterfaceFactory + * @var CustomerInterfaceFactory */ protected $customerDataFactory; /** * Group Management * - * @var \Magento\Customer\Api\GroupManagementInterface + * @var GroupManagementInterface */ protected $groupManagement; /** * Initialize dependencies. * - * @param \Magento\Customer\Helper\Address $customerAddressHelper - * @param \Magento\Customer\Model\Vat $customerVat + * @param Address $customerAddressHelper + * @param Vat $customerVat * @param VatValidator $vatValidator - * @param \Magento\Customer\Api\Data\CustomerInterfaceFactory $customerDataFactory - * @param \Magento\Customer\Api\GroupManagementInterface $groupManagement - * @param \Magento\Customer\Api\AddressRepositoryInterface $addressRepository - * @param \Magento\Customer\Model\Session $customerSession + * @param CustomerInterfaceFactory $customerDataFactory + * @param GroupManagementInterface $groupManagement + * @param AddressRepositoryInterface $addressRepository + * @param Session $customerSession */ public function __construct( - \Magento\Customer\Helper\Address $customerAddressHelper, - \Magento\Customer\Model\Vat $customerVat, + Address $customerAddressHelper, + Vat $customerVat, VatValidator $vatValidator, - \Magento\Customer\Api\Data\CustomerInterfaceFactory $customerDataFactory, - \Magento\Customer\Api\GroupManagementInterface $groupManagement, - \Magento\Customer\Api\AddressRepositoryInterface $addressRepository, - \Magento\Customer\Model\Session $customerSession + CustomerInterfaceFactory $customerDataFactory, + GroupManagementInterface $groupManagement, + AddressRepositoryInterface $addressRepository, + Session $customerSession ) { $this->customerVat = $customerVat; $this->customerAddressHelper = $customerAddressHelper; @@ -83,25 +92,23 @@ public function __construct( /** * Handle customer VAT number if needed on collect_totals_before event of quote address * - * @param \Magento\Framework\Event\Observer $observer + * @param Observer $observer * @return void * @SuppressWarnings(PHPMD.CyclomaticComplexity) */ - public function execute(\Magento\Framework\Event\Observer $observer) + public function execute(Observer $observer) { - /** @var \Magento\Quote\Api\Data\ShippingAssignmentInterface $shippingAssignment */ + /** @var ShippingAssignmentInterface $shippingAssignment */ $shippingAssignment = $observer->getShippingAssignment(); - /** @var \Magento\Quote\Model\Quote $quote */ + /** @var Quote $quote */ $quote = $observer->getQuote(); - /** @var \Magento\Quote\Model\Quote\Address $address */ + /** @var Quote\Address $address */ $address = $shippingAssignment->getShipping()->getAddress(); $customer = $quote->getCustomer(); $storeId = $customer->getStoreId(); - if ($customer->getDisableAutoGroupChange() - || false == $this->vatValidator->isEnabled($address, $storeId) - ) { + if ($customer->getDisableAutoGroupChange() || !$this->vatValidator->isEnabled($address, $storeId)) { return; } $customerCountryCode = $address->getCountryId(); @@ -135,6 +142,7 @@ public function execute(\Magento\Framework\Event\Observer $observer) $quote->setCustomerGroupId($groupId); $this->customerSession->setCustomerGroupId($groupId); $customer->setGroupId($groupId); + $customer->setEmail($customer->getEmail() ?: $quote->getCustomerEmail()); $quote->setCustomer($customer); } } diff --git a/app/code/Magento/Quote/Plugin/UpdateQuoteItemStore.php b/app/code/Magento/Quote/Plugin/UpdateQuoteItemStore.php deleted file mode 100644 index 19a7e03264d8a..0000000000000 --- a/app/code/Magento/Quote/Plugin/UpdateQuoteItemStore.php +++ /dev/null @@ -1,72 +0,0 @@ -<?php -/** - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. - */ -declare(strict_types=1); - -namespace Magento\Quote\Plugin; - -use Magento\Checkout\Model\Session; -use Magento\Quote\Model\QuoteRepository; -use Magento\Store\Api\Data\StoreInterface; -use Magento\Store\Model\StoreSwitcherInterface; - -/** - * Updates quote items store id. - * - * @SuppressWarnings(PHPMD.CookieAndSessionMisuse) - */ -class UpdateQuoteItemStore -{ - /** - * @var QuoteRepository - */ - private $quoteRepository; - - /** - * @var Session - */ - private $checkoutSession; - - /** - * @param QuoteRepository $quoteRepository - * @param Session $checkoutSession - */ - public function __construct( - QuoteRepository $quoteRepository, - Session $checkoutSession - ) { - $this->quoteRepository = $quoteRepository; - $this->checkoutSession = $checkoutSession; - } - - /** - * Update store id in active quote after store view switching. - * - * @param StoreSwitcherInterface $subject - * @param string $result - * @param StoreInterface $fromStore store where we came from - * @param StoreInterface $targetStore store where to go to - * @param string $redirectUrl original url requested for redirect after switching - * @return string url to be redirected after switching - * @SuppressWarnings(PHPMD.UnusedFormalParameter) - */ - public function afterSwitch( - StoreSwitcherInterface $subject, - $result, - StoreInterface $fromStore, - StoreInterface $targetStore, - string $redirectUrl - ): string { - $quote = $this->checkoutSession->getQuote(); - if ($quote->getIsActive()) { - $quote->setStoreId( - $targetStore->getId() - ); - $quote->getItemsCollection(false); - $this->quoteRepository->save($quote); - } - return $result; - } -} diff --git a/app/code/Magento/Quote/Setup/Patch/Data/WishlistDataCleanUp.php b/app/code/Magento/Quote/Setup/Patch/Data/WishlistDataCleanUp.php new file mode 100644 index 0000000000000..3d66f53ff6c6d --- /dev/null +++ b/app/code/Magento/Quote/Setup/Patch/Data/WishlistDataCleanUp.php @@ -0,0 +1,154 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); +namespace Magento\Quote\Setup\Patch\Data; + +use Magento\Framework\DB\Query\Generator; +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\Serialize\Serializer\Json; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Quote\Setup\QuoteSetupFactory; +use Psr\Log\LoggerInterface; + +/** + * Class Clean Up Data Removes unused data + */ +class WishlistDataCleanUp implements DataPatchInterface +{ + /** + * Batch size for query + */ + private const BATCH_SIZE = 1000; + + /** + * @var QuoteSetupFactory + */ + private $quoteSetupFactory; + + /** + * @var Generator + */ + private $queryGenerator; + + /** + * @var Json + */ + private $json; + + /** + * @var LoggerInterface + */ + private $logger; + + /** + * RemoveData constructor. + * @param Json $json + * @param Generator $queryGenerator + * @param QuoteSetupFactory $quoteSetupFactory + * @param LoggerInterface $logger + */ + public function __construct( + Json $json, + Generator $queryGenerator, + QuoteSetupFactory $quoteSetupFactory, + LoggerInterface $logger + ) { + $this->json = $json; + $this->queryGenerator = $queryGenerator; + $this->quoteSetupFactory = $quoteSetupFactory; + $this->logger = $logger; + } + + /** + * @inheritdoc + */ + public function apply() + { + try { + $this->cleanQuoteItemOptionTable(); + } catch (\Throwable $e) { + $this->logger->warning( + 'Quote module WishlistDataCleanUp patch experienced an error and could not be completed.' + . ' Please submit a support ticket or email us at security@magento.com.' + ); + + return $this; + } + + return $this; + } + + /** + * Remove login data from quote_item_option table. + * + * @throws LocalizedException + */ + private function cleanQuoteItemOptionTable() + { + $quoteSetup = $this->quoteSetupFactory->create(); + $tableName = $quoteSetup->getTable('quote_item_option'); + $select = $quoteSetup + ->getConnection() + ->select() + ->from( + $tableName, + ['option_id', 'value'] + ) + ->where( + 'value LIKE ?', + '%login%' + ); + $iterator = $this->queryGenerator->generate('option_id', $select, self::BATCH_SIZE); + $rowErrorFlag = false; + foreach ($iterator as $selectByRange) { + $optionRows = $quoteSetup->getConnection()->fetchAll($selectByRange); + foreach ($optionRows as $optionRow) { + try { + $rowValue = $this->json->unserialize($optionRow['value']); + if (is_array($rowValue) + && array_key_exists('login', $rowValue) + ) { + unset($rowValue['login']); + } + $rowValue = $this->json->serialize($rowValue); + $quoteSetup->getConnection()->update( + $tableName, + ['value' => $rowValue], + ['option_id = ?' => $optionRow['option_id']] + ); + } catch (\Throwable $e) { + $rowErrorFlag = true; + continue; + } + } + } + if ($rowErrorFlag) { + $this->logger->warning( + 'Data clean up could not be completed due to unexpected data format in the table "' + . $tableName + . '". Please submit a support ticket or email us at security@magento.com.' + ); + } + } + + /** + * @inheritdoc + */ + public static function getDependencies() + { + return [ + ConvertSerializedDataToJson::class + ]; + } + + /** + * @inheritdoc + */ + public function getAliases() + { + return []; + } +} diff --git a/app/code/Magento/Quote/Test/Mftf/Test/StorefrontGuestCheckoutDisabledProductTest.xml b/app/code/Magento/Quote/Test/Mftf/Test/StorefrontGuestCheckoutDisabledProductTest.xml index 80af412439338..ee5f2fccfe203 100644 --- a/app/code/Magento/Quote/Test/Mftf/Test/StorefrontGuestCheckoutDisabledProductTest.xml +++ b/app/code/Magento/Quote/Test/Mftf/Test/StorefrontGuestCheckoutDisabledProductTest.xml @@ -124,8 +124,8 @@ <closeTab stepKey="closeTab"/> <!-- Check cart --> <wait time="60" stepKey="waitForCartToBeUpdated"/> - <reloadPage stepKey="reloadPage"/> - <waitForPageLoad stepKey="waitForCheckoutPageReload"/> + <actionGroup ref="ReloadPageActionGroup" stepKey="reloadPage"/> + <comment userInput="Replacing reload action and preserve Backward Compatibility" stepKey="waitForCheckoutPageReload"/> <click selector="{{StorefrontMinicartSection.showCart}}" stepKey="clickMiniCart"/> <dontSeeElement selector="{{StorefrontMinicartSection.quantity}}" stepKey="dontSeeCartItem"/> <!-- Add simple product to shopping cart --> @@ -151,8 +151,9 @@ <closeTab stepKey="closeTab2"/> <!--Check cart--> <wait time="60" stepKey="waitForCartToBeUpdated2"/> - <reloadPage stepKey="reloadPage2"/> - <waitForPageLoad stepKey="waitForCheckoutPageReload2"/> + <actionGroup ref="ReloadPageActionGroup" stepKey="reloadPage2"/> + <comment userInput="Replacing reload action and preserve Backward Compatibility" stepKey="waitForCheckoutPageReload2"/> + <click selector="{{StorefrontMinicartSection.showCart}}" stepKey="clickMiniCart2"/> <dontSeeElement selector="{{StorefrontMinicartSection.quantity}}" stepKey="dontSeeCartItem2"/> </test> diff --git a/app/code/Magento/Quote/Test/Unit/Model/QuoteManagementTest.php b/app/code/Magento/Quote/Test/Unit/Model/QuoteManagementTest.php index ea758f7ce34f3..4197af2f2848a 100644 --- a/app/code/Magento/Quote/Test/Unit/Model/QuoteManagementTest.php +++ b/app/code/Magento/Quote/Test/Unit/Model/QuoteManagementTest.php @@ -247,6 +247,7 @@ protected function setUp(): void 'getPayment', 'setCheckoutMethod', 'setCustomerIsGuest', + 'getCustomer', 'getId' ] ) @@ -799,6 +800,12 @@ public function testPlaceOrderIfCustomerIsGuest() $this->quoteMock->expects($this->once()) ->method('getCheckoutMethod') ->willReturn(Onepage::METHOD_GUEST); + $customerMock = $this->getMockBuilder(Customer::class) + ->disableOriginalConstructor() + ->getMock(); + $this->quoteMock->expects($this->once()) + ->method('getCustomer') + ->willReturn($customerMock); $this->quoteMock->expects($this->once())->method('setCustomerId')->with(null)->willReturnSelf(); $this->quoteMock->expects($this->once())->method('setCustomerEmail')->with($email)->willReturnSelf(); @@ -866,6 +873,9 @@ public function testPlaceOrderIfCustomerIsGuest() $this->assertEquals($orderId, $service->placeOrder($cartId)); } + /** + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ public function testPlaceOrder() { $cartId = 323; diff --git a/app/code/Magento/Quote/Test/Unit/Model/QuoteRepositoryTest.php b/app/code/Magento/Quote/Test/Unit/Model/QuoteRepositoryTest.php index e19fb93255eb2..add00ba71d0fb 100644 --- a/app/code/Magento/Quote/Test/Unit/Model/QuoteRepositoryTest.php +++ b/app/code/Magento/Quote/Test/Unit/Model/QuoteRepositoryTest.php @@ -33,6 +33,7 @@ /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) * @SuppressWarnings(PHPMD.TooManyMethods) + * @SuppressWarnings(PHPMD.TooManyPublicMethods) */ class QuoteRepositoryTest extends TestCase { @@ -223,14 +224,44 @@ public function testGet() static::assertEquals($this->quoteMock, $this->model->get($cartId)); } - public function testGetForCustomerAfterGet() + /** + * @param int $quoteId + * @param int $customerQuoteId + * @param bool $isSame + * @dataProvider getForCustomerAfterGetDataProvider + */ + public function testGetForCustomerAfterGet(int $quoteId, int $customerQuoteId, bool $isSame) { - $cartId = 15; $customerId = 23; + $customerQuote = $this->getMockBuilder(Quote::class) + ->addMethods( + [ + 'setSharedStoreIds', + 'getCustomerId' + ] + ) + ->onlyMethods( + [ + 'load', + 'loadByIdWithoutStore', + 'loadByCustomer', + 'getIsActive', + 'getId', + 'save', + 'delete', + 'getStoreId', + 'getData' + ] + ) + ->disableOriginalConstructor() + ->getMock(); $this->cartFactoryMock->expects(static::exactly(2)) ->method('create') - ->willReturn($this->quoteMock); + ->willReturnOnConsecutiveCalls( + $this->quoteMock, + $customerQuote + ); $this->storeManagerMock->expects(static::exactly(2)) ->method('getStore') ->willReturn($this->storeMock); @@ -241,24 +272,34 @@ public function testGetForCustomerAfterGet() ->method('setSharedStoreIds'); $this->quoteMock->expects(static::once()) ->method('loadByIdWithoutStore') - ->with($cartId) - ->willReturn($this->storeMock); - $this->quoteMock->expects(static::once()) + ->with($quoteId) + ->willReturnSelf(); + $customerQuote->expects(static::once()) ->method('loadByCustomer') ->with($customerId) - ->willReturn($this->storeMock); - $this->quoteMock->expects(static::exactly(3)) - ->method('getId') - ->willReturn($cartId); - $this->quoteMock->expects(static::any()) - ->method('getCustomerId') + ->willReturnSelf(); + $this->quoteMock->method('getId') + ->willReturn($quoteId); + $customerQuote->method('getId') + ->willReturn($customerQuoteId); + $this->quoteMock->method('getCustomerId') + ->willReturn($customerId); + $customerQuote->method('getCustomerId') ->willReturn($customerId); - $this->loadHandlerMock->expects(static::exactly(2)) + $this->loadHandlerMock->expects($isSame ? $this->once() : $this->exactly(2)) ->method('load') ->with($this->quoteMock); - static::assertEquals($this->quoteMock, $this->model->get($cartId)); - static::assertEquals($this->quoteMock, $this->model->getForCustomer($customerId)); + static::assertSame($this->quoteMock, $this->model->get($quoteId)); + static::assertSame($isSame ? $this->quoteMock : $customerQuote, $this->model->getForCustomer($customerId)); + } + + public function getForCustomerAfterGetDataProvider(): array + { + return [ + [15, 15, true], + [15, 16, false], + ]; } public function testGetWithSharedStoreIds() diff --git a/app/code/Magento/Quote/etc/frontend/di.xml b/app/code/Magento/Quote/etc/frontend/di.xml index ecad94fbbc249..125afb96f20fd 100644 --- a/app/code/Magento/Quote/etc/frontend/di.xml +++ b/app/code/Magento/Quote/etc/frontend/di.xml @@ -12,9 +12,6 @@ <argument name="checkoutSession" xsi:type="object">Magento\Checkout\Model\Session\Proxy</argument> </arguments> </type> - <type name="Magento\Store\Model\StoreSwitcherInterface"> - <plugin name="update_quote_item_store_after_switch_store_view" type="Magento\Quote\Plugin\UpdateQuoteItemStore"/> - </type> <type name="Magento\Store\Api\StoreCookieManagerInterface"> <plugin name="update_quote_store_after_switch_store_view" type="Magento\Quote\Plugin\UpdateQuoteStore"/> </type> diff --git a/app/code/Magento/QuoteGraphQl/Model/Cart/AddSimpleProductToCart.php b/app/code/Magento/QuoteGraphQl/Model/Cart/AddSimpleProductToCart.php index 83c1d03f132db..f2dd6389d2c4a 100644 --- a/app/code/Magento/QuoteGraphQl/Model/Cart/AddSimpleProductToCart.php +++ b/app/code/Magento/QuoteGraphQl/Model/Cart/AddSimpleProductToCart.php @@ -16,7 +16,7 @@ use Magento\QuoteGraphQl\Model\Cart\BuyRequest\BuyRequestBuilder; /** - * Add simple product to cart + * Add simple product to cart mutation */ class AddSimpleProductToCart { @@ -52,6 +52,7 @@ public function __construct( */ public function execute(Quote $cart, array $cartItemData): void { + $cartItemData['model'] = $cart; $sku = $this->extractSku($cartItemData); try { diff --git a/app/code/Magento/QuoteGraphQl/Model/Cart/BuyRequest/BuyRequestBuilder.php b/app/code/Magento/QuoteGraphQl/Model/Cart/BuyRequest/BuyRequestBuilder.php index c14cc1324732c..c4909eef31287 100644 --- a/app/code/Magento/QuoteGraphQl/Model/Cart/BuyRequest/BuyRequestBuilder.php +++ b/app/code/Magento/QuoteGraphQl/Model/Cart/BuyRequest/BuyRequestBuilder.php @@ -45,11 +45,11 @@ public function __construct( */ public function build(array $cartItemData): DataObject { - $requestData = [[]]; + $requestData = []; foreach ($this->providers as $provider) { $requestData[] = $provider->execute($cartItemData); } - return $this->dataObjectFactory->create(['data' => array_merge(...$requestData)]); + return $this->dataObjectFactory->create(['data' => array_merge([], ...$requestData)]); } } diff --git a/app/code/Magento/QuoteGraphQl/Model/Cart/SetPaymentMethodOnCart.php b/app/code/Magento/QuoteGraphQl/Model/Cart/SetPaymentMethodOnCart.php index 4deb794761efb..81a30216f0035 100644 --- a/app/code/Magento/QuoteGraphQl/Model/Cart/SetPaymentMethodOnCart.php +++ b/app/code/Magento/QuoteGraphQl/Model/Cart/SetPaymentMethodOnCart.php @@ -7,6 +7,9 @@ namespace Magento\QuoteGraphQl\Model\Cart; +use Magento\Checkout\Api\Exception\PaymentProcessingRateLimitExceededException; +use Magento\Checkout\Api\PaymentProcessingRateLimiterInterface; +use Magento\Framework\App\ObjectManager; use Magento\Framework\Exception\LocalizedException; use Magento\Framework\Exception\NoSuchEntityException; use Magento\Framework\GraphQl\Exception\GraphQlInputException; @@ -18,7 +21,7 @@ use Magento\QuoteGraphQl\Model\Cart\Payment\AdditionalDataProviderPool; /** - * Set payment method on cart + * Saves related payment method info for a cart. */ class SetPaymentMethodOnCart { @@ -37,19 +40,28 @@ class SetPaymentMethodOnCart */ private $additionalDataProviderPool; + /** + * @var PaymentProcessingRateLimiterInterface + */ + private $paymentRateLimiter; + /** * @param PaymentMethodManagementInterface $paymentMethodManagement * @param PaymentInterfaceFactory $paymentFactory * @param AdditionalDataProviderPool $additionalDataProviderPool + * @param PaymentProcessingRateLimiterInterface|null $paymentRateLimiter */ public function __construct( PaymentMethodManagementInterface $paymentMethodManagement, PaymentInterfaceFactory $paymentFactory, - AdditionalDataProviderPool $additionalDataProviderPool + AdditionalDataProviderPool $additionalDataProviderPool, + ?PaymentProcessingRateLimiterInterface $paymentRateLimiter = null ) { $this->paymentMethodManagement = $paymentMethodManagement; $this->paymentFactory = $paymentFactory; $this->additionalDataProviderPool = $additionalDataProviderPool; + $this->paymentRateLimiter = $paymentRateLimiter + ?? ObjectManager::getInstance()->get(PaymentProcessingRateLimiterInterface::class); } /** @@ -62,6 +74,12 @@ public function __construct( */ public function execute(Quote $cart, array $paymentData): void { + try { + $this->paymentRateLimiter->limit(); + } catch (PaymentProcessingRateLimitExceededException $exception) { + throw new GraphQlInputException(__($exception->getMessage()), $exception); + } + if (!isset($paymentData['code']) || empty($paymentData['code'])) { throw new GraphQlInputException(__('Required parameter "code" for "payment_method" is missing.')); } diff --git a/app/code/Magento/QuoteGraphQl/Model/Cart/SetShippingAddressesOnCart.php b/app/code/Magento/QuoteGraphQl/Model/Cart/SetShippingAddressesOnCart.php index e959c19a7cbe4..71740488c4cea 100644 --- a/app/code/Magento/QuoteGraphQl/Model/Cart/SetShippingAddressesOnCart.php +++ b/app/code/Magento/QuoteGraphQl/Model/Cart/SetShippingAddressesOnCart.php @@ -7,9 +7,11 @@ namespace Magento\QuoteGraphQl\Model\Cart; +use Magento\Framework\App\ObjectManager; use Magento\Framework\GraphQl\Exception\GraphQlInputException; use Magento\GraphQl\Model\Query\ContextInterface; use Magento\Quote\Api\Data\CartInterface; +use Magento\Quote\Model\QuoteRepository; /** * Set single shipping address for a specified shopping cart @@ -26,16 +28,25 @@ class SetShippingAddressesOnCart implements SetShippingAddressesOnCartInterface */ private $getShippingAddress; + /** + * @var QuoteRepository + */ + private $quoteRepository; + /** * @param AssignShippingAddressToCart $assignShippingAddressToCart * @param GetShippingAddress $getShippingAddress + * @param QuoteRepository|null $quoteRepository */ public function __construct( AssignShippingAddressToCart $assignShippingAddressToCart, - GetShippingAddress $getShippingAddress + GetShippingAddress $getShippingAddress, + QuoteRepository $quoteRepository = null ) { $this->assignShippingAddressToCart = $assignShippingAddressToCart; $this->getShippingAddress = $getShippingAddress; + $this->quoteRepository = $quoteRepository + ?? ObjectManager::getInstance()->get(QuoteRepository::class); } /** @@ -70,5 +81,7 @@ public function execute(ContextInterface $context, CartInterface $cart, array $s throw $e; } $this->assignShippingAddressToCart->execute($cart, $shippingAddress); + // trigger quote re-evaluation after address change + $this->quoteRepository->save($cart); } } diff --git a/app/code/Magento/QuoteGraphQl/Model/CartItem/DataProvider/Processor/ItemDataCompositeProcessor.php b/app/code/Magento/QuoteGraphQl/Model/CartItem/DataProvider/Processor/ItemDataCompositeProcessor.php new file mode 100644 index 0000000000000..73a22471584ec --- /dev/null +++ b/app/code/Magento/QuoteGraphQl/Model/CartItem/DataProvider/Processor/ItemDataCompositeProcessor.php @@ -0,0 +1,45 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\QuoteGraphQl\Model\CartItem\DataProvider\Processor; + +use Magento\GraphQl\Model\Query\ContextInterface; + +/** + * {@inheritdoc} + */ +class ItemDataCompositeProcessor implements ItemDataProcessorInterface +{ + /** + * @var ItemDataProcessorInterface[] + */ + private $itemDataProcessors; + + /** + * @param ItemDataProcessorInterface[] $itemDataProcessors + */ + public function __construct(array $itemDataProcessors = []) + { + $this->itemDataProcessors = $itemDataProcessors; + } + + /** + * Process cart item data + * + * @param array $cartItemData + * @param ContextInterface $context + * @return array + */ + public function process(array $cartItemData, ContextInterface $context): array + { + foreach ($this->itemDataProcessors as $itemDataProcessor) { + $cartItemData = $itemDataProcessor->process($cartItemData, $context); + } + + return $cartItemData; + } +} diff --git a/app/code/Magento/QuoteGraphQl/Model/CartItem/DataProvider/Processor/ItemDataProcessorInterface.php b/app/code/Magento/QuoteGraphQl/Model/CartItem/DataProvider/Processor/ItemDataProcessorInterface.php new file mode 100644 index 0000000000000..33f40bd28c1d3 --- /dev/null +++ b/app/code/Magento/QuoteGraphQl/Model/CartItem/DataProvider/Processor/ItemDataProcessorInterface.php @@ -0,0 +1,25 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\QuoteGraphQl\Model\CartItem\DataProvider\Processor; + +use Magento\GraphQl\Model\Query\ContextInterface; + +/** + * Process Cart Item Data + */ +interface ItemDataProcessorInterface +{ + /** + * Process cart item data + * + * @param array $cartItemData + * @param ContextInterface $context + * @return array + */ + public function process(array $cartItemData, ContextInterface $context): array; +} diff --git a/app/code/Magento/QuoteGraphQl/Model/Resolver/AddProductsToCart.php b/app/code/Magento/QuoteGraphQl/Model/Resolver/AddProductsToCart.php index d5e554f096ec1..c7ab7596741e0 100644 --- a/app/code/Magento/QuoteGraphQl/Model/Resolver/AddProductsToCart.php +++ b/app/code/Magento/QuoteGraphQl/Model/Resolver/AddProductsToCart.php @@ -11,11 +11,13 @@ use Magento\Framework\GraphQl\Exception\GraphQlInputException; use Magento\Framework\GraphQl\Query\ResolverInterface; use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; +use Magento\GraphQl\Model\Query\ContextInterface; use Magento\Quote\Model\Cart\AddProductsToCart as AddProductsToCartService; use Magento\Quote\Model\Cart\Data\AddProductsToCartOutput; use Magento\Quote\Model\Cart\Data\CartItemFactory; use Magento\QuoteGraphQl\Model\Cart\GetCartForUser; use Magento\Quote\Model\Cart\Data\Error; +use Magento\QuoteGraphQl\Model\CartItem\DataProvider\Processor\ItemDataProcessorInterface; /** * Resolver for addProductsToCart mutation @@ -34,16 +36,24 @@ class AddProductsToCart implements ResolverInterface */ private $addProductsToCartService; + /** + * @var ItemDataProcessorInterface + */ + private $itemDataProcessor; + /** * @param GetCartForUser $getCartForUser * @param AddProductsToCartService $addProductsToCart + * @param ItemDataProcessorInterface $itemDataProcessor */ public function __construct( GetCartForUser $getCartForUser, - AddProductsToCartService $addProductsToCart + AddProductsToCartService $addProductsToCart, + ItemDataProcessorInterface $itemDataProcessor ) { $this->getCartForUser = $getCartForUser; $this->addProductsToCartService = $addProductsToCart; + $this->itemDataProcessor = $itemDataProcessor; } /** @@ -68,6 +78,9 @@ public function resolve(Field $field, $context, ResolveInfo $info, array $value $cartItems = []; foreach ($cartItemsData as $cartItemData) { + if (!$this->itemIsAllowedToCart($cartItemData, $context)) { + continue; + } $cartItems[] = (new CartItemFactory())->create($cartItemData); } @@ -90,4 +103,21 @@ function (Error $error) { ) ]; } + + /** + * Check if the item can be added to cart + * + * @param array $cartItemData + * @param ContextInterface $context + * @return bool + */ + private function itemIsAllowedToCart(array $cartItemData, ContextInterface $context): bool + { + $cartItemData = $this->itemDataProcessor->process($cartItemData, $context); + if (isset($cartItemData['grant_checkout']) && $cartItemData['grant_checkout'] === false) { + return false; + } + + return true; + } } diff --git a/app/code/Magento/QuoteGraphQl/Model/Resolver/CartPrices.php b/app/code/Magento/QuoteGraphQl/Model/Resolver/CartPrices.php index 6a57a7662af09..66cc9ed11ed9f 100644 --- a/app/code/Magento/QuoteGraphQl/Model/Resolver/CartPrices.php +++ b/app/code/Magento/QuoteGraphQl/Model/Resolver/CartPrices.php @@ -45,6 +45,12 @@ public function resolve(Field $field, $context, ResolveInfo $info, array $value /** @var Quote $quote */ $quote = $value['model']; + /** + * To calculate a right discount value + * before calculate totals + * need to reset Cart Fixed Rules in the quote + */ + $quote->setCartFixedRules([]); $cartTotals = $this->totalsCollector->collectQuoteTotals($quote); $currency = $quote->getQuoteCurrencyCode(); diff --git a/app/code/Magento/QuoteGraphQl/Model/Resolver/MergeCarts.php b/app/code/Magento/QuoteGraphQl/Model/Resolver/MergeCarts.php index d77d19df55603..297fd25be1fae 100644 --- a/app/code/Magento/QuoteGraphQl/Model/Resolver/MergeCarts.php +++ b/app/code/Magento/QuoteGraphQl/Model/Resolver/MergeCarts.php @@ -7,17 +7,24 @@ namespace Magento\QuoteGraphQl\Model\Resolver; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\Exception\CouldNotSaveException; use Magento\Framework\GraphQl\Config\Element\Field; +use Magento\Framework\GraphQl\Exception\GraphQlAuthorizationException; use Magento\Framework\GraphQl\Exception\GraphQlInputException; +use Magento\Framework\GraphQl\Exception\GraphQlNoSuchEntityException; use Magento\Framework\GraphQl\Query\ResolverInterface; use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; -use Magento\QuoteGraphQl\Model\Cart\GetCartForUser; -use Magento\Quote\Api\CartRepositoryInterface; use Magento\GraphQl\Model\Query\ContextInterface; -use Magento\Framework\GraphQl\Exception\GraphQlAuthorizationException; +use Magento\Quote\Api\CartRepositoryInterface; +use Magento\Quote\Model\Cart\CustomerCartResolver; +use Magento\Quote\Model\QuoteIdToMaskedQuoteIdInterface; +use Magento\QuoteGraphQl\Model\Cart\GetCartForUser; /** * Merge Carts Resolver + * + * @SuppressWarnings(PHPMD.LongVariable) */ class MergeCarts implements ResolverInterface { @@ -31,44 +38,95 @@ class MergeCarts implements ResolverInterface */ private $cartRepository; + /** + * @var CustomerCartResolver + */ + private $customerCartResolver; + + /** + * @var QuoteIdToMaskedQuoteIdInterface + */ + private $quoteIdToMaskedQuoteId; + /** * @param GetCartForUser $getCartForUser * @param CartRepositoryInterface $cartRepository + * @param CustomerCartResolver|null $customerCartResolver + * @param QuoteIdToMaskedQuoteIdInterface|null $quoteIdToMaskedQuoteId */ public function __construct( GetCartForUser $getCartForUser, - CartRepositoryInterface $cartRepository + CartRepositoryInterface $cartRepository, + CustomerCartResolver $customerCartResolver = null, + QuoteIdToMaskedQuoteIdInterface $quoteIdToMaskedQuoteId = null ) { $this->getCartForUser = $getCartForUser; $this->cartRepository = $cartRepository; + $this->customerCartResolver = $customerCartResolver + ?: ObjectManager::getInstance()->get(CustomerCartResolver::class); + $this->quoteIdToMaskedQuoteId = $quoteIdToMaskedQuoteId + ?: ObjectManager::getInstance()->get(QuoteIdToMaskedQuoteIdInterface::class); } /** * @inheritdoc */ - public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null) - { + public function resolve( + Field $field, + $context, + ResolveInfo $info, + array $value = null, + array $args = null + ) { if (empty($args['source_cart_id'])) { - throw new GraphQlInputException(__('Required parameter "source_cart_id" is missing')); - } - - if (empty($args['destination_cart_id'])) { - throw new GraphQlInputException(__('Required parameter "destination_cart_id" is missing')); + throw new GraphQlInputException(__( + 'Required parameter "source_cart_id" is missing' + )); } /** @var ContextInterface $context */ if (false === $context->getExtensionAttributes()->getIsCustomer()) { - throw new GraphQlAuthorizationException(__('The current customer isn\'t authorized.')); + throw new GraphQlAuthorizationException(__( + 'The current customer isn\'t authorized.' + )); + } + $currentUserId = $context->getUserId(); + + if (!isset($args['destination_cart_id'])) { + try { + $cart = $this->customerCartResolver->resolve($currentUserId); + } catch (CouldNotSaveException $exception) { + throw new GraphQlNoSuchEntityException( + __('Could not create empty cart for customer'), + $exception + ); + } + $customerMaskedCartId = $this->quoteIdToMaskedQuoteId->execute( + (int) $cart->getId() + ); + } else { + if (empty($args['destination_cart_id'])) { + throw new GraphQlInputException(__( + 'The parameter "destination_cart_id" cannot be empty' + )); + } } $guestMaskedCartId = $args['source_cart_id']; - $customerMaskedCartId = $args['destination_cart_id']; + $customerMaskedCartId = $customerMaskedCartId ?? $args['destination_cart_id']; - $currentUserId = $context->getUserId(); $storeId = (int)$context->getExtensionAttributes()->getStore()->getId(); // passing customerId as null enforces source cart should always be a guestcart - $guestCart = $this->getCartForUser->execute($guestMaskedCartId, null, $storeId); - $customerCart = $this->getCartForUser->execute($customerMaskedCartId, $currentUserId, $storeId); + $guestCart = $this->getCartForUser->execute( + $guestMaskedCartId, + null, + $storeId + ); + $customerCart = $this->getCartForUser->execute( + $customerMaskedCartId, + $currentUserId, + $storeId + ); $customerCart->merge($guestCart); $guestCart->setIsActive(false); $this->cartRepository->save($customerCart); diff --git a/app/code/Magento/QuoteGraphQl/Test/Unit/Model/Cart/SetPaymentMethodOnCartTest.php b/app/code/Magento/QuoteGraphQl/Test/Unit/Model/Cart/SetPaymentMethodOnCartTest.php new file mode 100644 index 0000000000000..281e1233d8bbe --- /dev/null +++ b/app/code/Magento/QuoteGraphQl/Test/Unit/Model/Cart/SetPaymentMethodOnCartTest.php @@ -0,0 +1,61 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +declare(strict_types=1); + +namespace Magento\QuoteGraphQl\Test\Unit\Model\Cart; + +use Magento\Checkout\Api\Exception\PaymentProcessingRateLimitExceededException; +use Magento\Checkout\Api\PaymentProcessingRateLimiterInterface; +use Magento\Framework\GraphQl\Exception\GraphQlInputException; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; +use Magento\Quote\Model\Quote; +use Magento\QuoteGraphQl\Model\Cart\SetPaymentMethodOnCart; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +class SetPaymentMethodOnCartTest extends TestCase +{ + /** + * @var SetPaymentMethodOnCart + */ + private $model; + + /** + * @var PaymentProcessingRateLimiterInterface|MockObject + */ + private $rateLimiterMock; + + /** + * @inheritDoc + */ + protected function setUp(): void + { + parent::setUp(); + + $objectManager = new ObjectManager($this); + $this->rateLimiterMock = $this->getMockForAbstractClass(PaymentProcessingRateLimiterInterface::class); + $this->model = $objectManager->getObject( + SetPaymentMethodOnCart::class, + ['paymentRateLimiter' => $this->rateLimiterMock] + ); + } + + /** + * Verify that the method is rate-limited. + * + * @return void + */ + public function testLimited(): void + { + $this->rateLimiterMock->method('limit') + ->willThrowException(new PaymentProcessingRateLimitExceededException(__($message = 'Error'))); + $this->expectException(GraphQlInputException::class); + $this->expectExceptionMessage($message); + + $this->model->execute($this->createMock(Quote::class), []); + } +} diff --git a/app/code/Magento/QuoteGraphQl/etc/di.xml b/app/code/Magento/QuoteGraphQl/etc/di.xml index d230df253221b..35b52dd495c5a 100644 --- a/app/code/Magento/QuoteGraphQl/etc/di.xml +++ b/app/code/Magento/QuoteGraphQl/etc/di.xml @@ -7,6 +7,7 @@ --> <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd"> <preference for="Magento\QuoteGraphQl\Model\CartItem\DataProvider\CustomizableOptionValueInterface" type="Magento\QuoteGraphQl\Model\CartItem\DataProvider\CustomizableOptionValue\Composite" /> + <preference for="Magento\QuoteGraphQl\Model\CartItem\DataProvider\Processor\ItemDataProcessorInterface" type="Magento\QuoteGraphQl\Model\CartItem\DataProvider\Processor\ItemDataCompositeProcessor" /> <type name="Magento\QuoteGraphQl\Model\Resolver\CartItemTypeResolver"> <arguments> <argument name="supportedTypes" xsi:type="array"> diff --git a/app/code/Magento/QuoteGraphQl/etc/graphql/events.xml b/app/code/Magento/QuoteGraphQl/etc/graphql/events.xml new file mode 100644 index 0000000000000..1e9822bbf3ef8 --- /dev/null +++ b/app/code/Magento/QuoteGraphQl/etc/graphql/events.xml @@ -0,0 +1,12 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd"> + <event name="sales_model_service_quote_submit_success"> + <observer name="sendEmail" instance="Magento\Quote\Observer\SubmitObserver" /> + </event> +</config> diff --git a/app/code/Magento/QuoteGraphQl/etc/schema.graphqls b/app/code/Magento/QuoteGraphQl/etc/schema.graphqls index 4e0e7ce5732be..cc9d1803b3e31 100644 --- a/app/code/Magento/QuoteGraphQl/etc/schema.graphqls +++ b/app/code/Magento/QuoteGraphQl/etc/schema.graphqls @@ -20,7 +20,7 @@ type Mutation { setPaymentMethodOnCart(input: SetPaymentMethodOnCartInput): SetPaymentMethodOnCartOutput @resolver(class: "Magento\\QuoteGraphQl\\Model\\Resolver\\SetPaymentMethodOnCart") setGuestEmailOnCart(input: SetGuestEmailOnCartInput): SetGuestEmailOnCartOutput @resolver(class: "Magento\\QuoteGraphQl\\Model\\Resolver\\SetGuestEmailOnCart") setPaymentMethodAndPlaceOrder(input: SetPaymentMethodAndPlaceOrderInput): PlaceOrderOutput @deprecated(reason: "Should use setPaymentMethodOnCart and placeOrder mutations in single request.") @resolver(class: "\\Magento\\QuoteGraphQl\\Model\\Resolver\\SetPaymentAndPlaceOrder") - mergeCarts(source_cart_id: String!, destination_cart_id: String!): Cart! @doc(description:"Merges the source cart into the destination cart") @resolver(class: "Magento\\QuoteGraphQl\\Model\\Resolver\\MergeCarts") + mergeCarts(source_cart_id: String!, destination_cart_id: String): Cart! @doc(description:"Merges the source cart into the destination cart") @resolver(class: "Magento\\QuoteGraphQl\\Model\\Resolver\\MergeCarts") placeOrder(input: PlaceOrderInput): PlaceOrderOutput @resolver(class: "\\Magento\\QuoteGraphQl\\Model\\Resolver\\PlaceOrder") addProductsToCart(cartId: String!, cartItems: [CartItemInput!]!): AddProductsToCartOutput @doc(description:"Add any type of product to the cart") @resolver(class: "Magento\\QuoteGraphQl\\Model\\Resolver\\AddProductsToCart") } diff --git a/app/code/Magento/RelatedProductGraphQl/Model/Resolver/Batch/AbstractLikedProducts.php b/app/code/Magento/RelatedProductGraphQl/Model/Resolver/Batch/AbstractLikedProducts.php index e14d8bde6be74..fac7b23d408e3 100644 --- a/app/code/Magento/RelatedProductGraphQl/Model/Resolver/Batch/AbstractLikedProducts.php +++ b/app/code/Magento/RelatedProductGraphQl/Model/Resolver/Batch/AbstractLikedProducts.php @@ -89,8 +89,7 @@ private function findRelations(array $products, array $loadAttributes, int $link if (!$relations) { return []; } - $relatedIds = array_values($relations); - $relatedIds = array_unique(array_merge(...$relatedIds)); + $relatedIds = array_unique(array_merge([], ...array_values($relations))); //Loading products data. $this->searchCriteriaBuilder->addFilter('entity_id', $relatedIds, 'in'); $relatedSearchResult = $this->productDataProvider->getList( @@ -142,7 +141,7 @@ public function resolve(ContextInterface $context, Field $field, array $requests $products[] = $request->getValue()['model']; $fields[] = $this->productFieldsSelector->getProductFieldsFromInfo($request->getInfo(), $this->getNode()); } - $fields = array_unique(array_merge(...$fields)); + $fields = array_unique(array_merge([], ...$fields)); //Finding relations. $related = $this->findRelations($products, $fields, $this->getLinkType()); diff --git a/app/code/Magento/ReleaseNotification/etc/di.xml b/app/code/Magento/ReleaseNotification/etc/di.xml index a4c434ff7f623..118f9346bb643 100644 --- a/app/code/Magento/ReleaseNotification/etc/di.xml +++ b/app/code/Magento/ReleaseNotification/etc/di.xml @@ -10,8 +10,8 @@ <type name="Magento\Config\Model\Config\TypePool"> <arguments> <argument name="sensitive" xsi:type="array"> - <item name="releaseNotification/content_url" xsi:type="string">1</item> - <item name="releaseNotification/use_https" xsi:type="string">1</item> + <item name="system/release_notification/content_url" xsi:type="string">1</item> + <item name="system/release_notification/use_https" xsi:type="string">1</item> </argument> </arguments> </type> diff --git a/app/code/Magento/RemoteStorage/Console/Command/RemoteStorageSynchronizeCommand.php b/app/code/Magento/RemoteStorage/Console/Command/RemoteStorageSynchronizeCommand.php new file mode 100644 index 0000000000000..a53a203b6d550 --- /dev/null +++ b/app/code/Magento/RemoteStorage/Console/Command/RemoteStorageSynchronizeCommand.php @@ -0,0 +1,83 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\RemoteStorage\Console\Command; + +use Magento\Framework\Console\Cli; +use Magento\Framework\Exception\LocalizedException; +use Magento\RemoteStorage\Model\Synchronizer; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Magento\RemoteStorage\Model\Config; + +/** + * Synchronizes local storage with remote storage. + */ +class RemoteStorageSynchronizeCommand extends Command +{ + private const NAME = 'remote-storage:sync'; + + /** + * @var Synchronizer + */ + private $synchronizer; + + /** + * @var Config + */ + private $config; + + /** + * @param Synchronizer $synchronizer + * @param Config $config + */ + public function __construct( + Synchronizer $synchronizer, + Config $config + ) { + $this->synchronizer = $synchronizer; + $this->config = $config; + + parent::__construct(self::NAME); + } + + /** + * @inheritDoc + */ + protected function configure(): void + { + $this->setDescription('Synchronize media files with remote storage.'); + } + + /** + * Run synchronization. + * + * @param InputInterface $input + * @param OutputInterface $output + * @return int + * @throws LocalizedException + */ + public function execute(InputInterface $input, OutputInterface $output): int + { + if (!$this->config->isEnabled()) { + $output->writeln('<error>Remote storage is not enabled.</error>'); + + return Cli::RETURN_FAILURE; + } + + $output->writeln('<info>Uploading media files to remote storage.</info>'); + + foreach ($this->synchronizer->execute() as $file) { + $output->writeln('- ' . $file); + } + + $output->writeln('<info>End of upload.</info>'); + + return Cli::RETURN_SUCCESS; + } +} diff --git a/app/code/Magento/RemoteStorage/Driver/DriverException.php b/app/code/Magento/RemoteStorage/Driver/DriverException.php new file mode 100644 index 0000000000000..b35a7e7c4d4da --- /dev/null +++ b/app/code/Magento/RemoteStorage/Driver/DriverException.php @@ -0,0 +1,17 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\RemoteStorage\Driver; + +use Magento\Framework\Exception\LocalizedException; + +/** + * Remote storage driver. + */ +class DriverException extends LocalizedException +{ +} diff --git a/app/code/Magento/RemoteStorage/Driver/DriverFactoryInterface.php b/app/code/Magento/RemoteStorage/Driver/DriverFactoryInterface.php new file mode 100644 index 0000000000000..b9074efc527f0 --- /dev/null +++ b/app/code/Magento/RemoteStorage/Driver/DriverFactoryInterface.php @@ -0,0 +1,25 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\RemoteStorage\Driver; + +/** + * Factory for drivers with additional configuration. + */ +interface DriverFactoryInterface +{ + /** + * Creates pre-configured driver. + * + * @param array $config + * @param string $prefix + * @return RemoteDriverInterface + * + * @throws DriverException + */ + public function create(array $config, string $prefix): RemoteDriverInterface; +} diff --git a/app/code/Magento/RemoteStorage/Driver/DriverFactoryPool.php b/app/code/Magento/RemoteStorage/Driver/DriverFactoryPool.php new file mode 100644 index 0000000000000..d13f599387d90 --- /dev/null +++ b/app/code/Magento/RemoteStorage/Driver/DriverFactoryPool.php @@ -0,0 +1,57 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\RemoteStorage\Driver; + +use Magento\Framework\Exception\RuntimeException; + +/** + * Pool of driver factories. + */ +class DriverFactoryPool +{ + /** + * @var DriverFactoryInterface[] + */ + private $pool; + + /** + * @param DriverFactoryInterface[] $pool + */ + public function __construct(array $pool = []) + { + $this->pool = $pool; + } + + /** + * Check if factory exists. + * + * @param string $name + * @return bool + */ + public function has(string $name): bool + { + return isset($this->pool[$name]); + } + + /** + * Retrieve factory. + * + * @param string $name + * @return DriverFactoryInterface + * + * @throws RuntimeException + */ + public function get(string $name): DriverFactoryInterface + { + if (!$this->has($name)) { + throw new RuntimeException(__('Driver "%1" does not exist', $name)); + } + + return $this->pool[$name]; + } +} diff --git a/app/code/Magento/RemoteStorage/Driver/DriverPool.php b/app/code/Magento/RemoteStorage/Driver/DriverPool.php new file mode 100644 index 0000000000000..0c085da78ddac --- /dev/null +++ b/app/code/Magento/RemoteStorage/Driver/DriverPool.php @@ -0,0 +1,93 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\RemoteStorage\Driver; + +use Magento\Framework\Exception\FileSystemException; +use Magento\Framework\Exception\RuntimeException; +use Magento\Framework\Filesystem\DriverInterface; +use Magento\Framework\Filesystem\DriverPool as BaseDriverPool; +use Magento\Framework\Filesystem\DriverPoolInterface; +use Magento\RemoteStorage\Model\Config; + +/** + * The remote driver pool. + */ +class DriverPool extends BaseDriverPool implements DriverPoolInterface +{ + public const PATH_DRIVER = 'remote_storage/driver'; + public const PATH_EXPOSE_URLS = 'remote_storage/expose_urls'; + public const PATH_PREFIX = 'remote_storage/prefix'; + public const PATH_CONFIG = 'remote_storage/config'; + + /** + * Driver name. + */ + public const REMOTE = 'remote'; + + /** + * @var Config + */ + private $config; + + /** + * @var DriverFactoryPool + */ + private $driverFactoryPool; + + /** + * @var array + */ + private $pool = []; + + /** + * @param Config $config + * @param DriverFactoryPool $driverFactoryPool + * @param array $extraTypes + */ + public function __construct( + Config $config, + DriverFactoryPool $driverFactoryPool, + array $extraTypes = [] + ) { + $this->config = $config; + $this->driverFactoryPool = $driverFactoryPool; + + parent::__construct($extraTypes); + } + + /** + * Retrieves remote driver. + * + * @param string $code + * @return DriverInterface + * @throws DriverException + * @throws FileSystemException + * @throws RuntimeException + */ + public function getDriver($code = self::REMOTE): DriverInterface + { + if ($code === self::REMOTE) { + if (isset($this->pool[$code])) { + return $this->pool[$code]; + } + + $driver = $this->config->getDriver(); + + if ($driver && $this->driverFactoryPool->has($driver)) { + return $this->pool[$code] = $this->driverFactoryPool->get($driver)->create( + $this->config->getConfig(), + $this->config->getPrefix() + ); + } + + throw new RuntimeException(__('Remote driver is not available.')); + } + + return parent::getDriver($code); + } +} diff --git a/app/code/Magento/RemoteStorage/Driver/RemoteDriverInterface.php b/app/code/Magento/RemoteStorage/Driver/RemoteDriverInterface.php new file mode 100644 index 0000000000000..fc108bb388cb5 --- /dev/null +++ b/app/code/Magento/RemoteStorage/Driver/RemoteDriverInterface.php @@ -0,0 +1,23 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\RemoteStorage\Driver; + +use Magento\Framework\Filesystem\ExtendedDriverInterface; + +/** + * Remote storage driver. + */ +interface RemoteDriverInterface extends ExtendedDriverInterface +{ + /** + * Test storage connection. + * + * @throws DriverException + */ + public function test(): void; +} diff --git a/app/code/Magento/RemoteStorage/Filesystem.php b/app/code/Magento/RemoteStorage/Filesystem.php new file mode 100644 index 0000000000000..01af39cfc50a3 --- /dev/null +++ b/app/code/Magento/RemoteStorage/Filesystem.php @@ -0,0 +1,131 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\RemoteStorage; + +use Magento\Framework\Filesystem\Directory\ReadFactory; +use Magento\Framework\Filesystem\Directory\WriteFactory; +use Magento\Framework\Filesystem as BaseFilesystem; +use Magento\RemoteStorage\Driver\DriverPool; +use Magento\RemoteStorage\Model\Config; + +/** + * Filesystem implementation for remote storage. + */ +class Filesystem extends BaseFilesystem implements FilesystemInterface +{ + /** + * @var bool + */ + private $isEnabled; + + /** + * @var array + */ + private $directoryCodes; + + /** + * @var DriverPool + */ + private $driverPool; + + /** + * @param BaseFilesystem\DirectoryList $directoryList + * @param ReadFactory $readFactory + * @param WriteFactory $writeFactory + * @param Config $config + * @param DriverPool $driverPool + * @param array $directoryCodes + */ + public function __construct( + BaseFilesystem\DirectoryList $directoryList, + ReadFactory $readFactory, + WriteFactory $writeFactory, + Config $config, + DriverPool $driverPool, + array $directoryCodes = [] + ) { + $this->isEnabled = $config->isEnabled(); + $this->driverPool = $driverPool; + $this->directoryCodes = $directoryCodes; + + parent::__construct($directoryList, $readFactory, $writeFactory); + } + + /** + * @inheritDoc + */ + public function getDirectoryRead($directoryCode, $driverCode = DriverPool::REMOTE) + { + $hasCode = !$this->directoryCodes || in_array($directoryCode, $this->directoryCodes, true); + + if ($driverCode === DriverPool::REMOTE && $hasCode && $this->isEnabled) { + $code = $directoryCode . '_' . $driverCode; + + if (!array_key_exists($code, $this->readInstances)) { + $uri = $this->getUri($directoryCode) ?: '/'; + + $this->readInstances[$code] = $this->readFactory->create( + $this->driverPool->getDriver()->getAbsolutePath('', $uri), + $driverCode + ); + } + + return $this->readInstances[$code]; + } + + return parent::getDirectoryRead($directoryCode); + } + + /** + * @inheritDoc + */ + public function getDirectoryWrite($directoryCode, $driverCode = DriverPool::REMOTE) + { + $hasCode = !$this->directoryCodes || in_array($directoryCode, $this->directoryCodes, true); + + if ($driverCode === DriverPool::REMOTE && $hasCode && $this->isEnabled) { + $code = $directoryCode . '_' . $driverCode; + + if (!array_key_exists($code, $this->writeInstances)) { + $uri = $this->getUri($directoryCode) ?: '/'; + + $this->writeInstances[$code] = $this->writeFactory->create( + $this->driverPool->getDriver()->getAbsolutePath('', $uri), + $driverCode + ); + } + + return $this->writeInstances[$code]; + } + + return parent::getDirectoryWrite($directoryCode); + } + + /** + * @inheritDoc + */ + public function getDirectoryReadByPath($path, $driverCode = DriverPool::REMOTE) + { + if ($driverCode === DriverPool::REMOTE && $this->isEnabled) { + return $this->readFactory->create( + $this->driverPool->getDriver()->getAbsolutePath('', $path), + $driverCode + ); + } + + return parent::getDirectoryReadByPath($path); + } + + /** + * @inheritDoc + */ + public function getDirectoryCodes(): array + { + return $this->directoryCodes; + } +} diff --git a/app/code/Magento/RemoteStorage/FilesystemInterface.php b/app/code/Magento/RemoteStorage/FilesystemInterface.php new file mode 100644 index 0000000000000..42669200c0caf --- /dev/null +++ b/app/code/Magento/RemoteStorage/FilesystemInterface.php @@ -0,0 +1,19 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\RemoteStorage; + +/** + * Provides extension for applicable directory codes. + */ +interface FilesystemInterface +{ + /** + * Retrieve directory codes. + */ + public function getDirectoryCodes(): array; +} diff --git a/app/code/Magento/RemoteStorage/LICENSE.txt b/app/code/Magento/RemoteStorage/LICENSE.txt new file mode 100644 index 0000000000000..49525fd99da9c --- /dev/null +++ b/app/code/Magento/RemoteStorage/LICENSE.txt @@ -0,0 +1,48 @@ + +Open Software License ("OSL") v. 3.0 + +This Open Software License (the "License") applies to any original work of authorship (the "Original Work") whose owner (the "Licensor") has placed the following licensing notice adjacent to the copyright notice for the Original Work: + +Licensed under the Open Software License version 3.0 + + 1. Grant of Copyright License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, for the duration of the copyright, to do the following: + + 1. to reproduce the Original Work in copies, either alone or as part of a collective work; + + 2. to translate, adapt, alter, transform, modify, or arrange the Original Work, thereby creating derivative works ("Derivative Works") based upon the Original Work; + + 3. to distribute or communicate copies of the Original Work and Derivative Works to the public, with the proviso that copies of Original Work or Derivative Works that You distribute or communicate shall be licensed under this Open Software License; + + 4. to perform the Original Work publicly; and + + 5. to display the Original Work publicly. + + 2. Grant of Patent License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, under patent claims owned or controlled by the Licensor that are embodied in the Original Work as furnished by the Licensor, for the duration of the patents, to make, use, sell, offer for sale, have made, and import the Original Work and Derivative Works. + + 3. Grant of Source Code License. The term "Source Code" means the preferred form of the Original Work for making modifications to it and all available documentation describing how to modify the Original Work. Licensor agrees to provide a machine-readable copy of the Source Code of the Original Work along with each copy of the Original Work that Licensor distributes. Licensor reserves the right to satisfy this obligation by placing a machine-readable copy of the Source Code in an information repository reasonably calculated to permit inexpensive and convenient access by You for as long as Licensor continues to distribute the Original Work. + + 4. Exclusions From License Grant. Neither the names of Licensor, nor the names of any contributors to the Original Work, nor any of their trademarks or service marks, may be used to endorse or promote products derived from this Original Work without express prior permission of the Licensor. Except as expressly stated herein, nothing in this License grants any license to Licensor's trademarks, copyrights, patents, trade secrets or any other intellectual property. No patent license is granted to make, use, sell, offer for sale, have made, or import embodiments of any patent claims other than the licensed claims defined in Section 2. No license is granted to the trademarks of Licensor even if such marks are included in the Original Work. Nothing in this License shall be interpreted to prohibit Licensor from licensing under terms different from this License any Original Work that Licensor otherwise would have a right to license. + + 5. External Deployment. The term "External Deployment" means the use, distribution, or communication of the Original Work or Derivative Works in any way such that the Original Work or Derivative Works may be used by anyone other than You, whether those works are distributed or communicated to those persons or made available as an application intended for use over a network. As an express condition for the grants of license hereunder, You must treat any External Deployment by You of the Original Work or a Derivative Work as a distribution under section 1(c). + + 6. Attribution Rights. You must retain, in the Source Code of any Derivative Works that You create, all copyright, patent, or trademark notices from the Source Code of the Original Work, as well as any notices of licensing and any descriptive text identified therein as an "Attribution Notice." You must cause the Source Code for any Derivative Works that You create to carry a prominent Attribution Notice reasonably calculated to inform recipients that You have modified the Original Work. + + 7. Warranty of Provenance and Disclaimer of Warranty. Licensor warrants that the copyright in and to the Original Work and the patent rights granted herein by Licensor are owned by the Licensor or are sublicensed to You under the terms of this License with the permission of the contributor(s) of those copyrights and patent rights. Except as expressly stated in the immediately preceding sentence, the Original Work is provided under this License on an "AS IS" BASIS and WITHOUT WARRANTY, either express or implied, including, without limitation, the warranties of non-infringement, merchantability or fitness for a particular purpose. THE ENTIRE RISK AS TO THE QUALITY OF THE ORIGINAL WORK IS WITH YOU. This DISCLAIMER OF WARRANTY constitutes an essential part of this License. No license to the Original Work is granted by this License except under this disclaimer. + + 8. Limitation of Liability. Under no circumstances and under no legal theory, whether in tort (including negligence), contract, or otherwise, shall the Licensor be liable to anyone for any indirect, special, incidental, or consequential damages of any character arising as a result of this License or the use of the Original Work including, without limitation, damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses. This limitation of liability shall not apply to the extent applicable law prohibits such limitation. + + 9. Acceptance and Termination. If, at any time, You expressly assented to this License, that assent indicates your clear and irrevocable acceptance of this License and all of its terms and conditions. If You distribute or communicate copies of the Original Work or a Derivative Work, You must make a reasonable effort under the circumstances to obtain the express assent of recipients to the terms of this License. This License conditions your rights to undertake the activities listed in Section 1, including your right to create Derivative Works based upon the Original Work, and doing so without honoring these terms and conditions is prohibited by copyright law and international treaty. Nothing in this License is intended to affect copyright exceptions and limitations (including 'fair use' or 'fair dealing'). This License shall terminate immediately and You may no longer exercise any of the rights granted to You by this License upon your failure to honor the conditions in Section 1(c). + + 10. Termination for Patent Action. This License shall terminate automatically and You may no longer exercise any of the rights granted to You by this License as of the date You commence an action, including a cross-claim or counterclaim, against Licensor or any licensee alleging that the Original Work infringes a patent. This termination provision shall not apply for an action alleging patent infringement by combinations of the Original Work with other software or hardware. + + 11. Jurisdiction, Venue and Governing Law. Any action or suit relating to this License may be brought only in the courts of a jurisdiction wherein the Licensor resides or in which Licensor conducts its primary business, and under the laws of that jurisdiction excluding its conflict-of-law provisions. The application of the United Nations Convention on Contracts for the International Sale of Goods is expressly excluded. Any use of the Original Work outside the scope of this License or after its termination shall be subject to the requirements and penalties of copyright or patent law in the appropriate jurisdiction. This section shall survive the termination of this License. + + 12. Attorneys' Fees. In any action to enforce the terms of this License or seeking damages relating thereto, the prevailing party shall be entitled to recover its costs and expenses, including, without limitation, reasonable attorneys' fees and costs incurred in connection with such action, including any appeal of such action. This section shall survive the termination of this License. + + 13. Miscellaneous. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. + + 14. Definition of "You" in This License. "You" throughout this License, whether in upper or lower case, means an individual or a legal entity exercising rights under, and complying with all of the terms of, this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with you. For purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + + 15. Right to Use. You may use the Original Work in all ways not otherwise restricted or conditioned by this License or by law, and Licensor promises not to interfere with or be responsible for such uses by You. + + 16. Modification of This License. This License is Copyright (C) 2005 Lawrence Rosen. Permission is granted to copy, distribute, or communicate this License without modification. Nothing in this License permits You to modify this License as applied to the Original Work or to Derivative Works. However, You may modify the text of this License and copy, distribute or communicate your modified version (the "Modified License") and apply it to other original works of authorship subject to the following conditions: (i) You may not indicate in any way that your Modified License is the "Open Software License" or "OSL" and you may not use those names in the name of your Modified License; (ii) You must replace the notice specified in the first paragraph above with the notice "Licensed under <insert your license name here>" or with a notice of your own that is not confusingly similar to the notice in this License; and (iii) You may not claim that your original works are open source software unless your Modified License has been approved by Open Source Initiative (OSI) and You comply with its license review and certification process. \ No newline at end of file diff --git a/app/code/Magento/RemoteStorage/LICENSE_AFL.txt b/app/code/Magento/RemoteStorage/LICENSE_AFL.txt new file mode 100644 index 0000000000000..f39d641b18a19 --- /dev/null +++ b/app/code/Magento/RemoteStorage/LICENSE_AFL.txt @@ -0,0 +1,48 @@ + +Academic Free License ("AFL") v. 3.0 + +This Academic Free License (the "License") applies to any original work of authorship (the "Original Work") whose owner (the "Licensor") has placed the following licensing notice adjacent to the copyright notice for the Original Work: + +Licensed under the Academic Free License version 3.0 + + 1. Grant of Copyright License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, for the duration of the copyright, to do the following: + + 1. to reproduce the Original Work in copies, either alone or as part of a collective work; + + 2. to translate, adapt, alter, transform, modify, or arrange the Original Work, thereby creating derivative works ("Derivative Works") based upon the Original Work; + + 3. to distribute or communicate copies of the Original Work and Derivative Works to the public, under any license of your choice that does not contradict the terms and conditions, including Licensor's reserved rights and remedies, in this Academic Free License; + + 4. to perform the Original Work publicly; and + + 5. to display the Original Work publicly. + + 2. Grant of Patent License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, under patent claims owned or controlled by the Licensor that are embodied in the Original Work as furnished by the Licensor, for the duration of the patents, to make, use, sell, offer for sale, have made, and import the Original Work and Derivative Works. + + 3. Grant of Source Code License. The term "Source Code" means the preferred form of the Original Work for making modifications to it and all available documentation describing how to modify the Original Work. Licensor agrees to provide a machine-readable copy of the Source Code of the Original Work along with each copy of the Original Work that Licensor distributes. Licensor reserves the right to satisfy this obligation by placing a machine-readable copy of the Source Code in an information repository reasonably calculated to permit inexpensive and convenient access by You for as long as Licensor continues to distribute the Original Work. + + 4. Exclusions From License Grant. Neither the names of Licensor, nor the names of any contributors to the Original Work, nor any of their trademarks or service marks, may be used to endorse or promote products derived from this Original Work without express prior permission of the Licensor. Except as expressly stated herein, nothing in this License grants any license to Licensor's trademarks, copyrights, patents, trade secrets or any other intellectual property. No patent license is granted to make, use, sell, offer for sale, have made, or import embodiments of any patent claims other than the licensed claims defined in Section 2. No license is granted to the trademarks of Licensor even if such marks are included in the Original Work. Nothing in this License shall be interpreted to prohibit Licensor from licensing under terms different from this License any Original Work that Licensor otherwise would have a right to license. + + 5. External Deployment. The term "External Deployment" means the use, distribution, or communication of the Original Work or Derivative Works in any way such that the Original Work or Derivative Works may be used by anyone other than You, whether those works are distributed or communicated to those persons or made available as an application intended for use over a network. As an express condition for the grants of license hereunder, You must treat any External Deployment by You of the Original Work or a Derivative Work as a distribution under section 1(c). + + 6. Attribution Rights. You must retain, in the Source Code of any Derivative Works that You create, all copyright, patent, or trademark notices from the Source Code of the Original Work, as well as any notices of licensing and any descriptive text identified therein as an "Attribution Notice." You must cause the Source Code for any Derivative Works that You create to carry a prominent Attribution Notice reasonably calculated to inform recipients that You have modified the Original Work. + + 7. Warranty of Provenance and Disclaimer of Warranty. Licensor warrants that the copyright in and to the Original Work and the patent rights granted herein by Licensor are owned by the Licensor or are sublicensed to You under the terms of this License with the permission of the contributor(s) of those copyrights and patent rights. Except as expressly stated in the immediately preceding sentence, the Original Work is provided under this License on an "AS IS" BASIS and WITHOUT WARRANTY, either express or implied, including, without limitation, the warranties of non-infringement, merchantability or fitness for a particular purpose. THE ENTIRE RISK AS TO THE QUALITY OF THE ORIGINAL WORK IS WITH YOU. This DISCLAIMER OF WARRANTY constitutes an essential part of this License. No license to the Original Work is granted by this License except under this disclaimer. + + 8. Limitation of Liability. Under no circumstances and under no legal theory, whether in tort (including negligence), contract, or otherwise, shall the Licensor be liable to anyone for any indirect, special, incidental, or consequential damages of any character arising as a result of this License or the use of the Original Work including, without limitation, damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses. This limitation of liability shall not apply to the extent applicable law prohibits such limitation. + + 9. Acceptance and Termination. If, at any time, You expressly assented to this License, that assent indicates your clear and irrevocable acceptance of this License and all of its terms and conditions. If You distribute or communicate copies of the Original Work or a Derivative Work, You must make a reasonable effort under the circumstances to obtain the express assent of recipients to the terms of this License. This License conditions your rights to undertake the activities listed in Section 1, including your right to create Derivative Works based upon the Original Work, and doing so without honoring these terms and conditions is prohibited by copyright law and international treaty. Nothing in this License is intended to affect copyright exceptions and limitations (including "fair use" or "fair dealing"). This License shall terminate immediately and You may no longer exercise any of the rights granted to You by this License upon your failure to honor the conditions in Section 1(c). + + 10. Termination for Patent Action. This License shall terminate automatically and You may no longer exercise any of the rights granted to You by this License as of the date You commence an action, including a cross-claim or counterclaim, against Licensor or any licensee alleging that the Original Work infringes a patent. This termination provision shall not apply for an action alleging patent infringement by combinations of the Original Work with other software or hardware. + + 11. Jurisdiction, Venue and Governing Law. Any action or suit relating to this License may be brought only in the courts of a jurisdiction wherein the Licensor resides or in which Licensor conducts its primary business, and under the laws of that jurisdiction excluding its conflict-of-law provisions. The application of the United Nations Convention on Contracts for the International Sale of Goods is expressly excluded. Any use of the Original Work outside the scope of this License or after its termination shall be subject to the requirements and penalties of copyright or patent law in the appropriate jurisdiction. This section shall survive the termination of this License. + + 12. Attorneys' Fees. In any action to enforce the terms of this License or seeking damages relating thereto, the prevailing party shall be entitled to recover its costs and expenses, including, without limitation, reasonable attorneys' fees and costs incurred in connection with such action, including any appeal of such action. This section shall survive the termination of this License. + + 13. Miscellaneous. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. + + 14. Definition of "You" in This License. "You" throughout this License, whether in upper or lower case, means an individual or a legal entity exercising rights under, and complying with all of the terms of, this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with you. For purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + + 15. Right to Use. You may use the Original Work in all ways not otherwise restricted or conditioned by this License or by law, and Licensor promises not to interfere with or be responsible for such uses by You. + + 16. Modification of This License. This License is Copyright © 2005 Lawrence Rosen. Permission is granted to copy, distribute, or communicate this License without modification. Nothing in this License permits You to modify this License as applied to the Original Work or to Derivative Works. However, You may modify the text of this License and copy, distribute or communicate your modified version (the "Modified License") and apply it to other original works of authorship subject to the following conditions: (i) You may not indicate in any way that your Modified License is the "Academic Free License" or "AFL" and you may not use those names in the name of your Modified License; (ii) You must replace the notice specified in the first paragraph above with the notice "Licensed under <insert your license name here>" or with a notice of your own that is not confusingly similar to the notice in this License; and (iii) You may not claim that your original works are open source software unless your Modified License has been approved by Open Source Initiative (OSI) and You comply with its license review and certification process. diff --git a/app/code/Magento/RemoteStorage/Model/Config.php b/app/code/Magento/RemoteStorage/Model/Config.php new file mode 100644 index 0000000000000..41fbfdde15bd0 --- /dev/null +++ b/app/code/Magento/RemoteStorage/Model/Config.php @@ -0,0 +1,96 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\RemoteStorage\Model; + +use Magento\Framework\App\DeploymentConfig; +use Magento\Framework\Exception\FileSystemException; +use Magento\Framework\Exception\RuntimeException; +use Magento\RemoteStorage\Driver\DriverPool; +use Magento\Framework\Filesystem\DriverPool as BaseDriverPool; + +/** + * Configuration for remote storage. + */ +class Config +{ + /** + * @var DeploymentConfig + */ + private $config; + + /** + * @param DeploymentConfig $config + */ + public function __construct(DeploymentConfig $config) + { + $this->config = $config; + } + + /** + * Retrieve driver name. + * + * @return string + * @throws FileSystemException + * @throws RuntimeException + */ + public function getDriver(): string + { + return $this->config->get(DriverPool::PATH_DRIVER, BaseDriverPool::FILE); + } + + /** + * Check if remote FS is enabled. + * + * @return bool + * @throws FileSystemException + * @throws RuntimeException + */ + public function isEnabled(): bool + { + $driver = $this->getDriver(); + + return $driver && $driver !== BaseDriverPool::FILE; + } + + /** + * Retrieves config. + * + * @return array + * @throws FileSystemException + * @throws RuntimeException + */ + public function getConfig(): array + { + return (array)$this->config->get(DriverPool::PATH_CONFIG, []); + } + + /** + * Retrieves prefix. + * + * @return string + * + * @throws FileSystemException + * @throws RuntimeException + */ + public function getPrefix(): string + { + return (string)$this->config->get(DriverPool::PATH_PREFIX, ''); + } + + /** + * Retrieves value for exposing URLs. + * + * @return bool + * @throws FileSystemException + * @throws RuntimeException + */ + public function getExposeUrls(): bool + { + return (bool)$this->config->get(DriverPool::PATH_EXPOSE_URLS, false); + } +} diff --git a/app/code/Magento/RemoteStorage/Model/Synchronizer.php b/app/code/Magento/RemoteStorage/Model/Synchronizer.php new file mode 100644 index 0000000000000..4276c7a1a2ffd --- /dev/null +++ b/app/code/Magento/RemoteStorage/Model/Synchronizer.php @@ -0,0 +1,104 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\RemoteStorage\Model; + +use Generator; +use Magento\Framework\Exception\FileSystemException; +use Magento\Framework\Exception\ValidatorException; +use Magento\Framework\Filesystem\DriverPool; +use Magento\Framework\Filesystem\Glob; +use Magento\RemoteStorage\Driver\DriverPool as RemoteDriverPool; +use Magento\RemoteStorage\Filesystem; +use Magento\Framework\Filesystem\Directory\WriteInterface; + +/** + * Synchronize files from local filesystem. + */ +class Synchronizer +{ + /** + * @var Filesystem + */ + private $filesystem; + + /** + * @param Filesystem $filesystem + */ + public function __construct(Filesystem $filesystem) + { + $this->filesystem = $filesystem; + } + + /** + * File upload. + * + * @return Generator + * @throws FileSystemException + * @throws ValidatorException + */ + public function execute(): Generator + { + foreach ($this->filesystem->getDirectoryCodes() as $directoryCode) { + $directory = $this->filesystem->getDirectoryWrite($directoryCode, DriverPool::FILE); + $remoteDirectory = $this->filesystem->getDirectoryWrite($directoryCode, RemoteDriverPool::REMOTE); + + yield from $this->copyRecursive($directory, $remoteDirectory, $directory->getAbsolutePath()); + } + } + + /** + * Recursive file upload. + * + * @param WriteInterface $directory + * @param WriteInterface $remoteDirectory + * @param string $path + * @param string $pattern + * @param int $flags + * @return Generator + * @throws FileSystemException + */ + private function copyRecursive( + WriteInterface $directory, + WriteInterface $remoteDirectory, + string $path, + string $pattern = '*.*', + int $flags = Glob::GLOB_NOSORT + ): Generator { + $path = rtrim($path, '/'); + $localDriver = $directory->getDriver(); + $remoteDriver = $remoteDirectory->getDriver(); + + foreach (Glob::glob($path . '/' . $pattern, $flags) as $file) { + /** + * Extracting relative path in local system to apply it for remote system. + */ + $relativeFile = $directory->getRelativePath($file); + $destination = $remoteDirectory->getAbsolutePath($relativeFile); + + if (!$remoteDirectory->isExist($destination)) { + $localDriver->copy($file, $destination, $remoteDriver); + + yield $relativeFile; + } + } + + foreach (Glob::glob($path . '/{,.}[!.,!..]*', + $flags | Glob::GLOB_ONLYDIR | Glob::GLOB_BRACE) as $childDirectory) { + $relativeDirectory = $directory->getRelativePath($childDirectory); + $destinationDirectory = $remoteDirectory->getAbsolutePath($relativeDirectory); + + if (!$remoteDirectory->isDirectory($destinationDirectory)) { + $remoteDriver->createDirectory($destinationDirectory); + + yield $relativeDirectory; + } + + yield from $this->copyRecursive($directory, $remoteDirectory, $childDirectory, $pattern, $flags); + } + } +} diff --git a/app/code/Magento/RemoteStorage/Plugin/Image.php b/app/code/Magento/RemoteStorage/Plugin/Image.php new file mode 100644 index 0000000000000..013e3fd23e168 --- /dev/null +++ b/app/code/Magento/RemoteStorage/Plugin/Image.php @@ -0,0 +1,257 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\RemoteStorage\Plugin; + +use Magento\Framework\App\Filesystem\DirectoryList; +use Magento\Framework\Exception\FileSystemException; +use Magento\Framework\Exception\RuntimeException; +use Magento\Framework\Filesystem; +use Magento\Framework\Filesystem\Directory\TargetDirectory; +use Magento\Framework\Filesystem\Io\File; +use Magento\Framework\Image\Adapter\AbstractAdapter; +use Magento\RemoteStorage\Model\Config; +use Psr\Log\LoggerInterface; + +/** + * @see AbstractAdapter + */ +class Image +{ + /** + * @var Filesystem\Directory\WriteInterface + */ + private $tmpDirectoryWrite; + + /** + * @var Filesystem\Directory\WriteInterface + */ + private $remoteDirectoryWrite; + + /** + * @var array + */ + private $tmpFiles = []; + + /** + * @var bool + */ + private $isEnabled; + + /** + * @var File + */ + private $ioFile; + + /** + * @var LoggerInterface + */ + private $logger; + + /** + * @param Filesystem $filesystem + * @param File $ioFile + * @param TargetDirectory $targetDirectory + * @param Config $config + * @param LoggerInterface $logger + * @throws FileSystemException + * @throws RuntimeException + */ + public function __construct( + Filesystem $filesystem, + File $ioFile, + TargetDirectory $targetDirectory, + Config $config, + LoggerInterface $logger + ) { + $this->tmpDirectoryWrite = $filesystem->getDirectoryWrite(DirectoryList::TMP); + $this->remoteDirectoryWrite = $targetDirectory->getDirectoryWrite(DirectoryList::ROOT); + $this->isEnabled = $config->isEnabled(); + $this->ioFile = $ioFile; + $this->logger = $logger; + } + + /** + * Copy file from remote server to tmp directory of Magento + * + * @param AbstractAdapter $subject + * @param string $filename + * @return array + * @throws FileSystemException + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function beforeOpen(AbstractAdapter $subject, $filename): array + { + if ($this->isEnabled) { + $filename = $this->copyFileToTmp($filename); + } + return [$filename]; + } + + /** + * Copy import file locally to validate + * + * @param AbstractAdapter $subject + * @param string $filePath + * @return string[] + * @throws FileSystemException + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function beforeValidateUploadFile(AbstractAdapter $subject, $filePath): array + { + if ($this->isEnabled) { + $filePath = $this->copyFileToTmp($filePath); + } + return [$filePath]; + } + + /** + * Copy watermark locally before adding it an image + * + * @param AbstractAdapter $subject + * @param string $filePath + * @return string[] + * @throws FileSystemException + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function beforeWatermark(AbstractAdapter $subject, $filePath): array + { + if ($this->isEnabled) { + $filePath = $this->copyFileToTmp($filePath); + } + return [$filePath]; + } + + /** + * Get filesystem tmp path for file and provide it to save() function + * + * @param AbstractAdapter $subject + * @param callable $proceed + * @param string|null $destination + * @param string|null $newName + * @return void + * @throws FileSystemException + */ + public function aroundSave( + AbstractAdapter $subject, + callable $proceed, + $destination = null, + $newName = null + ): void { + if ($this->isEnabled) { + $relativePath = $this->remoteDirectoryWrite->getRelativePath($destination); + $tmpPath = $this->tmpDirectoryWrite->getAbsolutePath($relativePath); + + $proceed($tmpPath, $newName); + + $this->tmpDirectoryWrite->getDriver()->rename( + $this->prepareDestination($subject, $tmpPath, $newName), + $this->prepareDestination($subject, $destination, $newName), + $this->remoteDirectoryWrite->getDriver() + ); + } else { + $proceed($destination, $newName); + } + } + + /** + * Remove created tmp files + */ + public function __destruct() + { + try { + foreach ($this->tmpFiles as $tmpFile) { + $this->tmpDirectoryWrite->delete($tmpFile); + } + } catch (\Exception $e) { + $this->logger->error($e->getMessage()); + } + } + + /** + * Move files from storage to tmp folder + * + * @param string $filePath + * @return string + * @throws FileSystemException + */ + private function copyFileToTmp(string $filePath): string + { + if ($this->fileExistsInTmp($filePath)) { + return $filePath; + } + $absolutePath = $this->remoteDirectoryWrite->getAbsolutePath($filePath); + if ($this->remoteDirectoryWrite->isFile($absolutePath)) { + $this->tmpDirectoryWrite->create(); + $tmpPath = $this->storeTmpName($filePath); + $content = $this->remoteDirectoryWrite->getDriver()->fileGetContents($filePath); + $filePath = $this->tmpDirectoryWrite->getDriver()->filePutContents($tmpPath, $content) + ? $tmpPath + : $filePath; + } + return $filePath; + } + + /** + * Store created tmp image path + * + * @param string $filePath + * @return string + */ + private function storeTmpName(string $filePath): string + { + // phpcs:ignore Magento2.Functions.DiscouragedFunction + $tmpPath = $this->tmpDirectoryWrite->getAbsolutePath() . basename($filePath); + + $this->tmpFiles[$filePath] = $tmpPath; + + return $tmpPath; + } + + /** + * Check is file exist in tmp folder + * + * @param string $filePath + * @return bool + */ + private function fileExistsInTmp(string $filePath): bool + { + return in_array($filePath, $this->tmpFiles, true); + } + + /** + * Prepare destination path + * + * @param AbstractAdapter $image + * @param string|null $destination + * @param string|null $newName + * @return string + */ + private function prepareDestination( + AbstractAdapter $image, + string $destination = null, + string $newName = null + ): string { + if (empty($destination)) { + $destination = $image->getFileSrcPath(); + } elseif (empty($newName)) { + $info = $this->ioFile->getPathInfo($destination); + $newName = $info['basename']; + $destination = $info['dirname']; + } + + if (empty($newName)) { + $newFileName = $image->getFileSrcName(); + } else { + $newFileName = $newName; + } + return rtrim($destination, '/') . '/' . $newFileName; + } +} diff --git a/app/code/Magento/RemoteStorage/Plugin/MediaStorage.php b/app/code/Magento/RemoteStorage/Plugin/MediaStorage.php new file mode 100644 index 0000000000000..12837545c533b --- /dev/null +++ b/app/code/Magento/RemoteStorage/Plugin/MediaStorage.php @@ -0,0 +1,83 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\RemoteStorage\Plugin; + +use Magento\Framework\App\Filesystem\DirectoryList; +use Magento\Framework\Exception\FileSystemException; +use Magento\Framework\Exception\RuntimeException; +use Magento\Framework\Exception\ValidatorException; +use Magento\Framework\Filesystem\Directory\WriteInterface; +use Magento\MediaStorage\Model\File\Storage\Synchronization; +use Magento\RemoteStorage\Driver\DriverPool as RemoteDriverPool; +use Magento\Framework\Filesystem\DriverPool as LocalDriverPool; +use Magento\RemoteStorage\Model\Config; +use Magento\RemoteStorage\Filesystem; + +/** + * Modifies the base URL. + */ +class MediaStorage +{ + /** + * @var Filesystem + */ + private $filesystem; + + /** + * @var bool + */ + private $isEnabled; + + /** + * @var WriteInterface + */ + private $remoteDirectory; + + /** + * @var WriteInterface + */ + private $localDirectory; + + /** + * @param Config $config + * @param Filesystem $filesystem + * @throws FileSystemException + * @throws RuntimeException + */ + public function __construct(Config $config, Filesystem $filesystem) + { + $this->isEnabled = $config->isEnabled(); + $this->remoteDirectory = $filesystem->getDirectoryWrite(DirectoryList::PUB, RemoteDriverPool::REMOTE); + $this->localDirectory = $filesystem->getDirectoryWrite(DirectoryList::PUB, LocalDriverPool::FILE); + } + + /** + * Download remote file + * + * @param Synchronization $subject + * @param string $relativeFileName + * @return null + * @throws FileSystemException + * @throws ValidatorException + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function beforeSynchronize(Synchronization $subject, string $relativeFileName): void + { + if ($this->isEnabled && $this->remoteDirectory->isExist($relativeFileName)) { + $file = $this->localDirectory->openFile($relativeFileName, 'w'); + try { + $file->lock(); + $file->write($this->remoteDirectory->readFile($relativeFileName)); + $file->unlock(); + $file->close(); + } catch (FileSystemException $e) { + $file->close(); + } + } + } +} diff --git a/app/code/Magento/RemoteStorage/Plugin/Scope.php b/app/code/Magento/RemoteStorage/Plugin/Scope.php new file mode 100644 index 0000000000000..6a05b63dee3a6 --- /dev/null +++ b/app/code/Magento/RemoteStorage/Plugin/Scope.php @@ -0,0 +1,63 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\RemoteStorage\Plugin; + +use Magento\Framework\App\Filesystem\DirectoryList; +use Magento\Framework\Exception\ValidatorException; +use Magento\Framework\Url\ScopeInterface; +use Magento\Framework\UrlInterface; +use Magento\RemoteStorage\Driver\DriverPool; +use Magento\RemoteStorage\Model\Config; +use Magento\RemoteStorage\Filesystem; + +/** + * Modifies the base URL. + */ +class Scope +{ + /** + * @var Filesystem + */ + private $filesystem; + + /** + * @var bool + */ + private $isEnabled; + + /** + * @param Config $config + * @param Filesystem $filesystem + */ + public function __construct(Config $config, Filesystem $filesystem) + { + $this->isEnabled = $config->isEnabled() && $config->getExposeUrls(); + $this->filesystem = $filesystem; + } + + /** + * Modifies the base URL. + * + * @param ScopeInterface $subject + * @param string $result + * @param string $type + * @return string + * @throws ValidatorException + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterGetBaseUrl(ScopeInterface $subject, string $result, string $type = ''): string + { + if ($type === UrlInterface::URL_TYPE_MEDIA && $this->isEnabled) { + return $this->filesystem->getDirectoryRead(DirectoryList::MEDIA, DriverPool::REMOTE) + ->getAbsolutePath(); + } + + return $result; + } +} diff --git a/app/code/Magento/RemoteStorage/README.md b/app/code/Magento/RemoteStorage/README.md new file mode 100644 index 0000000000000..f33b25795a995 --- /dev/null +++ b/app/code/Magento/RemoteStorage/README.md @@ -0,0 +1 @@ +# Magento_RemoteStorage module diff --git a/app/code/Magento/RemoteStorage/Setup/ConfigOptionsList.php b/app/code/Magento/RemoteStorage/Setup/ConfigOptionsList.php new file mode 100644 index 0000000000000..b625661479962 --- /dev/null +++ b/app/code/Magento/RemoteStorage/Setup/ConfigOptionsList.php @@ -0,0 +1,201 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\RemoteStorage\Setup; + +use Magento\Framework\App\DeploymentConfig; +use Magento\Framework\Config\Data\ConfigData; +use Magento\Framework\Config\File\ConfigFilePool; +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\Filesystem\DriverPool; +use Magento\RemoteStorage\Driver\DriverFactoryPool; +use Magento\RemoteStorage\Driver\DriverPool as RemoteDriverPool; +use Magento\Framework\Setup\ConfigOptionsListInterface; +use Magento\Framework\Setup\Option\TextConfigOption; +use Psr\Log\LoggerInterface; + +/** + * Remote storage options. + */ +class ConfigOptionsList implements ConfigOptionsListInterface +{ + private const OPTION_REMOTE_STORAGE_DRIVER = 'remote-storage-driver'; + private const CONFIG_PATH__REMOTE_STORAGE_DRIVER = RemoteDriverPool::PATH_DRIVER; + private const OPTION_REMOTE_STORAGE_PREFIX = 'remote-storage-prefix'; + private const CONFIG_PATH__REMOTE_STORAGE_PREFIX = RemoteDriverPool::PATH_PREFIX; + private const OPTION_REMOTE_STORAGE_BUCKET = 'remote-storage-bucket'; + private const CONFIG_PATH__REMOTE_STORAGE_BUCKET = RemoteDriverPool::PATH_CONFIG . '/bucket'; + private const OPTION_REMOTE_STORAGE_REGION = 'remote-storage-region'; + private const CONFIG_PATH__REMOTE_STORAGE_REGION = RemoteDriverPool::PATH_CONFIG . '/region'; + private const OPTION_REMOTE_STORAGE_ACCESS_KEY = 'remote-storage-key'; + private const CONFIG_PATH__REMOTE_STORAGE_ACCESS_KEY = RemoteDriverPool::PATH_CONFIG . '/credentials/key'; + private const OPTION_REMOTE_STORAGE_SECRET_KEY = 'remote-storage-secret'; + private const CONFIG_PATH__REMOTE_STORAGE_SECRET_KEY = RemoteDriverPool::PATH_CONFIG . '/credentials/secret'; + + /** + * Map of option to config path relations. + * + * @var string[] + */ + private static $map = [ + self::OPTION_REMOTE_STORAGE_PREFIX => self::CONFIG_PATH__REMOTE_STORAGE_PREFIX, + self::OPTION_REMOTE_STORAGE_BUCKET => self::CONFIG_PATH__REMOTE_STORAGE_BUCKET, + self::OPTION_REMOTE_STORAGE_REGION => self::CONFIG_PATH__REMOTE_STORAGE_REGION, + self::OPTION_REMOTE_STORAGE_ACCESS_KEY => self::CONFIG_PATH__REMOTE_STORAGE_ACCESS_KEY, + self::OPTION_REMOTE_STORAGE_SECRET_KEY => self::CONFIG_PATH__REMOTE_STORAGE_SECRET_KEY + ]; + + /** + * @var DriverFactoryPool + */ + private $driverFactoryPool; + + /** + * @var LoggerInterface + */ + private $logger; + + /** + * @param DriverFactoryPool $driverFactoryPool + * @param LoggerInterface $logger + */ + public function __construct(DriverFactoryPool $driverFactoryPool, LoggerInterface $logger) + { + $this->driverFactoryPool = $driverFactoryPool; + $this->logger = $logger; + } + + /** + * @inheritDoc + */ + public function getOptions(): array + { + return [ + new TextConfigOption( + self::OPTION_REMOTE_STORAGE_DRIVER, + TextConfigOption::FRONTEND_WIZARD_TEXT, + self::CONFIG_PATH__REMOTE_STORAGE_DRIVER, + 'Remote storage driver', + DriverPool::FILE + ), + new TextConfigOption( + self::OPTION_REMOTE_STORAGE_PREFIX, + TextConfigOption::FRONTEND_WIZARD_TEXT, + self::CONFIG_PATH__REMOTE_STORAGE_PREFIX, + 'Remote storage prefix', + '' + ), + new TextConfigOption( + self::OPTION_REMOTE_STORAGE_BUCKET, + TextConfigOption::FRONTEND_WIZARD_TEXT, + self::CONFIG_PATH__REMOTE_STORAGE_BUCKET, + 'Remote storage bucket' + ), + new TextConfigOption( + self::OPTION_REMOTE_STORAGE_REGION, + TextConfigOption::FRONTEND_WIZARD_TEXT, + self::CONFIG_PATH__REMOTE_STORAGE_REGION, + 'Remote storage region' + ), + new TextConfigOption( + self::OPTION_REMOTE_STORAGE_ACCESS_KEY, + TextConfigOption::FRONTEND_WIZARD_PASSWORD, + self::CONFIG_PATH__REMOTE_STORAGE_ACCESS_KEY, + 'Remote storage access key', + '' + ), + new TextConfigOption( + self::OPTION_REMOTE_STORAGE_SECRET_KEY, + TextConfigOption::FRONTEND_WIZARD_PASSWORD, + self::CONFIG_PATH__REMOTE_STORAGE_SECRET_KEY, + 'Remote storage secret key', + '' + ) + ]; + } + + /** + * @inheritDoc + */ + public function createConfig(array $options, DeploymentConfig $deploymentConfig): array + { + $driver = $options[self::OPTION_REMOTE_STORAGE_DRIVER] ?? DriverPool::FILE; + + if ($driver === DriverPool::FILE) { + $configData = new ConfigData(ConfigFilePool::APP_ENV); + $configData->setOverrideWhenSave(true); + $configData->set(self::CONFIG_PATH__REMOTE_STORAGE_DRIVER, $driver); + } else { + $configData = $this->createConfigData($driver, $options); + } + + return [$configData]; + } + + /** + * @inheritDoc + */ + public function validate(array $options, DeploymentConfig $deploymentConfig): array + { + $driver = $options[self::OPTION_REMOTE_STORAGE_DRIVER] ?? DriverPool::FILE; + + if ($driver === DriverPool::FILE) { + return []; + } + + $errors = []; + + if (empty($options[self::OPTION_REMOTE_STORAGE_REGION])) { + $errors[] = 'Region is required'; + } + + if (empty($options[self::OPTION_REMOTE_STORAGE_BUCKET])) { + $errors[] = 'Bucket is required'; + } + + if (!$errors) { + $configData = $this->createConfigData($driver, $options); + + try { + $this->driverFactoryPool->get($driver)->create( + $configData->getData()['remote_storage']['config'], + $options[self::OPTION_REMOTE_STORAGE_PREFIX] + )->test(); + } catch (LocalizedException $exception) { + $message = $exception->getMessage(); + + $this->logger->critical($message); + + $errors[] = 'Adapter error: ' . $message; + } + } + + return $errors; + } + + /** + * Creates pre-configured config data object. + * + * @param string $driver + * @param array $options + * @return ConfigData + */ + private function createConfigData(string $driver, array $options): ConfigData + { + $configData = new ConfigData(ConfigFilePool::APP_ENV); + $configData->setOverrideWhenSave(true); + $configData->set(self::CONFIG_PATH__REMOTE_STORAGE_DRIVER, $driver); + + foreach (self::$map as $option => $configPath) { + if (!empty($options[$option])) { + $configData->set($configPath, $options[$option]); + } + } + + return $configData; + } +} diff --git a/app/code/Magento/RemoteStorage/Test/Unit/Model/SynchronizerTest.php b/app/code/Magento/RemoteStorage/Test/Unit/Model/SynchronizerTest.php new file mode 100644 index 0000000000000..5c3ddb74bb0cf --- /dev/null +++ b/app/code/Magento/RemoteStorage/Test/Unit/Model/SynchronizerTest.php @@ -0,0 +1,109 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\RemoteStorage\Test\Unit\Model; + +use Magento\Framework\Exception\FileSystemException; +use Magento\Framework\Exception\ValidatorException; +use Magento\Framework\Filesystem\Directory\WriteInterface; +use Magento\Framework\Filesystem\DriverInterface; +use Magento\RemoteStorage\Filesystem; +use Magento\RemoteStorage\Model\Synchronizer; +use PHPUnit\Framework\TestCase; +use Magento\Framework\Filesystem\DriverPool; +use Magento\RemoteStorage\Driver\DriverPool as RemoteDriverPool; + +/** + * @see Synchronizer + */ +class SynchronizerTest extends TestCase +{ + /** + * @var Synchronizer + */ + private $synchronizer; + + /** + * @var Filesystem + */ + private $filesystemMock; + + /** + * @inheritDoc + */ + protected function setUp(): void + { + $this->filesystemMock = $this->createMock(Filesystem::class); + + $this->synchronizer = new Synchronizer( + $this->filesystemMock + ); + } + + /** + * @throws FileSystemException + * @throws ValidatorException + */ + public function testExecute(): void + { + $this->filesystemMock->method('getDirectoryCodes') + ->willReturn(['test']); + + $localDriver = $this->createMock(DriverInterface::class); + $remoteDriver = $this->createMock(DriverInterface::class); + + $localDirectory = $this->createMock(WriteInterface::class); + $localDirectory->method('getDriver') + ->willReturn($localDriver); + $remoteDirectory = $this->createMock(WriteInterface::class); + $remoteDirectory->method('getDriver') + ->willReturn($remoteDriver); + + $this->filesystemMock->method('getDirectoryWrite') + ->willReturnMap([ + ['test', DriverPool::FILE, $localDirectory], + ['test', RemoteDriverPool::REMOTE, $remoteDirectory] + ]); + $localDirectory->method('getAbsolutePath') + ->willReturnMap([ + [null, __DIR__ . '/_files/test'] + ]); + $localDirectory->method('getRelativePath') + ->willReturnCallback(function ($arg) { + return str_replace(__DIR__, '', $arg); + }); + $remoteDirectory->expects(self::exactly(2)) + ->method('isExist') + ->willReturnMap([ + [ + 'remote:/_files/test/root_file.txt', + false + ], + [ + 'remote:/_files/test/.dot_directory/child_file.txt', + true + ] + ]); + $remoteDirectory->method('getAbsolutePath') + ->willReturnCallback(function ($arg) { + return 'remote:' . $arg; + }); + $localDriver->expects(self::once()) + ->method('copy') + ->withConsecutive( + [__DIR__ . '/_files/test/root_file.txt', 'remote:/_files/test/root_file.txt', $remoteDriver] + ); + + self::assertSame( + [ + '/_files/test/root_file.txt', + '/_files/test/.dot_directory' + ], + iterator_to_array($this->synchronizer->execute(), false) + ); + } +} diff --git a/app/code/Magento/RemoteStorage/Test/Unit/Model/_files/test/.dot_directory/child_file.txt b/app/code/Magento/RemoteStorage/Test/Unit/Model/_files/test/.dot_directory/child_file.txt new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/app/code/Magento/RemoteStorage/Test/Unit/Model/_files/test/.dot_file.txt b/app/code/Magento/RemoteStorage/Test/Unit/Model/_files/test/.dot_file.txt new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/app/code/Magento/RemoteStorage/Test/Unit/Model/_files/test/root_file.txt b/app/code/Magento/RemoteStorage/Test/Unit/Model/_files/test/root_file.txt new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/app/code/Magento/RemoteStorage/Test/Unit/Plugin/ImageTest.php b/app/code/Magento/RemoteStorage/Test/Unit/Plugin/ImageTest.php new file mode 100644 index 0000000000000..13d170946e343 --- /dev/null +++ b/app/code/Magento/RemoteStorage/Test/Unit/Plugin/ImageTest.php @@ -0,0 +1,188 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +namespace Magento\RemoteStorage\Test\Unit\Plugin; + +use Magento\Framework\App\Filesystem\DirectoryList; +use Magento\Framework\Filesystem; +use Magento\Framework\Filesystem\Directory\TargetDirectory; +use Magento\Framework\Filesystem\Directory\WriteInterface; +use Magento\Framework\Filesystem\DriverInterface; +use Magento\Framework\Filesystem\Io\File; +use Magento\Framework\Image\Adapter\AbstractAdapter; +use Magento\RemoteStorage\Model\Config; +use Magento\RemoteStorage\Plugin\Image; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +class ImageTest extends TestCase +{ + /** + * @var File|MockObject + */ + private $ioFile; + + /** + * @var Image + */ + private $plugin; + + /** + * @var WriteInterface|MockObject + */ + private $tmpDirectoryWrite; + + /** + * @var WriteInterface|MockObject + */ + private $targetDirectoryWrite; + + /** + * @return void + * @throws \Magento\Framework\Exception\FileSystemException + */ + protected function setUp(): void + { + /** @var Filesystem|MockObject $filesystem */ + $filesystem = $this->getMockBuilder(Filesystem::class)->disableOriginalConstructor()->getMock(); + $this->ioFile = $this->getMockBuilder(File::class)->disableOriginalConstructor()->getMock(); + /** @var TargetDirectory|MockObject $targetDirectory */ + $targetDirectory = $this->getMockBuilder(TargetDirectory::class)->disableOriginalConstructor()->getMock(); + /** @var Config|MockObject $config */ + $config = $this->getMockBuilder(Config::class)->disableOriginalConstructor()->getMock(); + $config->expects(self::atLeastOnce())->method('isEnabled')->willReturn(true); + $this->tmpDirectoryWrite = $this->getMockBuilder(WriteInterface::class) + ->disableOriginalConstructor()->getMock(); + $this->targetDirectoryWrite = $this->getMockBuilder(WriteInterface::class) + ->disableOriginalConstructor()->getMock(); + $filesystem->expects(self::atLeastOnce())->method('getDirectoryWrite')->with(DirectoryList::TMP) + ->willReturn($this->tmpDirectoryWrite); + $targetDirectory->expects(self::atLeastOnce())->method('getDirectoryWrite')->with(DirectoryList::ROOT) + ->willReturn($this->targetDirectoryWrite); + /** @var LoggerInterface|MockObject $logger */ + $logger = $this->getMockBuilder(LoggerInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->plugin = new Image( + $filesystem, + $this->ioFile, + $targetDirectory, + $config, + $logger + ); + } + + /** + * @dataProvider aroundSaveDataProvider + * @param string $destination + * @param string $newDestination + * @param string|null $newName + * @param string|null $oldName + * @return void + * @throws \Magento\Framework\Exception\FileSystemException + */ + public function testAroundSaveWithNewName( + string $destination, + string $newDestination, + ?string $newName, + ?string $oldName + ): void { + $tmpDestination = '/tmp/' . $destination; + /** @var AbstractAdapter $subject */ + $subject = $this->getMockBuilder(AbstractAdapter::class) + ->disableOriginalConstructor() + ->getMock(); + $proceed = function () { + }; + $targetDriver = $this->getMockBuilder(DriverInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->targetDirectoryWrite->expects(self::atLeastOnce())->method('getRelativePath') + ->willReturn($destination . $oldName); + $this->targetDirectoryWrite->expects(self::atLeastOnce())->method('getDriver') + ->willReturn($targetDriver); + $this->tmpDirectoryWrite->expects(self::atLeastOnce())->method('getAbsolutePath') + ->willReturn($tmpDestination); + $driver = $this->getMockBuilder(DriverInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $actualName = $newName ?? $oldName; + $driver->expects(self::atLeastOnce())->method('rename') + ->with($tmpDestination . $actualName, $newDestination, $driver); + $this->tmpDirectoryWrite->expects(self::atLeastOnce())->method('getDriver')->willReturn($driver); + $this->ioFile->method('getPathInfo') + ->willReturnMap( + [ + [$tmpDestination, ['dirname' => $tmpDestination, 'basename' => 'old_name.file']], + [$destination . $oldName, ['dirname' => $destination, 'basename' => 'old_name.file']] + ] + ); + $this->plugin->aroundSave($subject, $proceed, $destination . $oldName, $newName); + } + + /** + * @return array + */ + public function aroundSaveDataProvider(): array + { + return [ + 'with_new_name' => [ + 'destination' => 'destination/', + 'new_destination' => 'destination/new_name.file', + 'new_name' => 'new_name.file', + 'old_name' => null + ], + 'with_old_name' => [ + 'destination' => 'destination/', + 'new_destination' => 'destination/old_name.file', + 'new_name' => null, + 'old_name' => 'old_name.file' + ] + ]; + } + + /** + * @return void + * @throws \Magento\Framework\Exception\FileSystemException + */ + public function testBeforeOpen(): void + { + /** @var AbstractAdapter $subject */ + $subject = $this->getMockBuilder(AbstractAdapter::class) + ->disableOriginalConstructor() + ->getMock(); + $filename = '/path/file_name.file'; + $absolutePath = 'absolute' . $filename; + $tmpAbsolutePath = '/var/www/magento2/tmp'; + $tmpFilePath = $tmpAbsolutePath . 'file_name.file'; + $content = 'Just a test'; + + $targetDriver = $this->getMockBuilder(DriverInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $targetDriver->expects(self::atLeastOnce())->method('fileGetContents')->with($filename) + ->willReturn($content); + $tmpDriver = $this->getMockBuilder(DriverInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $tmpDriver->expects(self::atLeastOnce())->method('filePutContents')->with($tmpFilePath, $content) + ->willReturn(true); + $this->targetDirectoryWrite->expects(self::atLeastOnce())->method('getAbsolutePath')->with($filename) + ->willReturn($absolutePath); + $this->targetDirectoryWrite->expects(self::atLeastOnce())->method('isFile')->with($absolutePath) + ->willReturn(true); + $this->targetDirectoryWrite->expects(self::atLeastOnce())->method('getDriver') + ->willReturn($targetDriver); + $this->tmpDirectoryWrite->expects(self::atLeastOnce())->method('getDriver') + ->willReturn($tmpDriver); + $this->tmpDirectoryWrite->expects(self::atLeastOnce())->method('create'); + $this->tmpDirectoryWrite->expects(self::atLeastOnce())->method('getAbsolutePath') + ->willReturn($tmpAbsolutePath); + + self::assertEquals([$tmpFilePath], $this->plugin->beforeOpen($subject, $filename)); + } +} diff --git a/app/code/Magento/RemoteStorage/composer.json b/app/code/Magento/RemoteStorage/composer.json new file mode 100644 index 0000000000000..7345048a159e3 --- /dev/null +++ b/app/code/Magento/RemoteStorage/composer.json @@ -0,0 +1,32 @@ +{ + "name": "magento/module-remote-storage", + "description": "N/A", + "require": { + "php": "~7.3.0||~7.4.0", + "magento/framework": "^100.0.2" + }, + "suggest": { + "magento/module-backend": "*", + "magento/module-sitemap": "*", + "magento/module-cms": "*", + "magento/module-downloadable": "*", + "magento/module-catalog": "*", + "magento/module-media-storage": "*", + "magento/module-import-export": "*", + "magento/module-catalog-import-export": "*", + "magento/module-downloadable-import-export": "*" + }, + "type": "magento2-module", + "license": [ + "OSL-3.0", + "AFL-3.0" + ], + "autoload": { + "files": [ + "registration.php" + ], + "psr-4": { + "Magento\\RemoteStorage\\": "" + } + } +} diff --git a/app/code/Magento/RemoteStorage/etc/adminhtml/system.xml b/app/code/Magento/RemoteStorage/etc/adminhtml/system.xml new file mode 100644 index 0000000000000..5009a05d8b602 --- /dev/null +++ b/app/code/Magento/RemoteStorage/etc/adminhtml/system.xml @@ -0,0 +1,18 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Config:etc/system_file.xsd"> + <system> + <section id="system"> + <group id="media_storage_configuration"> + <field id="media_storage"> + <comment><![CDATA[<strong style="color:red">Warning!</strong> Database media storage will be ignored if remote storage is enabled.]]></comment> + </field> + </group> + </section> + </system> +</config> diff --git a/app/code/Magento/RemoteStorage/etc/di.xml b/app/code/Magento/RemoteStorage/etc/di.xml new file mode 100644 index 0000000000000..9fdde517b952c --- /dev/null +++ b/app/code/Magento/RemoteStorage/etc/di.xml @@ -0,0 +1,137 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd"> + <virtualType name="remoteWriteFactory" type="Magento\Framework\Filesystem\Directory\WriteFactory"> + <arguments> + <argument name="driverPool" xsi:type="object">Magento\RemoteStorage\Driver\DriverPool</argument> + </arguments> + </virtualType> + <virtualType name="remoteReadFactory" type="Magento\Framework\Filesystem\Directory\ReadFactory"> + <arguments> + <argument name="driverPool" xsi:type="object">Magento\RemoteStorage\Driver\DriverPool</argument> + </arguments> + </virtualType> + <type name="Magento\RemoteStorage\Filesystem"> + <arguments> + <argument name="writeFactory" xsi:type="object">remoteWriteFactory</argument> + <argument name="readFactory" xsi:type="object">remoteReadFactory</argument> + </arguments> + </type> + <virtualType name="customRemoteFilesystem" type="Magento\RemoteStorage\Filesystem"> + <arguments> + <argument name="directoryCodes" xsi:type="array"> + <item name="media" xsi:type="const">Magento\Framework\App\Filesystem\DirectoryList::MEDIA</item> + <item name="var_export" xsi:type="const">Magento\Framework\App\Filesystem\DirectoryList::VAR_EXPORT</item> + </argument> + </arguments> + </virtualType> + <virtualType name="fullRemoteFilesystem" type="Magento\RemoteStorage\Filesystem" /> + <preference for="Magento\Framework\Filesystem" type="customRemoteFilesystem"/> + <type name="Magento\Framework\Filesystem\Directory\TargetDirectory"> + <arguments> + <argument name="filesystem" xsi:type="object">fullRemoteFilesystem</argument> + <argument name="driverCode" xsi:type="const">Magento\RemoteStorage\Driver\DriverPool::REMOTE</argument> + </arguments> + </type> + <type name="Magento\Sitemap\Model\Sitemap"> + <arguments> + <argument name="filesystem" xsi:type="object">fullRemoteFilesystem</argument> + </arguments> + </type> + <type name="Magento\Sitemap\Controller\Adminhtml\Sitemap\Save"> + <arguments> + <argument name="filesystem" xsi:type="object">fullRemoteFilesystem</argument> + </arguments> + </type> + <type name="Magento\Sitemap\Block\Adminhtml\Grid\Renderer\Link"> + <arguments> + <argument name="filesystem" xsi:type="object">fullRemoteFilesystem</argument> + </arguments> + </type> + <type name="Magento\Sitemap\Controller\Adminhtml\Sitemap\Delete"> + <arguments> + <argument name="filesystem" xsi:type="object">fullRemoteFilesystem</argument> + </arguments> + </type> + <type name="Magento\Framework\Console\CommandListInterface"> + <arguments> + <argument name="commands" xsi:type="array"> + <item name="remoteStorageSync" xsi:type="object">Magento\RemoteStorage\Console\Command\RemoteStorageSynchronizeCommand</item> + </argument> + </arguments> + </type> + <type name="Magento\Framework\App\MaintenanceMode"> + <arguments> + <argument name="filesystem" xsi:type="object">fullRemoteFilesystem</argument> + </arguments> + </type> + <type name="Magento\MediaStorage\Model\File\Storage\Synchronization"> + <plugin name="remoteMedia" type="Magento\RemoteStorage\Plugin\MediaStorage" /> + </type> + <type name="Magento\Framework\Data\Collection\Filesystem"> + <arguments> + <argument name="filesystem" xsi:type="object">fullRemoteFilesystem</argument> + </arguments> + </type> + <type name="Magento\Cms\Model\Wysiwyg\Images\Storage"> + <arguments> + <argument name="filesystem" xsi:type="object">fullRemoteFilesystem</argument> + </arguments> + </type> + <type name="Magento\Framework\File\Mime"> + <arguments> + <argument name="filesystem" xsi:type="object">fullRemoteFilesystem</argument> + </arguments> + </type> + <type name="Magento\Framework\Image\Adapter\AbstractAdapter"> + <plugin name="remoteImageFile" type="Magento\RemoteStorage\Plugin\Image" sortOrder="10"/> + </type> + <type name="Magento\Catalog\Model\Category\FileInfo"> + <arguments> + <argument name="filesystem" xsi:type="object">fullRemoteFilesystem</argument> + </arguments> + </type> + <type name="Magento\Framework\Url\ScopeInterface"> + <plugin name="remoteUrl" type="Magento\RemoteStorage\Plugin\Scope"/> + </type> + <type name="Magento\ImportExport\Model\Import"> + <arguments> + <argument name="filesystem" xsi:type="object">fullRemoteFilesystem</argument> + </arguments> + </type> + <type name="Magento\ImportExport\Model\Import\ImageDirectoryBaseProvider"> + <arguments> + <argument name="filesystem" xsi:type="object">fullRemoteFilesystem</argument> + </arguments> + </type> + <type name="Magento\ImportExport\Helper\Report"> + <arguments> + <argument name="filesystem" xsi:type="object">fullRemoteFilesystem</argument> + </arguments> + </type> + <type name="Magento\CatalogImportExport\Model\Import\Product"> + <arguments> + <argument name="filesystem" xsi:type="object">fullRemoteFilesystem</argument> + </arguments> + </type> + <type name="Magento\CatalogImportExport\Model\Import\Uploader"> + <arguments> + <argument name="filesystem" xsi:type="object">fullRemoteFilesystem</argument> + </arguments> + </type> + <type name="Magento\DownloadableImportExport\Helper\Uploader"> + <arguments> + <argument name="filesystem" xsi:type="object">fullRemoteFilesystem</argument> + </arguments> + </type> + <type name="Magento\RemoteStorage\Model\Synchronizer"> + <arguments> + <argument name="filesystem" xsi:type="object">customRemoteFilesystem</argument> + </arguments> + </type> +</config> diff --git a/app/code/Magento/RemoteStorage/etc/module.xml b/app/code/Magento/RemoteStorage/etc/module.xml new file mode 100644 index 0000000000000..c06658c11ea90 --- /dev/null +++ b/app/code/Magento/RemoteStorage/etc/module.xml @@ -0,0 +1,17 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd"> + <module name="Magento_RemoteStorage" > + <sequence> + <module name="Magento_Backend"/> + <module name="Magento_Sitemap"/> + <module name="Magento_Store"/> + <module name="Magento_MediaStorage"/> + </sequence> + </module> +</config> diff --git a/app/code/Magento/RemoteStorage/registration.php b/app/code/Magento/RemoteStorage/registration.php new file mode 100644 index 0000000000000..3a6d6b67a8dcf --- /dev/null +++ b/app/code/Magento/RemoteStorage/registration.php @@ -0,0 +1,11 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +\Magento\Framework\Component\ComponentRegistrar::register( + \Magento\Framework\Component\ComponentRegistrar::MODULE, + 'Magento_RemoteStorage', + __DIR__ +); diff --git a/app/code/Magento/Reports/Block/Adminhtml/Grid.php b/app/code/Magento/Reports/Block/Adminhtml/Grid.php index 8885c94c6989a..eade7250f6123 100644 --- a/app/code/Magento/Reports/Block/Adminhtml/Grid.php +++ b/app/code/Magento/Reports/Block/Adminhtml/Grid.php @@ -209,7 +209,7 @@ protected function _getAllowedStoreIds() } elseif ($this->getRequest()->getParam('website')) { $storeIds = $this->_storeManager->getWebsite($this->getRequest()->getParam('website'))->getStoreIds(); } elseif ($this->getRequest()->getParam('group')) { - $storeIds = $storeIds = $this->_storeManager->getGroup( + $storeIds = $this->_storeManager->getGroup( $this->getRequest()->getParam('group') )->getStoreIds(); } diff --git a/app/code/Magento/Reports/Block/Adminhtml/Grid/Column/Renderer/Currency.php b/app/code/Magento/Reports/Block/Adminhtml/Grid/Column/Renderer/Currency.php index f22b3e7bb963b..1ebfd64b2f37c 100644 --- a/app/code/Magento/Reports/Block/Adminhtml/Grid/Column/Renderer/Currency.php +++ b/app/code/Magento/Reports/Block/Adminhtml/Grid/Column/Renderer/Currency.php @@ -3,36 +3,164 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); namespace Magento\Reports\Block\Adminhtml\Grid\Column\Renderer; +use Magento\Backend\Block\Widget\Grid\Column\Renderer\Currency as BackendCurrency; +use Magento\Backend\Block\Context; +use Magento\Directory\Model\Currency\DefaultLocator; +use Magento\Directory\Model\CurrencyFactory; +use Magento\Framework\DataObject; +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\Exception\NoSuchEntityException; +use Magento\Framework\Locale\CurrencyInterface; +use Magento\Store\Model\ScopeInterface; +use Magento\Store\Model\Store; +use Magento\Store\Model\StoreManagerInterface; +use Zend_Currency_Exception; + /** * Adminhtml grid item renderer currency * * @author Magento Core Team <core@magentocommerce.com> * @api * @since 100.0.2 + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ -class Currency extends \Magento\Backend\Block\Widget\Grid\Column\Renderer\Currency +class Currency extends BackendCurrency { + /** + * @var CurrencyFactory + */ + private $currencyFactory; + + /** + * @param Context $context + * @param StoreManagerInterface $storeManager + * @param DefaultLocator $currencyLocator + * @param CurrencyFactory $currencyFactory + * @param CurrencyInterface $localeCurrency + * @param array $data + */ + public function __construct( + Context $context, + StoreManagerInterface $storeManager, + DefaultLocator $currencyLocator, + CurrencyFactory $currencyFactory, + CurrencyInterface $localeCurrency, + array $data = [] + ) { + parent::__construct( + $context, + $storeManager, + $currencyLocator, + $currencyFactory, + $localeCurrency, + $data + ); + $this->currencyFactory = $currencyFactory; + } + /** * Renders grid column * - * @param \Magento\Framework\DataObject $row + * @param DataObject $row * @return string + * @throws LocalizedException + * @throws NoSuchEntityException + * @throws Zend_Currency_Exception */ - public function render(\Magento\Framework\DataObject $row) + public function render(DataObject $row) { $data = $row->getData($this->getColumn()->getIndex()); - $currencyCode = $this->_getCurrencyCode($row); + $currencyCode = $this->getStoreCurrencyCode($row); if (!$currencyCode) { return $data; } - $data = (float)$data * $this->_getRate($row); + $rate = $this->getStoreCurrencyRate($currencyCode, $row); + + $data = (float)$data * $rate; $data = sprintf("%f", $data); $data = $this->_localeCurrency->getCurrency($currencyCode)->toCurrency($data); return $data; } + + /** + * Get admin currency code + * + * @return string + * @throws LocalizedException + * @throws NoSuchEntityException + */ + private function getAdminCurrencyCode(): string + { + $adminWebsiteId = (int) $this->_storeManager + ->getStore(Store::ADMIN_CODE) + ->getWebsiteId(); + return (string) $this->_storeManager + ->getWebsite($adminWebsiteId) + ->getBaseCurrencyCode(); + } + + /** + * Get store currency code + * + * @param DataObject $row + * @return string + * @throws NoSuchEntityException + */ + private function getStoreCurrencyCode(DataObject $row): string + { + $catalogPriceScope = $this->getCatalogPriceScope(); + $storeId = $this->_request->getParam('store_ids'); + if ($catalogPriceScope != 0 && !empty($storeId)) { + $currencyCode = $this->_storeManager->getStore($storeId)->getBaseCurrencyCode(); + } elseif ($catalogPriceScope != 0) { + $currencyCode = $this->_currencyLocator->getDefaultCurrency($this->_request); + } else { + $currencyCode = $this->_getCurrencyCode($row); + } + return $currencyCode; + } + + /** + * Get store currency rate + * + * @param string $currencyCode + * @param DataObject $row + * @return float + * @throws LocalizedException + * @throws NoSuchEntityException + */ + private function getStoreCurrencyRate(string $currencyCode, DataObject $row): float + { + $catalogPriceScope = $this->getCatalogPriceScope(); + $adminCurrencyCode = $this->getAdminCurrencyCode(); + + if (($catalogPriceScope != 0 + && $adminCurrencyCode !== $currencyCode)) { + $storeCurrency = $this->currencyFactory->create()->load($adminCurrencyCode); + $currencyRate = $storeCurrency->getRate($currencyCode); + } else { + $currencyRate = $this->_getRate($row); + } + return (float) $currencyRate; + } + + /** + * Get catalog price scope from the admin config + * + * @return int + */ + private function getCatalogPriceScope(): int + { + return (int) $this->_scopeConfig->getValue( + Store::XML_PATH_PRICE_SCOPE, + ScopeInterface::SCOPE_WEBSITE + ); + } } diff --git a/app/code/Magento/Reports/Block/Adminhtml/Grid/Shopcart.php b/app/code/Magento/Reports/Block/Adminhtml/Grid/Shopcart.php index afa0ce79aca6e..1d65dd5874c6e 100644 --- a/app/code/Magento/Reports/Block/Adminhtml/Grid/Shopcart.php +++ b/app/code/Magento/Reports/Block/Adminhtml/Grid/Shopcart.php @@ -28,6 +28,7 @@ class Shopcart extends \Magento\Backend\Block\Widget\Grid\Extended /** * StoreIds setter + * * @codeCoverageIgnore * * @param array $storeIds @@ -46,6 +47,10 @@ public function setStoreIds($storeIds) */ public function getCurrentCurrencyCode() { + if (empty($this->_storeIds)) { + $this->setStoreIds(array_keys($this->_storeManager->getStores())); + } + if ($this->_currentCurrencyCode === null) { reset($this->_storeIds); $this->_currentCurrencyCode = count( diff --git a/app/code/Magento/Reports/Block/Product/Viewed.php b/app/code/Magento/Reports/Block/Product/Viewed.php index ba4d03182213a..09d59e475905b 100644 --- a/app/code/Magento/Reports/Block/Product/Viewed.php +++ b/app/code/Magento/Reports/Block/Product/Viewed.php @@ -76,10 +76,10 @@ protected function _toHtml() */ public function getIdentities() { - $identities = [[]]; + $identities = []; foreach ($this->getItemsCollection() as $item) { $identities[] = $item->getIdentities(); } - return array_merge(...$identities); + return array_merge([], ...$identities); } } diff --git a/app/code/Magento/Reports/Model/Product/DataRetriever.php b/app/code/Magento/Reports/Model/Product/DataRetriever.php new file mode 100644 index 0000000000000..c6260a4e7bacc --- /dev/null +++ b/app/code/Magento/Reports/Model/Product/DataRetriever.php @@ -0,0 +1,106 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Reports\Model\Product; + +use Magento\Catalog\Model\ResourceModel\Product\Collection as ProductCollection; +use Magento\Catalog\Model\ResourceModel\Product\CollectionFactory as ProductCollectionFactory; +use Magento\Store\Model\StoreManagerInterface; + +/** + * Retrieve products data for reports by entity id's + */ +class DataRetriever +{ + /** + * @var ProductCollectionFactory + */ + private $productCollectionFactory; + + /** + * @var StoreManagerInterface + */ + private $storeManager; + + /** + * DataRetriever constructor. + * + * @param ProductCollectionFactory $productCollectionFactory + * @param StoreManagerInterface $storeManager + */ + public function __construct( + ProductCollectionFactory $productCollectionFactory, + StoreManagerInterface $storeManager + ) { + $this->productCollectionFactory = $productCollectionFactory; + $this->storeManager = $storeManager; + } + + /** + * Retrieve products data by entity id's + * + * @param array $entityIds + * @return array + */ + public function execute(array $entityIds = []): array + { + $productCollection = $this->getProductCollection($entityIds); + + return $this->prepareDataByCollection($productCollection); + } + + /** + * Get product collection filtered by entity id's + * + * @param array $entityIds + * @return ProductCollection + */ + private function getProductCollection(array $entityIds = []): ProductCollection + { + $productCollection = $this->productCollectionFactory->create(); + $productCollection->addAttributeToSelect('name'); + $productCollection->addIdFilter($entityIds); + $productCollection->addPriceData(null, $this->getWebsiteIdForFilter()); + + return $productCollection; + } + + /** + * Retrieve website id for filter collection + * + * @return int + */ + private function getWebsiteIdForFilter(): int + { + $defaultStoreView = $this->storeManager->getDefaultStoreView(); + if ($defaultStoreView) { + $websiteId = (int)$defaultStoreView->getWebsiteId(); + } else { + $websites = $this->storeManager->getWebsites(); + $website = reset($websites); + $websiteId = (int)$website->getId(); + } + + return $websiteId; + } + + /** + * Prepare data by collection + * + * @param ProductCollection $productCollection + * @return array + */ + private function prepareDataByCollection(ProductCollection $productCollection): array + { + $productsData = []; + foreach ($productCollection as $product) { + $productsData[$product->getId()] = $product->getData(); + } + + return $productsData; + } +} diff --git a/app/code/Magento/Reports/Model/ResourceModel/Event.php b/app/code/Magento/Reports/Model/ResourceModel/Event.php index 1f621a3fde39d..d27b8a03fecec 100644 --- a/app/code/Magento/Reports/Model/ResourceModel/Event.php +++ b/app/code/Magento/Reports/Model/ResourceModel/Event.php @@ -93,6 +93,9 @@ public function applyLogToCollection( $skipIds = [] ) { $idFieldName = $collection->getResource()->getIdFieldName(); + $predefinedStoreIds = ($collection->getStoreId() === null) + ? null + : [$collection->getStoreId()]; $derivedSelect = $this->getConnection() ->select() @@ -103,7 +106,7 @@ public function applyLogToCollection( ->where('event_type_id = ?', (int) $eventTypeId) ->where('subject_id = ?', (int) $eventSubjectId) ->where('subtype = ?', (int) $subtype) - ->where('store_id IN(?)', $this->getCurrentStoreIds()) + ->where('store_id IN(?)', $this->getCurrentStoreIds($predefinedStoreIds)) ->group('object_id'); if ($skipIds) { @@ -132,13 +135,11 @@ public function getCurrentStoreIds(array $predefinedStoreIds = null) { $stores = []; // get all or specified stores - if ($this->_storeManager->getStore()->getId() == 0) { - if (null !== $predefinedStoreIds) { - $stores = $predefinedStoreIds; - } else { - foreach ($this->_storeManager->getStores() as $store) { - $stores[] = $store->getId(); - } + if ($predefinedStoreIds !== null) { + $stores = $predefinedStoreIds; + } elseif ($this->_storeManager->getStore()->getId() == 0) { + foreach ($this->_storeManager->getStores() as $store) { + $stores[] = $store->getId(); } } else { // get all stores, required by configuration in current store scope diff --git a/app/code/Magento/Reports/Model/ResourceModel/Order/Collection.php b/app/code/Magento/Reports/Model/ResourceModel/Order/Collection.php index 44571550459c2..47583c27370fb 100644 --- a/app/code/Magento/Reports/Model/ResourceModel/Order/Collection.php +++ b/app/code/Magento/Reports/Model/ResourceModel/Order/Collection.php @@ -159,7 +159,7 @@ public function prepareSummary($range, $customStart, $customEnd, $isFilter = 0) if ($this->_isLive) { $this->_prepareSummaryLive($range, $customStart, $customEnd, $isFilter); } else { - $this->_prepareSummaryAggregated($range, $customStart, $customEnd, $isFilter); + $this->_prepareSummaryAggregated($range, $customStart, $customEnd); } return $this; diff --git a/app/code/Magento/Reports/Model/ResourceModel/Quote/Item/Collection.php b/app/code/Magento/Reports/Model/ResourceModel/Quote/Item/Collection.php index 16df2d30db40d..e7dc28eb74a49 100644 --- a/app/code/Magento/Reports/Model/ResourceModel/Quote/Item/Collection.php +++ b/app/code/Magento/Reports/Model/ResourceModel/Quote/Item/Collection.php @@ -7,7 +7,8 @@ namespace Magento\Reports\Model\ResourceModel\Quote\Item; -use Magento\Framework\App\ResourceConnection; +use Magento\Framework\App\ObjectManager; +use Magento\Reports\Model\Product\DataRetriever as ProductDataRetriever; /** * Collection of Magento\Quote\Model\Quote\Item @@ -49,6 +50,11 @@ class Collection extends \Magento\Framework\Model\ResourceModel\Db\Collection\Ab */ protected $orderResource; + /** + * @var ProductDataRetriever + */ + private $productDataRetriever; + /** * @param \Magento\Framework\Data\Collection\EntityFactory $entityFactory * @param \Psr\Log\LoggerInterface $logger @@ -59,6 +65,9 @@ class Collection extends \Magento\Framework\Model\ResourceModel\Db\Collection\Ab * @param \Magento\Sales\Model\ResourceModel\Order\Collection $orderResource * @param \Magento\Framework\DB\Adapter\AdapterInterface $connection * @param \Magento\Framework\Model\ResourceModel\Db\AbstractDb $resource + * @param ProductDataRetriever|null $productDataRetriever + * + * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( \Magento\Framework\Data\Collection\EntityFactory $entityFactory, @@ -69,7 +78,8 @@ public function __construct( \Magento\Customer\Model\ResourceModel\Customer $customerResource, \Magento\Sales\Model\ResourceModel\Order\Collection $orderResource, \Magento\Framework\DB\Adapter\AdapterInterface $connection = null, - \Magento\Framework\Model\ResourceModel\Db\AbstractDb $resource = null + \Magento\Framework\Model\ResourceModel\Db\AbstractDb $resource = null, + ?ProductDataRetriever $productDataRetriever = null ) { parent::__construct( $entityFactory, @@ -82,6 +92,8 @@ public function __construct( $this->productResource = $productResource; $this->customerResource = $customerResource; $this->orderResource = $orderResource; + $this->productDataRetriever = $productDataRetriever + ?? ObjectManager::getInstance()->get(ProductDataRetriever::class); } /** @@ -225,7 +237,7 @@ protected function _afterLoad() foreach ($items as $item) { $productIds[] = $item->getProductId(); } - $productData = $this->getProductData($productIds); + $productData = $this->productDataRetriever->execute($productIds); $orderData = $this->getOrdersData($productIds); foreach ($items as $item) { $item->setId($item->getProductId()); diff --git a/app/code/Magento/Reports/Test/Mftf/Test/CancelOrdersInOrderSalesReportTest.xml b/app/code/Magento/Reports/Test/Mftf/Test/CancelOrdersInOrderSalesReportTest.xml index 3e79eb044b5cb..6e9e8e800e076 100644 --- a/app/code/Magento/Reports/Test/Mftf/Test/CancelOrdersInOrderSalesReportTest.xml +++ b/app/code/Magento/Reports/Test/Mftf/Test/CancelOrdersInOrderSalesReportTest.xml @@ -47,7 +47,7 @@ <argument name="customer" value="$$createCustomer$$"/> </actionGroup> - <click selector="{{AdminOrderDetailsMainActionsSection.invoice}}" stepKey="clickInvoiceAction"/> + <actionGroup ref="AdminClickInvoiceButtonOrderViewActionGroup" stepKey="clickInvoiceAction"/> <see selector="{{AdminHeaderSection.pageTitle}}" userInput="New Invoice" stepKey="seePageNameNewInvoicePage"/> <actionGroup ref="AdminInvoiceClickSubmitActionGroup" stepKey="clickSubmitInvoice"/> <click selector="{{AdminOrderDetailsMainActionsSection.ship}}" stepKey="clickShipAction"/> diff --git a/app/code/Magento/Reports/Test/Unit/Block/Adminhtml/Grid/Column/Renderer/CurrencyTest.php b/app/code/Magento/Reports/Test/Unit/Block/Adminhtml/Grid/Column/Renderer/CurrencyTest.php new file mode 100644 index 0000000000000..f071bffe57c1e --- /dev/null +++ b/app/code/Magento/Reports/Test/Unit/Block/Adminhtml/Grid/Column/Renderer/CurrencyTest.php @@ -0,0 +1,300 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Reports\Test\Unit\Block\Adminhtml\Grid\Column\Renderer; + +use Magento\Backend\Block\Widget\Grid\Column; +use Magento\Directory\Model\Currency as CurrencyModel; +use Magento\Directory\Model\Currency\DefaultLocator; +use Magento\Directory\Model\CurrencyFactory; +use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Framework\DataObject; +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\Exception\NoSuchEntityException; +use Magento\Framework\Locale\CurrencyInterface; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; +use Magento\Reports\Block\Adminhtml\Grid\Column\Renderer\Currency; +use Magento\Store\Api\Data\StoreInterface; +use Magento\Store\Api\Data\WebsiteInterface; +use Magento\Store\Model\StoreManagerInterface; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Zend_Currency; +use Zend_Currency_Exception; + +/** + * Test for class Currency. + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ +class CurrencyTest extends TestCase +{ + /** + * @var Currency|MockObject + */ + private $model; + + /** + * @var StoreManagerInterface|MockObject + */ + private $storeManagerMock; + + /** + * @var DefaultLocator|MockObject + */ + private $currencyLocatorMock; + + /** + * @var CurrencyInterface|MockObject + */ + private $localeCurrencyMock; + + /** + * @var Column|MockObject + */ + private $gridColumnMock; + + /** + * @var ScopeConfigInterface|MockObject + */ + private $scopeConfigMock; + + /** + * @var StoreInterface|MockObject + */ + private $storeMock; + + /** + * @var WebsiteInterface|MockObject + */ + private $websiteMock; + + /** + * @var DataObject + */ + private $row; + + /** + * @var CurrencyModel|MockObject + */ + private $currencyMock; + + /** + * @var MockObject + */ + private $backendCurrencyMock; + + protected function setUp(): void + { + $objectManager = new ObjectManager($this); + $this->scopeConfigMock = $this->getMockForAbstractClass( + ScopeConfigInterface::class, + [], + '', + true, + true, + true, + ['getValue'] + ); + + $this->storeManagerMock = $this->getMockForAbstractClass( + StoreManagerInterface::class, + [], + '', + true, + true, + true, + ['getStore', 'getWebsite'] + ); + + $this->storeMock = $this->getMockForAbstractClass( + StoreInterface::class, + [], + '', + true, + true, + true, + ['getWebsiteId', 'getCurrentCurrencyCode'] + ); + + $this->websiteMock = $this->getMockForAbstractClass( + WebsiteInterface::class, + [], + '', + true, + true, + true, + ['getBaseCurrencyCode'] + ); + + $this->currencyLocatorMock = $this->getMockBuilder(DefaultLocator::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->currencyMock = $this->createMock(CurrencyModel::class); + $this->currencyMock->expects($this->any())->method('load')->willReturnSelf(); + + $currencyFactoryMock = $this->createPartialMock(CurrencyFactory::class, ['create']); + $currencyFactoryMock->expects($this->any())->method('create')->willReturn($this->currencyMock); + + $this->backendCurrencyMock = $this->getMockBuilder(Currency::class) + ->disableOriginalConstructor() + ->getMock(); + $this->localeCurrencyMock = $this->getMockForAbstractClass( + CurrencyInterface::class, + [], + '', + true, + true, + true, + ['getCurrency'] + ); + + $this->gridColumnMock = $this->getMockBuilder(Column::class) + ->addMethods(['getIndex']) + ->disableOriginalConstructor() + ->getMock(); + $this->model = $objectManager->getObject( + Currency::class, + [ + '_scopeConfig' => $this->scopeConfigMock, + 'storeManager' => $this->storeManagerMock, + 'currencyLocator' => $this->currencyLocatorMock, + 'currencyFactory' => $currencyFactoryMock, + 'localeCurrency' => $this->localeCurrencyMock + ] + ); + $this->model->setColumn($this->gridColumnMock); + } + + /** + * Test render function which converts store currency based on price scope settings + * + * @param float $rate + * @param string $columnIndex + * @param int $catalogPriceScope + * @param int $adminWebsiteId + * @param string $adminCurrencyCode + * @param string $storeCurrencyCode + * @param float $adminOrderAmount + * @param float $convertedAmount + * @throws LocalizedException + * @throws NoSuchEntityException + * @throws Zend_Currency_Exception + * @dataProvider getCurrencyDataProvider + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + public function testRender( + float $rate, + string $columnIndex, + int $catalogPriceScope, + int $adminWebsiteId, + string $adminCurrencyCode, + string $storeCurrencyCode, + float $adminOrderAmount, + float $convertedAmount + ): void { + $this->row = new DataObject( + [ + $columnIndex => $adminOrderAmount, + 'rate' => $rate + ] + ); + $this->backendCurrencyMock + ->expects($this->any()) + ->method('getColumn') + ->willReturn($this->gridColumnMock); + $this->gridColumnMock + ->expects($this->any()) + ->method('getIndex') + ->willReturn($columnIndex); + $this->currencyMock + ->expects($this->any()) + ->method('getRate') + ->willReturn($rate); + $this->scopeConfigMock + ->expects($this->any()) + ->method('getValue') + ->willReturn($catalogPriceScope); + $this->storeManagerMock + ->expects($this->any()) + ->method('getStore') + ->willReturn($this->storeMock); + $this->storeMock + ->expects($this->any()) + ->method('getWebsiteId') + ->willReturn($adminWebsiteId); + $this->storeManagerMock + ->expects($this->any()) + ->method('getWebsite') + ->with($adminWebsiteId) + ->willReturn($this->websiteMock); + $this->websiteMock + ->expects($this->any()) + ->method('getBaseCurrencyCode') + ->willReturn($adminCurrencyCode); + $this->currencyLocatorMock + ->expects($this->any()) + ->method('getDefaultCurrency') + ->willReturn($storeCurrencyCode); + $currLocaleMock = $this->createMock(Zend_Currency::class); + $currLocaleMock + ->expects($this->any()) + ->method('toCurrency') + ->willReturn($convertedAmount); + $this->localeCurrencyMock + ->expects($this->any()) + ->method('getCurrency') + ->with($storeCurrencyCode) + ->willReturn($currLocaleMock); + $actualAmount = $this->model->render($this->row); + $this->assertEquals($convertedAmount, $actualAmount); + } + + /** + * DataProvider for testRender. + * + * @return array + */ + public function getCurrencyDataProvider(): array + { + return [ + 'rate conversion with same admin and storefront rate' => [ + 'rate' => 1.00, + 'columnIndex' => 'total_income_amount', + 'catalogPriceScope' => 1, + 'adminWebsiteId' => 1, + 'adminCurrencyCode' => 'EUR', + 'storeCurrencyCode' => 'EUR', + 'adminOrderAmount' => 105.00, + 'convertedAmount' => 105.00 + ], + 'rate conversion with different admin and storefront rate' => [ + 'rate' => 1.4150, + 'columnIndex' => 'total_income_amount', + 'catalogPriceScope' => 1, + 'adminWebsiteId' => 1, + 'adminCurrencyCode' => 'USD', + 'storeCurrencyCode' => 'EUR', + 'adminOrderAmount' => 105.00, + 'convertedAmount' => 148.575 + ] + ]; + } + + protected function tearDown(): void + { + unset($this->scopeConfigMock); + unset($this->storeManagerMock); + unset($this->currencyLocatorMock); + unset($this->localeCurrencyMock); + unset($this->websiteMock); + unset($this->storeMock); + unset($this->currencyMock); + unset($this->backendCurrencyMock); + unset($this->gridColumnMock); + } +} diff --git a/app/code/Magento/Reports/Test/Unit/Block/Adminhtml/Grid/ShopcartTest.php b/app/code/Magento/Reports/Test/Unit/Block/Adminhtml/Grid/ShopcartTest.php new file mode 100644 index 0000000000000..25dcccdb1ef7a --- /dev/null +++ b/app/code/Magento/Reports/Test/Unit/Block/Adminhtml/Grid/ShopcartTest.php @@ -0,0 +1,110 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Reports\Test\Unit\Block\Adminhtml\Grid; + +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; +use Magento\Reports\Block\Adminhtml\Grid\Shopcart; +use Magento\Store\Api\Data\StoreInterface; +use Magento\Store\Model\StoreManagerInterface; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * Test for class \Magento\Reports\Block\Adminhtml\Grid\Shopcart. + */ +class ShopcartTest extends TestCase +{ + /** + * @var Shopcart|MockObject + */ + private $model; + + /** + * @var StoreManagerInterface|MockObject + */ + private $storeManagerMock; + + protected function setUp(): void + { + $objectManager = new ObjectManager($this); + + $this->storeManagerMock = $this->getMockForAbstractClass( + StoreManagerInterface::class, + [], + '', + true, + true, + true, + ['getStore'] + ); + + $this->model = $objectManager->getObject( + Shopcart::class, + ['_storeManager' => $this->storeManagerMock] + ); + } + + /** + * @param $storeIds + * + * @dataProvider getCurrentCurrencyCodeDataProvider + */ + public function testGetCurrentCurrencyCode($storeIds) + { + $storeMock = $this->getMockForAbstractClass( + StoreInterface::class, + [], + '', + true, + true, + true, + ['getBaseCurrencyCode'] + ); + + $this->model->setStoreIds($storeIds); + + if ($storeIds) { + $expectedCurrencyCode = 'EUR'; + $this->storeManagerMock->expects($this->once()) + ->method('getStore') + ->with($storeIds[0]) + ->willReturn($storeMock); + $storeMock->expects($this->once()) + ->method('getBaseCurrencyCode') + ->willReturn($expectedCurrencyCode); + } else { + $expectedCurrencyCode = 'USD'; + $this->storeManagerMock->expects($this->once()) + ->method('getStore') + ->with(1) + ->willReturn($storeMock); + $this->storeManagerMock->expects($this->once()) + ->method('getStores') + ->willReturn([1 => $storeMock]); + $storeMock->expects($this->once()) + ->method('getBaseCurrencyCode') + ->willReturn($expectedCurrencyCode); + } + + $currencyCode = $this->model->getCurrentCurrencyCode(); + $this->assertEquals($expectedCurrencyCode, $currencyCode); + } + + /** + * DataProvider for testGetCurrentCurrencyCode. + * + * @return array + */ + public function getCurrentCurrencyCodeDataProvider() + { + return [ + [[]], + [[2]], + ]; + } +} diff --git a/app/code/Magento/Reports/Test/Unit/Model/ResourceModel/EventTest.php b/app/code/Magento/Reports/Test/Unit/Model/ResourceModel/EventTest.php index adb31b52161f8..bc064a434fa32 100644 --- a/app/code/Magento/Reports/Test/Unit/Model/ResourceModel/EventTest.php +++ b/app/code/Magento/Reports/Test/Unit/Model/ResourceModel/EventTest.php @@ -134,9 +134,13 @@ public function testUpdateCustomerTypeWithType() } /** + * @dataProvider getApplyLogToCollectionDataProvider + * @param null|array $storeId + * @param null|array $storeIdSelect + * * @return void */ - public function testApplyLogToCollection() + public function testApplyLogToCollection($storeId, $storeIdSelect) { $derivedSelect = 'SELECT * FROM table'; $idFieldName = 'IdFieldName'; @@ -160,6 +164,7 @@ public function testApplyLogToCollection() ->willReturnSelf(); $collectionMock = $this->getMockBuilder(AbstractDb::class) + ->setMethods(['getResource', 'getIdFieldName', 'getSelect', 'getStoreId']) ->disableOriginalConstructor() ->getMock(); $collectionMock @@ -174,6 +179,10 @@ public function testApplyLogToCollection() ->expects($this->any()) ->method('getSelect') ->willReturn($collectionSelectMock); + $collectionMock + ->expects($this->any()) + ->method('getStoreId') + ->willReturn($storeId); $selectMock = $this->getMockBuilder(Select::class) ->disableOriginalConstructor() @@ -195,6 +204,15 @@ public function testApplyLogToCollection() ->expects($this->any()) ->method('__toString') ->willReturn($derivedSelect); + $selectMock + ->expects($this->any()) + ->method('where') + ->willReturnMap([ + ['event_type_id = ?', 1], + ['subject_id = ?', 1], + ['subtype = ?', 1], + ['store_id IN(?)', $storeIdSelect] + ]); $this->connectionMock ->expects($this->once()) @@ -209,6 +227,16 @@ public function testApplyLogToCollection() $this->event->applyLogToCollection($collectionMock, 1, 1, 1); } + /** + * @return array + */ + public function getApplyLogToCollectionDataProvider() + { + return [ + ['storeId' => 1, 'storeIdSelect' => [1]], + ['storeId' => null, 'storeIdSelect' => [1]], + ]; + } /** * @return void */ diff --git a/app/code/Magento/Reports/Test/Unit/Model/ResourceModel/Report/Quote/CollectionTest.php b/app/code/Magento/Reports/Test/Unit/Model/ResourceModel/Report/Quote/CollectionTest.php index 6e7d5bdce16f5..90d224ee417db 100644 --- a/app/code/Magento/Reports/Test/Unit/Model/ResourceModel/Report/Quote/CollectionTest.php +++ b/app/code/Magento/Reports/Test/Unit/Model/ResourceModel/Report/Quote/CollectionTest.php @@ -7,15 +7,17 @@ namespace Magento\Reports\Test\Unit\Model\ResourceModel\Report\Quote; -use Magento\Eav\Model\Entity\AbstractEntity; +use Magento\Catalog\Model\ResourceModel\Product\Collection as ProductCollection; use Magento\Eav\Model\Entity\Attribute\AbstractAttribute; -use Magento\Framework\DB\Adapter\AdapterInterface; use Magento\Framework\DB\Adapter\Pdo\Mysql; use Magento\Framework\DB\Select; use Magento\Framework\Event\ManagerInterface; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; use Magento\Quote\Model\ResourceModel\Quote; -use Magento\Reports\Model\ResourceModel\Quote\Collection; +use Magento\Reports\Model\Product\DataRetriever as ProductDataRetriever; +use Magento\Reports\Model\ResourceModel\Quote\Collection as QuoteCollection; +use Magento\Reports\Model\ResourceModel\Quote\Item\Collection as QuoteItemCollection; +use Magento\Sales\Model\ResourceModel\Order\Collection as OrderCollection; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -34,16 +36,22 @@ class CollectionTest extends TestCase */ protected $selectMock; + /** + * @var ProductDataRetriever|MockObject + */ + private $productDataRetriever; + protected function setUp(): void { $this->objectManager = new ObjectManager($this); $this->selectMock = $this->createMock(Select::class); + $this->productDataRetriever = $this->createMock(ProductDataRetriever::class); } public function testGetSelectCountSql() { /** @var MockObject $collection */ - $collection = $this->getMockBuilder(Collection::class) + $collection = $this->getMockBuilder(QuoteCollection::class) ->setMethods(['getSelect']) ->disableOriginalConstructor() ->getMock(); @@ -61,8 +69,8 @@ public function testPrepareActiveCartItems() { /** @var MockObject $collection */ $constructArgs = $this->objectManager - ->getConstructArguments(\Magento\Reports\Model\ResourceModel\Quote\Item\Collection::class); - $collection = $this->getMockBuilder(\Magento\Reports\Model\ResourceModel\Quote\Item\Collection::class) + ->getConstructArguments(QuoteItemCollection::class); + $collection = $this->getMockBuilder(QuoteItemCollection::class) ->setMethods(['getSelect', 'getTable', 'getFlag', 'setFlag']) ->disableOriginalConstructor() ->setConstructorArgs($constructArgs) @@ -88,18 +96,18 @@ public function testLoadWithFilter() { /** @var MockObject $collection */ $constructArgs = $this->objectManager - ->getConstructArguments(\Magento\Reports\Model\ResourceModel\Quote\Item\Collection::class); + ->getConstructArguments(QuoteItemCollection::class); $constructArgs['eventManager'] = $this->getMockForAbstractClass(ManagerInterface::class); - $connectionMock = $this->getMockForAbstractClass(AdapterInterface::class); $resourceMock = $this->createMock(Quote::class); $resourceMock->expects($this->any())->method('getConnection') ->willReturn($this->createMock(Mysql::class)); $constructArgs['resource'] = $resourceMock; - $productResourceMock = $this->createMock(\Magento\Catalog\Model\ResourceModel\Product\Collection::class); + $productResourceMock = $this->createMock(ProductCollection::class); $constructArgs['productResource'] = $productResourceMock; - $orderResourceMock = $this->createMock(\Magento\Sales\Model\ResourceModel\Order\Collection::class); + $orderResourceMock = $this->createMock(OrderCollection::class); $constructArgs['orderResource'] = $orderResourceMock; - $collection = $this->getMockBuilder(\Magento\Reports\Model\ResourceModel\Quote\Item\Collection::class) + $constructArgs['productDataRetriever'] = $this->productDataRetriever; + $collection = $this->getMockBuilder(QuoteItemCollection::class) ->setMethods( [ '_beforeLoad', @@ -129,24 +137,12 @@ public function testLoadWithFilter() //productLoad() $productAttributeMock = $this->createMock(AbstractAttribute::class); $priceAttributeMock = $this->createMock(AbstractAttribute::class); - $productResourceMock->expects($this->once())->method('getConnection')->willReturn($connectionMock); $productResourceMock->expects($this->any())->method('getAttribute') ->willReturnMap([['name', $productAttributeMock], ['price', $priceAttributeMock]]); - $productResourceMock->expects($this->once())->method('getSelect')->willReturn($this->selectMock); - $eavEntity = $this->createMock(AbstractEntity::class); - $eavEntity->expects($this->once())->method('getLinkField')->willReturn('entity_id'); - $productResourceMock->expects($this->once())->method('getEntity')->willReturn($eavEntity); - $this->selectMock->expects($this->once())->method('reset')->willReturnSelf(); - $this->selectMock->expects($this->once())->method('from')->willReturnSelf(); - $this->selectMock->expects($this->once())->method('useStraightJoin')->willReturnSelf(); - $this->selectMock->expects($this->once())->method('joinInner')->willReturnSelf(); - $this->selectMock->expects($this->once())->method('joinLeft')->willReturnSelf(); $collection->expects($this->once())->method('getOrdersData')->willReturn([]); - $productAttributeMock->expects($this->once())->method('getBackend')->willReturnSelf(); - $priceAttributeMock->expects($this->once())->method('getBackend')->willReturnSelf(); - $connectionMock->expects($this->once())->method('fetchAssoc')->willReturn([1, 2, 3]); //_afterLoad() $collection->expects($this->once())->method('getItems')->willReturn([]); + $this->productDataRetriever->expects($this->once())->method('execute')->willReturn([]); $collection->loadWithFilter(); } } diff --git a/app/code/Magento/Reports/composer.json b/app/code/Magento/Reports/composer.json index f1fe6c1e2c83a..df535ae28b135 100644 --- a/app/code/Magento/Reports/composer.json +++ b/app/code/Magento/Reports/composer.json @@ -22,7 +22,8 @@ "magento/module-store": "*", "magento/module-tax": "*", "magento/module-widget": "*", - "magento/module-wishlist": "*" + "magento/module-wishlist": "*", + "magento/module-directory": "*" }, "type": "magento2-module", "license": [ diff --git a/app/code/Magento/Review/Block/Adminhtml/Edit.php b/app/code/Magento/Review/Block/Adminhtml/Edit.php index c85374edb8d98..9162d293f9332 100644 --- a/app/code/Magento/Review/Block/Adminhtml/Edit.php +++ b/app/code/Magento/Review/Block/Adminhtml/Edit.php @@ -220,10 +220,16 @@ protected function _construct() ); } } - Event.observe(window, \'load\', function(){ - Event.observe($("select_stores"), \'change\', review.updateRating); - }); '; + if (!$this->_storeManager->hasSingleStore()) { + $this->_formInitScripts[] = ' + require(["jquery","prototype"], function(jQuery){ + Event.observe(window, \'load\', function(){ + Event.observe($("select_stores"), \'change\', review.updateRating); + }); + }) + '; + } } /** diff --git a/app/code/Magento/Review/Block/Adminhtml/Grid.php b/app/code/Magento/Review/Block/Adminhtml/Grid.php index e20cb7554e094..798d6ae7148af 100644 --- a/app/code/Magento/Review/Block/Adminhtml/Grid.php +++ b/app/code/Magento/Review/Block/Adminhtml/Grid.php @@ -10,12 +10,11 @@ /** * Adminhtml reviews grid * - * @method int getProductId() getProductId() - * @method \Magento\Review\Block\Adminhtml\Grid setProductId() setProductId(int $productId) - * @method int getCustomerId() getCustomerId() - * @method \Magento\Review\Block\Adminhtml\Grid setCustomerId() setCustomerId(int $customerId) - * @method \Magento\Review\Block\Adminhtml\Grid setMassactionIdFieldOnlyIndexValue() - * setMassactionIdFieldOnlyIndexValue(bool $onlyIndex) + * @method int getProductId() + * @method Grid setProductId(int $productId) + * @method int getCustomerId() + * @method Grid setCustomerId(int $customerId) + * @method Grid setMassactionIdFieldOnlyIndexValue(bool $onlyIndex) */ class Grid extends \Magento\Backend\Block\Widget\Grid\Extended { @@ -110,9 +109,7 @@ protected function _afterLoadCollection() } /** - * Prepare collection - * - * @return \Magento\Review\Block\Adminhtml\Grid + * @inheritDoc */ protected function _prepareCollection() { diff --git a/app/code/Magento/Review/Test/Mftf/Test/AdminReviewsByProductsReportTest.xml b/app/code/Magento/Review/Test/Mftf/Test/AdminReviewsByProductsReportTest.xml index 0809ad0fa8541..1f785da69571a 100644 --- a/app/code/Magento/Review/Test/Mftf/Test/AdminReviewsByProductsReportTest.xml +++ b/app/code/Magento/Review/Test/Mftf/Test/AdminReviewsByProductsReportTest.xml @@ -16,6 +16,9 @@ <description value="Review By Products Grid Filters"/> <severity value="AVERAGE"/> <testCaseId value="MC-32333"/> + <skip> + <issueId value="MQE-2288" /> + </skip> </annotations> <before> <!--Login--> diff --git a/app/code/Magento/ReviewGraphQl/etc/graphql/di.xml b/app/code/Magento/ReviewGraphQl/etc/graphql/di.xml new file mode 100644 index 0000000000000..34e309f0b151a --- /dev/null +++ b/app/code/Magento/ReviewGraphQl/etc/graphql/di.xml @@ -0,0 +1,17 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd"> + <type name="Magento\StoreGraphQl\Model\Resolver\Store\StoreConfigDataProvider"> + <arguments> + <argument name="extendedConfigData" xsi:type="array"> + <item name="product_reviews_enabled" xsi:type="string">catalog/review/active</item> + <item name="allow_guests_to_write_product_reviews" xsi:type="string">catalog/review/allow_guest</item> + </argument> + </arguments> + </type> +</config> diff --git a/app/code/Magento/ReviewGraphQl/etc/schema.graphqls b/app/code/Magento/ReviewGraphQl/etc/schema.graphqls index 14b4fc60e8b09..709e25598a737 100644 --- a/app/code/Magento/ReviewGraphQl/etc/schema.graphqls +++ b/app/code/Magento/ReviewGraphQl/etc/schema.graphqls @@ -39,14 +39,14 @@ type ProductReviewRatingsMetadata { } type ProductReviewRatingMetadata { - id: String! @doc(description: "Base64 encoded rating ID.") + id: String! @doc(description: "An encoded rating ID.") name: String! @doc(description: "The label assigned to an aspect of a product that is being rated, such as quality or price") values: [ProductReviewRatingValueMetadata!]! @doc(description: "List of product review ratings sorted by position.") @resolver(class: "Magento\\ReviewGraphQl\\Model\\Resolver\\ProductReviewRatingValueMetadata") } type ProductReviewRatingValueMetadata { - value_id: String! @doc(description: "Base 64 encoded rating value id.") - value: String! @doc(description: "e.g Good, Perfect, 3, 4, 5") + value_id: String! @doc(description: "An encoded rating value id.") + value: String! @doc(description: "A ratings scale, such as the number of stars awarded") } type Customer { @@ -73,6 +73,11 @@ input CreateProductReviewInput { } input ProductReviewRatingInput { - id: String! @doc(description: "Base64 encoded rating ID.") - value_id: String! @doc(description: "Base 64 encoded rating value id.") + id: String! @doc(description: "An encoded rating ID.") + value_id: String! @doc(description: "An encoded rating value id.") +} + +type StoreConfig @doc(description: "The type contains information about a store config") { + product_reviews_enabled : String @doc(description: "Indicates whether product reviews are enabled. Possible values: 1 (Yes) and 0 (No)") + allow_guests_to_write_product_reviews : String @doc(description: "Indicates whether guest users can write product reviews. Possible values: 1 (Yes) and 0 (No)") } diff --git a/app/code/Magento/Sales/Block/Adminhtml/Items/Column/DefaultColumn.php b/app/code/Magento/Sales/Block/Adminhtml/Items/Column/DefaultColumn.php index efef617acf900..81f670de91805 100644 --- a/app/code/Magento/Sales/Block/Adminhtml/Items/Column/DefaultColumn.php +++ b/app/code/Magento/Sales/Block/Adminhtml/Items/Column/DefaultColumn.php @@ -68,7 +68,7 @@ public function getItem() */ public function getOrderOptions() { - $result = [[]]; + $result = []; if ($options = $this->getItem()->getProductOptions()) { if (isset($options['options'])) { $result[] = $options['options']; @@ -80,7 +80,7 @@ public function getOrderOptions() $result[] = $options['attributes_info']; } } - return array_merge(...$result); + return array_merge([], ...$result); } /** diff --git a/app/code/Magento/Sales/Block/Adminhtml/Order/View.php b/app/code/Magento/Sales/Block/Adminhtml/Order/View.php index e4b12c30e71b4..d70df80038193 100644 --- a/app/code/Magento/Sales/Block/Adminhtml/Order/View.php +++ b/app/code/Magento/Sales/Block/Adminhtml/Order/View.php @@ -1,7 +1,5 @@ <?php /** - * @category Magento - * @package Magento_Sales * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ diff --git a/app/code/Magento/Sales/Block/Order/Email/Items/DefaultItems.php b/app/code/Magento/Sales/Block/Order/Email/Items/DefaultItems.php index cbb79f188f231..57fc0441fe830 100644 --- a/app/code/Magento/Sales/Block/Order/Email/Items/DefaultItems.php +++ b/app/code/Magento/Sales/Block/Order/Email/Items/DefaultItems.php @@ -39,7 +39,7 @@ public function getOrder() */ public function getItemOptions() { - $result = [[]]; + $result = []; if ($options = $this->getItem()->getOrderItem()->getProductOptions()) { if (isset($options['options'])) { $result[] = $options['options']; @@ -52,7 +52,7 @@ public function getItemOptions() } } - return array_merge(...$result); + return array_merge([], ...$result); } /** diff --git a/app/code/Magento/Sales/Block/Order/Email/Items/Order/DefaultOrder.php b/app/code/Magento/Sales/Block/Order/Email/Items/Order/DefaultOrder.php index 0291a1275c350..cb9c7315244ac 100644 --- a/app/code/Magento/Sales/Block/Order/Email/Items/Order/DefaultOrder.php +++ b/app/code/Magento/Sales/Block/Order/Email/Items/Order/DefaultOrder.php @@ -34,7 +34,7 @@ public function getOrder() */ public function getItemOptions() { - $result = [[]]; + $result = []; if ($options = $this->getItem()->getProductOptions()) { if (isset($options['options'])) { $result[] = $options['options']; @@ -47,7 +47,7 @@ public function getItemOptions() } } - return array_merge(...$result); + return array_merge([], ...$result); } /** diff --git a/app/code/Magento/Sales/Block/Order/Item/Renderer/DefaultRenderer.php b/app/code/Magento/Sales/Block/Order/Item/Renderer/DefaultRenderer.php index bca6d49760d9a..010878559c2f0 100644 --- a/app/code/Magento/Sales/Block/Order/Item/Renderer/DefaultRenderer.php +++ b/app/code/Magento/Sales/Block/Order/Item/Renderer/DefaultRenderer.php @@ -105,7 +105,7 @@ public function getOrderItem() */ public function getItemOptions() { - $result = [[]]; + $result = []; $options = $this->getOrderItem()->getProductOptions(); if ($options) { if (isset($options['options'])) { @@ -118,7 +118,7 @@ public function getItemOptions() $result[] = $options['attributes_info']; } } - return array_merge(...$result); + return array_merge([], ...$result); } /** diff --git a/app/code/Magento/Sales/Block/Order/Recent.php b/app/code/Magento/Sales/Block/Order/Recent.php index 934f1b5efdcdd..6aef07ce8eb14 100644 --- a/app/code/Magento/Sales/Block/Order/Recent.php +++ b/app/code/Magento/Sales/Block/Order/Recent.php @@ -6,9 +6,9 @@ namespace Magento\Sales\Block\Order; use Magento\Framework\View\Element\Template\Context; -use Magento\Sales\Model\ResourceModel\Order\CollectionFactory; use Magento\Customer\Model\Session; use Magento\Sales\Model\Order\Config; +use Magento\Sales\Model\ResourceModel\Order\CollectionFactoryInterface; use Magento\Store\Model\StoreManagerInterface; use Magento\Framework\App\ObjectManager; @@ -26,7 +26,7 @@ class Recent extends \Magento\Framework\View\Element\Template const ORDER_LIMIT = 5; /** - * @var \Magento\Sales\Model\ResourceModel\Order\CollectionFactory + * @var CollectionFactoryInterface */ protected $_orderCollectionFactory; @@ -47,7 +47,7 @@ class Recent extends \Magento\Framework\View\Element\Template /** * @param \Magento\Framework\View\Element\Template\Context $context - * @param \Magento\Sales\Model\ResourceModel\Order\CollectionFactory $orderCollectionFactory + * @param CollectionFactoryInterface $orderCollectionFactory * @param \Magento\Customer\Model\Session $customerSession * @param \Magento\Sales\Model\Order\Config $orderConfig * @param array $data @@ -55,7 +55,7 @@ class Recent extends \Magento\Framework\View\Element\Template */ public function __construct( Context $context, - CollectionFactory $orderCollectionFactory, + CollectionFactoryInterface $orderCollectionFactory, Session $customerSession, Config $orderConfig, array $data = [], @@ -84,11 +84,12 @@ protected function _construct() */ private function getRecentOrders() { - $orders = $this->_orderCollectionFactory->create()->addAttributeToSelect( + $customerId = $this->_customerSession->getCustomerId(); + $orders = $this->_orderCollectionFactory->create($customerId)->addAttributeToSelect( '*' )->addAttributeToFilter( 'customer_id', - $this->_customerSession->getCustomerId() + $customerId )->addAttributeToFilter( 'store_id', $this->storeManager->getStore()->getId() diff --git a/app/code/Magento/Sales/Controller/Adminhtml/Order/AddComment.php b/app/code/Magento/Sales/Controller/Adminhtml/Order/AddComment.php index e85083a50d725..492d2d71df8d9 100644 --- a/app/code/Magento/Sales/Controller/Adminhtml/Order/AddComment.php +++ b/app/code/Magento/Sales/Controller/Adminhtml/Order/AddComment.php @@ -10,6 +10,8 @@ /** * Class AddComment + * + * Controller responsible for addition of the order comment to the order */ class AddComment extends \Magento\Sales\Controller\Adminhtml\Order implements HttpPostActionInterface { @@ -42,6 +44,7 @@ public function execute() ); } + $order->setStatus($data['status']); $notify = $data['is_customer_notified'] ?? false; $visible = $data['is_visible_on_front'] ?? false; diff --git a/app/code/Magento/Sales/Controller/Adminhtml/Order/Create.php b/app/code/Magento/Sales/Controller/Adminhtml/Order/Create.php index 45cd504be201a..6dbaa188b89e6 100644 --- a/app/code/Magento/Sales/Controller/Adminhtml/Order/Create.php +++ b/app/code/Magento/Sales/Controller/Adminhtml/Order/Create.php @@ -344,6 +344,8 @@ protected function _processActionData($action = null) $this->messageManager->addSuccessMessage(__('The coupon code has been accepted.')); } } + } elseif (isset($data['coupon']['code']) && empty($couponCode)) { + $this->messageManager->addSuccessMessage(__('The coupon code has been removed.')); } return $this; diff --git a/app/code/Magento/Sales/CustomerData/LastOrderedItems.php b/app/code/Magento/Sales/CustomerData/LastOrderedItems.php index f2f36979f30b2..5319e9d7e328b 100644 --- a/app/code/Magento/Sales/CustomerData/LastOrderedItems.php +++ b/app/code/Magento/Sales/CustomerData/LastOrderedItems.php @@ -23,7 +23,7 @@ class LastOrderedItems implements SectionSourceInterface const SIDEBAR_ORDER_LIMIT = 5; /** - * @var \Magento\Sales\Model\ResourceModel\Order\CollectionFactory + * @var \Magento\Sales\Model\ResourceModel\Order\CollectionFactoryInterface */ protected $_orderCollectionFactory; @@ -68,7 +68,7 @@ class LastOrderedItems implements SectionSourceInterface private $logger; /** - * @param \Magento\Sales\Model\ResourceModel\Order\CollectionFactory $orderCollectionFactory + * @param \Magento\Sales\Model\ResourceModel\Order\CollectionFactoryInterface $orderCollectionFactory * @param \Magento\Sales\Model\Order\Config $orderConfig * @param \Magento\Customer\Model\Session $customerSession * @param \Magento\CatalogInventory\Api\StockRegistryInterface $stockRegistry @@ -77,7 +77,7 @@ class LastOrderedItems implements SectionSourceInterface * @param LoggerInterface $logger */ public function __construct( - \Magento\Sales\Model\ResourceModel\Order\CollectionFactory $orderCollectionFactory, + \Magento\Sales\Model\ResourceModel\Order\CollectionFactoryInterface $orderCollectionFactory, \Magento\Sales\Model\Order\Config $orderConfig, \Magento\Customer\Model\Session $customerSession, \Magento\CatalogInventory\Api\StockRegistryInterface $stockRegistry, @@ -103,7 +103,7 @@ protected function initOrders() { $customerId = $this->_customerSession->getCustomerId(); - $orders = $this->_orderCollectionFactory->create() + $orders = $this->_orderCollectionFactory->create($customerId) ->addAttributeToFilter('customer_id', $customerId) ->addAttributeToFilter('status', ['in' => $this->_orderConfig->getVisibleOnFrontStatuses()]) ->addAttributeToSort('created_at', 'desc') @@ -138,7 +138,7 @@ protected function getItems() $this->logger->critical($noEntityException); continue; } - if (isset($product) && in_array($website, $product->getWebsiteIds())) { + if (in_array($website, $product->getWebsiteIds())) { $url = $product->isVisibleInSiteVisibility() ? $product->getProductUrl() : null; $items[] = [ 'id' => $item->getId(), @@ -188,7 +188,7 @@ protected function getLastOrder() } /** - * {@inheritdoc} + * @inheritdoc */ public function getSectionData() { diff --git a/app/code/Magento/Sales/Helper/Guest.php b/app/code/Magento/Sales/Helper/Guest.php index a3f2ac6ba3556..3b7e491086b17 100644 --- a/app/code/Magento/Sales/Helper/Guest.php +++ b/app/code/Magento/Sales/Helper/Guest.php @@ -15,6 +15,7 @@ /** * Sales module base helper * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + * @SuppressWarnings(PHPMD.CookieAndSessionMisuse) */ class Guest extends \Magento\Framework\App\Helper\AbstractHelper { @@ -71,7 +72,7 @@ class Guest extends \Magento\Framework\App\Helper\AbstractHelper const COOKIE_NAME = 'guest-view'; /** - * Cookie path + * Cookie path value */ const COOKIE_PATH = '/'; @@ -151,6 +152,7 @@ public function loadValidOrder(App\RequestInterface $request) return $this->resultRedirectFactory->create()->setPath('sales/order/history'); } $post = $request->getPostValue(); + $post = filter_var($post, FILTER_CALLBACK, ['options' => 'trim']); $fromCookie = $this->cookieManager->getCookie(self::COOKIE_NAME); if (empty($post) && !$fromCookie) { return $this->resultRedirectFactory->create()->setPath('sales/guest/form'); @@ -224,6 +226,7 @@ private function setGuestViewCookie($cookieValue) */ private function loadFromCookie($fromCookie) { + // phpcs:ignore Magento2.Functions.DiscouragedFunction $cookieData = explode(':', base64_decode($fromCookie)); $protectCode = isset($cookieData[0]) ? $cookieData[0] : null; $incrementId = isset($cookieData[1]) ? $cookieData[1] : null; diff --git a/app/code/Magento/Sales/Model/AdminOrder/Create.php b/app/code/Magento/Sales/Model/AdminOrder/Create.php index 8ef12e5889520..1f23e4480ec1c 100644 --- a/app/code/Magento/Sales/Model/AdminOrder/Create.php +++ b/app/code/Magento/Sales/Model/AdminOrder/Create.php @@ -550,6 +550,9 @@ public function initFromOrder(\Magento\Sales\Model\Order $order) $quote = $this->getQuote(); if (!$quote->isVirtual() && $this->getShippingAddress()->getSameAsBilling()) { + $quote->getBillingAddress()->setCustomerAddressId( + $quote->getShippingAddress()->getCustomerAddressId() + ); $this->setShippingAsBilling(1); } @@ -642,6 +645,7 @@ protected function _initShippingAddressFromOrder(\Magento\Sales\Model\Order $ord * @param \Magento\Sales\Model\Order\Item $orderItem * @param int $qty * @return \Magento\Quote\Model\Quote\Item|string|$this + * @SuppressWarnings(PHPMD.CyclomaticComplexity) */ public function initFromOrderItem(\Magento\Sales\Model\Order\Item $orderItem, $qty = null) { @@ -667,9 +671,18 @@ public function initFromOrderItem(\Magento\Sales\Model\Order\Item $orderItem, $q if ($productOptions !== null && !empty($productOptions['options'])) { $formattedOptions = []; foreach ($productOptions['options'] as $option) { + if (in_array($option['option_type'], ['date', 'date_time', 'time', 'file'])) { + $product->setSkipCheckRequiredOption(false); + $formattedOptions[$option['option_id']] = + $buyRequest->getDataByKey('options')[$option['option_id']]; + continue; + } + $formattedOptions[$option['option_id']] = $option['option_value']; } - $buyRequest->setData('options', $formattedOptions); + if (!empty($formattedOptions)) { + $buyRequest->setData('options', $formattedOptions); + } } $item = $this->getQuote()->addProduct($product, $buyRequest); if (is_string($item)) { @@ -1999,15 +2012,17 @@ protected function _validate() $this->_errors[] = __('Please specify order items.'); } + $errors = []; foreach ($items as $item) { /** @var \Magento\Quote\Model\Quote\Item $item */ $messages = $item->getMessage(false); if ($item->getHasError() && is_array($messages) && !empty($messages)) { - // phpcs:ignore Magento2.Performance.ForeachArrayMerge - $this->_errors = array_merge($this->_errors, $messages); + $errors[] = $messages; } } + $this->_errors = array_merge([], $this->_errors, ...$errors); + if (!$this->getQuote()->isVirtual()) { if (!$this->getQuote()->getShippingAddress()->getShippingMethod()) { $this->_errors[] = __('The shipping method is missing. Select the shipping method and try again.'); @@ -2110,6 +2125,9 @@ private function isAddressesAreEqual(Order $order) $billingData['address_type'], $billingData['entity_id'] ); + if (isset($shippingData['customer_address_id']) && !isset($billingData['customer_address_id'])) { + unset($shippingData['customer_address_id']); + } return $shippingData == $billingData; } diff --git a/app/code/Magento/Sales/Model/Order.php b/app/code/Magento/Sales/Model/Order.php index 0af42b0a99d09..fc8088ffc8383 100644 --- a/app/code/Magento/Sales/Model/Order.php +++ b/app/code/Magento/Sales/Model/Order.php @@ -7,6 +7,7 @@ use Magento\Config\Model\Config\Source\Nooptreq; use Magento\Directory\Model\Currency; +use Magento\Directory\Model\RegionFactory; use Magento\Framework\Api\AttributeValueFactory; use Magento\Framework\Api\SearchCriteriaBuilder; use Magento\Framework\App\Config\ScopeConfigInterface; @@ -42,17 +43,17 @@ * * @api * @method int getGiftMessageId() - * @method \Magento\Sales\Model\Order setGiftMessageId(int $value) + * @method Order setGiftMessageId(int $value) * @method bool hasBillingAddressId() - * @method \Magento\Sales\Model\Order unsBillingAddressId() + * @method Order unsBillingAddressId() * @method bool hasShippingAddressId() - * @method \Magento\Sales\Model\Order unsShippingAddressId() + * @method Order unsShippingAddressId() * @method int getShippigAddressId() * @method bool hasCustomerNoteNotify() * @method bool hasForcedCanCreditmemo() * @method bool getIsInProcess() * @method \Magento\Customer\Model\Customer|null getCustomer() - * @method \Magento\Sales\Model\Order setSendEmail(bool $value) + * @method Order setSendEmail(bool $value) * @SuppressWarnings(PHPMD.ExcessivePublicCount) * @SuppressWarnings(PHPMD.TooManyFields) * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) @@ -307,6 +308,16 @@ class Order extends AbstractModel implements EntityInterface, OrderInterface */ private $scopeConfig; + /** + * @var RegionFactory + */ + private $regionFactory; + + /** + * @var array + */ + private $regionItems; + /** * @param \Magento\Framework\Model\Context $context * @param \Magento\Framework\Registry $registry @@ -340,6 +351,7 @@ class Order extends AbstractModel implements EntityInterface, OrderInterface * @param OrderItemRepositoryInterface $itemRepository * @param SearchCriteriaBuilder $searchCriteriaBuilder * @param ScopeConfigInterface $scopeConfig + * @param RegionFactory $regionFactory * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -374,7 +386,8 @@ public function __construct( ProductOption $productOption = null, OrderItemRepositoryInterface $itemRepository = null, SearchCriteriaBuilder $searchCriteriaBuilder = null, - ScopeConfigInterface $scopeConfig = null + ScopeConfigInterface $scopeConfig = null, + RegionFactory $regionFactory = null ) { $this->_storeManager = $storeManager; $this->_orderConfig = $orderConfig; @@ -403,6 +416,8 @@ public function __construct( $this->searchCriteriaBuilder = $searchCriteriaBuilder ?: ObjectManager::getInstance() ->get(SearchCriteriaBuilder::class); $this->scopeConfig = $scopeConfig ?: ObjectManager::getInstance()->get(ScopeConfigInterface::class); + $this->regionFactory = $regionFactory ?: ObjectManager::getInstance()->get(RegionFactory::class); + $this->regionItems = []; parent::__construct( $context, @@ -494,7 +509,7 @@ public function setCanSendNewEmailFlag($flag) * Load order by system increment identifier * * @param string $incrementId - * @return \Magento\Sales\Model\Order + * @return Order */ public function loadByIncrementId($incrementId) { @@ -506,7 +521,7 @@ public function loadByIncrementId($incrementId) * * @param string $incrementId * @param string $storeId - * @return \Magento\Sales\Model\Order + * @return Order */ public function loadByIncrementIdAndStoreId($incrementId, $storeId) { @@ -1346,9 +1361,21 @@ public function getShippingMethod($asObject = false) */ public function getAddressesCollection() { + $region = $this->regionFactory->create(); $collection = $this->_addressCollectionFactory->create()->setOrderFilter($this); if ($this->getId()) { foreach ($collection as $address) { + if (isset($this->regionItems[$address->getCountryId()][$address->getRegion()])) { + if ($this->regionItems[$address->getCountryId()][$address->getRegion()]) { + $address->setRegion($this->regionItems[$address->getCountryId()][$address->getRegion()]); + } + } else { + $region->loadByName($address->getRegion(), $address->getCountryId()); + $this->regionItems[$address->getCountryId()][$address->getRegion()] = $region->getName(); + if ($region->getName()) { + $address->setRegion($region->getName()); + } + } $address->setOrder($this); } } @@ -1818,7 +1845,7 @@ public function getTotalDue() $total = $this->priceCurrency->round($total); return max($total, 0); } - + /** * Retrieve order total due value * @@ -2052,7 +2079,7 @@ public function getStoreGroupName() { $storeId = $this->getStoreId(); if ($storeId === null) { - return $this->getStoreName(1); + return $this->getStoreName(); } return $this->getStore()->getGroup()->getName(); } diff --git a/app/code/Magento/Sales/Model/Order/Config.php b/app/code/Magento/Sales/Model/Order/Config.php index 32b9298be2b5f..20aee5c76cc1f 100644 --- a/app/code/Magento/Sales/Model/Order/Config.php +++ b/app/code/Magento/Sales/Model/Order/Config.php @@ -52,7 +52,7 @@ class Config */ protected $maskStatusesMapping = [ \Magento\Framework\App\Area::AREA_FRONTEND => [ - \Magento\Sales\Model\Order::STATUS_FRAUD => \Magento\Sales\Model\Order::STATE_PROCESSING, + \Magento\Sales\Model\Order::STATUS_FRAUD => \Magento\Sales\Model\Order::STATUS_FRAUD, \Magento\Sales\Model\Order::STATE_PAYMENT_REVIEW => \Magento\Sales\Model\Order::STATE_PROCESSING ] ]; diff --git a/app/code/Magento/Sales/Model/Order/Creditmemo/Sender/EmailSender.php b/app/code/Magento/Sales/Model/Order/Creditmemo/Sender/EmailSender.php index 93c8ed00f9daa..a92a1480bd023 100644 --- a/app/code/Magento/Sales/Model/Order/Creditmemo/Sender/EmailSender.php +++ b/app/code/Magento/Sales/Model/Order/Creditmemo/Sender/EmailSender.php @@ -111,6 +111,12 @@ public function send( 'store' => $order->getStore(), 'formattedShippingAddress' => $this->getFormattedShippingAddress($order), 'formattedBillingAddress' => $this->getFormattedBillingAddress($order), + 'order_data' => [ + 'customer_name' => $order->getCustomerName(), + 'is_not_virtual' => $order->getIsNotVirtual(), + 'email_customer_note' => $order->getEmailCustomerNote(), + 'frontend_status_label' => $order->getFrontendStatusLabel() + ] ]; $transportObject = new DataObject($transport); diff --git a/app/code/Magento/Sales/Model/Order/Email/NotifySender.php b/app/code/Magento/Sales/Model/Order/Email/NotifySender.php index 9e40ab769b0a6..468842d7b2ce4 100644 --- a/app/code/Magento/Sales/Model/Order/Email/NotifySender.php +++ b/app/code/Magento/Sales/Model/Order/Email/NotifySender.php @@ -10,6 +10,7 @@ /** * Class NotifySender + * phpcs:disable Magento2.Classes.AbstractApi * @api * @since 100.0.2 */ @@ -35,7 +36,7 @@ protected function checkAndSend(Order $order, $notify = true) if ($notify) { $sender->send(); - } else { + } elseif ($this->identityContainer->getCopyMethod() === 'copy') { // Email copies are sent as separated emails if their copy method // is 'copy' or a customer should not be notified $sender->sendCopyTo(); diff --git a/app/code/Magento/Sales/Model/Order/Invoice/GetLogoFile.php b/app/code/Magento/Sales/Model/Order/Invoice/GetLogoFile.php new file mode 100644 index 0000000000000..eec3fcdd59092 --- /dev/null +++ b/app/code/Magento/Sales/Model/Order/Invoice/GetLogoFile.php @@ -0,0 +1,77 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Sales\Model\Order\Invoice; + +use Magento\Store\Model\ScopeInterface; +use Magento\Framework\UrlInterface; +use Magento\Framework\App\Config\ScopeConfigInterface; + +/** + * Get Custom Logo File for Invoice HTML print + */ +class GetLogoFile +{ + private const XML_PATH_SALES_IDENTITY_LOGO_HTML = 'sales/identity/logo_html'; + private const LOGO_BASE_DIR = 'sales/store/logo_html/'; + + /** + * @var ScopeConfigInterface + */ + private $scopeConfig; + + /** + * @var UrlInterface + */ + private $urlBuilder; + + /** + * @param ScopeConfigInterface $scopeConfig + * @param UrlInterface $urlBuilder + */ + public function __construct( + ScopeConfigInterface $scopeConfig, + UrlInterface $urlBuilder + ) { + $this->scopeConfig = $scopeConfig; + $this->urlBuilder = $urlBuilder; + } + + /** + * Return Custom Invoice Logo file url if configured in admin + * + * @return string|null + */ + public function execute(): ?string + { + $invoiceLogoPath = $this->getIdentityLogoHtml(); + if (!$invoiceLogoPath) { + return null; + } + + return sprintf( + "%s%s%s", + $this->urlBuilder->getBaseUrl(['_type' => UrlInterface::URL_TYPE_MEDIA]), + self::LOGO_BASE_DIR, + $invoiceLogoPath + ); + } + + /** + * Get Admin Configuration for Invoice Logo HTML + * + * @return null|string + */ + private function getIdentityLogoHtml(): ?string + { + return $this->scopeConfig->getValue( + self::XML_PATH_SALES_IDENTITY_LOGO_HTML, + ScopeInterface::SCOPE_STORE, + null + ); + } +} diff --git a/app/code/Magento/Sales/Model/Order/Invoice/Sender/EmailSender.php b/app/code/Magento/Sales/Model/Order/Invoice/Sender/EmailSender.php index 004f36c277028..44b4df17619d8 100644 --- a/app/code/Magento/Sales/Model/Order/Invoice/Sender/EmailSender.php +++ b/app/code/Magento/Sales/Model/Order/Invoice/Sender/EmailSender.php @@ -111,6 +111,12 @@ public function send( 'store' => $order->getStore(), 'formattedShippingAddress' => $this->getFormattedShippingAddress($order), 'formattedBillingAddress' => $this->getFormattedBillingAddress($order), + 'order_data' => [ + 'customer_name' => $order->getCustomerName(), + 'is_not_virtual' => $order->getIsNotVirtual(), + 'email_customer_note' => $order->getEmailCustomerNote(), + 'frontend_status_label' => $order->getFrontendStatusLabel() + ] ]; $transportObject = new DataObject($transport); diff --git a/app/code/Magento/Sales/Model/Order/Payment.php b/app/code/Magento/Sales/Model/Order/Payment.php index 6a2a77b52927a..d1a34b496b1ac 100644 --- a/app/code/Magento/Sales/Model/Order/Payment.php +++ b/app/code/Magento/Sales/Model/Order/Payment.php @@ -742,7 +742,7 @@ public function refund($creditmemo) $this->formatPrice($baseAmountToRefund) ); } - $message = $message = $this->prependMessage($message); + $message = $this->prependMessage($message); $message = $this->_appendTransactionToMessage($transaction, $message); $orderState = $this->getOrderStateResolver()->getStateForOrder($this->getOrder()); $statuses = $this->getOrder()->getConfig()->getStateStatuses($orderState, false); diff --git a/app/code/Magento/Sales/Model/Order/Pdf/Items/AbstractItems.php b/app/code/Magento/Sales/Model/Order/Pdf/Items/AbstractItems.php index 29e011217ef20..a7315aeb9e3be 100644 --- a/app/code/Magento/Sales/Model/Order/Pdf/Items/AbstractItems.php +++ b/app/code/Magento/Sales/Model/Order/Pdf/Items/AbstractItems.php @@ -326,7 +326,7 @@ public function getItemPricesForDisplay() */ public function getItemOptions() { - $result = [[]]; + $result = []; $options = $this->getItem()->getOrderItem()->getProductOptions(); if ($options) { if (isset($options['options'])) { @@ -339,7 +339,7 @@ public function getItemOptions() $result[] = $options['attributes_info']; } } - return array_merge(...$result); + return array_merge([], ...$result); } /** diff --git a/app/code/Magento/Sales/Model/Order/Shipment/Sender/EmailSender.php b/app/code/Magento/Sales/Model/Order/Shipment/Sender/EmailSender.php index fe68555d9f7c7..534bb127db067 100644 --- a/app/code/Magento/Sales/Model/Order/Shipment/Sender/EmailSender.php +++ b/app/code/Magento/Sales/Model/Order/Shipment/Sender/EmailSender.php @@ -5,9 +5,21 @@ */ namespace Magento\Sales\Model\Order\Shipment\Sender; +use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Framework\Event\ManagerInterface; +use Magento\Payment\Helper\Data; +use Magento\Sales\Api\Data\OrderInterface; +use Magento\Sales\Api\Data\ShipmentCommentCreationInterface; +use Magento\Sales\Api\Data\ShipmentInterface; +use Magento\Sales\Model\Order\Address\Renderer; +use Magento\Sales\Model\Order\Email\Container\ShipmentIdentity; +use Magento\Sales\Model\Order\Email\Container\Template; use Magento\Sales\Model\Order\Email\Sender; +use Magento\Sales\Model\Order\Email\SenderBuilderFactory; use Magento\Sales\Model\Order\Shipment\SenderInterface; use Magento\Framework\DataObject; +use Magento\Sales\Model\ResourceModel\Order\Shipment; +use Psr\Log\LoggerInterface; /** * Email notification sender for Shipment. @@ -17,46 +29,46 @@ class EmailSender extends Sender implements SenderInterface { /** - * @var \Magento\Payment\Helper\Data + * @var Data */ private $paymentHelper; /** - * @var \Magento\Sales\Model\ResourceModel\Order\Shipment + * @var Shipment */ private $shipmentResource; /** - * @var \Magento\Framework\App\Config\ScopeConfigInterface + * @var ScopeConfigInterface */ private $globalConfig; /** - * @var \Magento\Framework\Event\ManagerInterface + * @var ManagerInterface */ private $eventManager; /** - * @param \Magento\Sales\Model\Order\Email\Container\Template $templateContainer - * @param \Magento\Sales\Model\Order\Email\Container\ShipmentIdentity $identityContainer - * @param \Magento\Sales\Model\Order\Email\SenderBuilderFactory $senderBuilderFactory - * @param \Psr\Log\LoggerInterface $logger - * @param \Magento\Sales\Model\Order\Address\Renderer $addressRenderer - * @param \Magento\Payment\Helper\Data $paymentHelper - * @param \Magento\Sales\Model\ResourceModel\Order\Shipment $shipmentResource - * @param \Magento\Framework\App\Config\ScopeConfigInterface $globalConfig - * @param \Magento\Framework\Event\ManagerInterface $eventManager + * @param Template $templateContainer + * @param ShipmentIdentity $identityContainer + * @param SenderBuilderFactory $senderBuilderFactory + * @param LoggerInterface $logger + * @param Renderer $addressRenderer + * @param Data $paymentHelper + * @param Shipment $shipmentResource + * @param ScopeConfigInterface $globalConfig + * @param ManagerInterface $eventManager */ public function __construct( - \Magento\Sales\Model\Order\Email\Container\Template $templateContainer, - \Magento\Sales\Model\Order\Email\Container\ShipmentIdentity $identityContainer, - \Magento\Sales\Model\Order\Email\SenderBuilderFactory $senderBuilderFactory, - \Psr\Log\LoggerInterface $logger, - \Magento\Sales\Model\Order\Address\Renderer $addressRenderer, - \Magento\Payment\Helper\Data $paymentHelper, - \Magento\Sales\Model\ResourceModel\Order\Shipment $shipmentResource, - \Magento\Framework\App\Config\ScopeConfigInterface $globalConfig, - \Magento\Framework\Event\ManagerInterface $eventManager + Template $templateContainer, + ShipmentIdentity $identityContainer, + SenderBuilderFactory $senderBuilderFactory, + LoggerInterface $logger, + Renderer $addressRenderer, + Data $paymentHelper, + Shipment $shipmentResource, + ScopeConfigInterface $globalConfig, + ManagerInterface $eventManager ) { parent::__construct( $templateContainer, @@ -83,18 +95,18 @@ public function __construct( * Otherwise, email will be sent later during running of * corresponding cron job. * - * @param \Magento\Sales\Api\Data\OrderInterface $order - * @param \Magento\Sales\Api\Data\ShipmentInterface $shipment - * @param \Magento\Sales\Api\Data\ShipmentCommentCreationInterface|null $comment + * @param OrderInterface $order + * @param ShipmentInterface $shipment + * @param ShipmentCommentCreationInterface|null $comment * @param bool $forceSyncMode * * @return bool * @throws \Exception */ public function send( - \Magento\Sales\Api\Data\OrderInterface $order, - \Magento\Sales\Api\Data\ShipmentInterface $shipment, - \Magento\Sales\Api\Data\ShipmentCommentCreationInterface $comment = null, + OrderInterface $order, + ShipmentInterface $shipment, + ShipmentCommentCreationInterface $comment = null, $forceSyncMode = false ) { $shipment->setSendEmail($this->identityContainer->isEnabled()); @@ -112,7 +124,13 @@ public function send( 'payment_html' => $this->getPaymentHtml($order), 'store' => $order->getStore(), 'formattedShippingAddress' => $this->getFormattedShippingAddress($order), - 'formattedBillingAddress' => $this->getFormattedBillingAddress($order) + 'formattedBillingAddress' => $this->getFormattedBillingAddress($order), + 'order_data' => [ + 'customer_name' => $order->getCustomerName(), + 'is_not_virtual' => $order->getIsNotVirtual(), + 'email_customer_note' => $order->getEmailCustomerNote(), + 'frontend_status_label' => $order->getFrontendStatusLabel() + ] ]; $transportObject = new DataObject($transport); @@ -147,12 +165,12 @@ public function send( /** * Returns payment info block as HTML. * - * @param \Magento\Sales\Api\Data\OrderInterface $order + * @param OrderInterface $order * * @return string * @throws \Exception */ - private function getPaymentHtml(\Magento\Sales\Api\Data\OrderInterface $order) + private function getPaymentHtml(OrderInterface $order) { return $this->paymentHelper->getInfoBlockHtml( $order->getPayment(), diff --git a/app/code/Magento/Sales/Model/ResourceModel/EntityAbstract.php b/app/code/Magento/Sales/Model/ResourceModel/EntityAbstract.php index 72ce60d32877c..eccfc8e56e6e5 100644 --- a/app/code/Magento/Sales/Model/ResourceModel/EntityAbstract.php +++ b/app/code/Magento/Sales/Model/ResourceModel/EntityAbstract.php @@ -87,7 +87,7 @@ public function __construct( * Perform actions after object save * * @param \Magento\Framework\Model\AbstractModel $object - * @param string $attribute + * @param AbstractAttribute|string[]|string $attribute * @return $this * @throws \Exception */ diff --git a/app/code/Magento/Sales/Model/ResourceModel/Order/Address/Collection.php b/app/code/Magento/Sales/Model/ResourceModel/Order/Address/Collection.php index f2a28b613cfea..5cc170c8bfa4b 100644 --- a/app/code/Magento/Sales/Model/ResourceModel/Order/Address/Collection.php +++ b/app/code/Magento/Sales/Model/ResourceModel/Order/Address/Collection.php @@ -7,15 +7,8 @@ use Magento\Sales\Api\Data\OrderAddressSearchResultInterface; use Magento\Sales\Model\ResourceModel\Order\Collection\AbstractCollection; -use Magento\Framework\Locale\ResolverInterface; -use Magento\Framework\Data\Collection\EntityFactoryInterface; -use Magento\Framework\Data\Collection\Db\FetchStrategyInterface; -use Magento\Framework\Event\ManagerInterface; -use Magento\Framework\Model\ResourceModel\Db\VersionControl\Snapshot; -use Magento\Framework\DB\Adapter\AdapterInterface; -use Magento\Framework\Model\ResourceModel\Db\AbstractDb; -use Magento\Framework\App\ObjectManager; -use Psr\Log\LoggerInterface; +use Magento\Sales\Model\Order\Address; +use Magento\Sales\Model\ResourceModel\Order\Address as AddressResource; /** * Order addresses collection @@ -36,44 +29,6 @@ class Collection extends AbstractCollection implements OrderAddressSearchResultI */ protected $_eventObject = 'order_address_collection'; - /** - * @var ResolverInterface - */ - private $localeResolver; - - /** - * @param EntityFactoryInterface $entityFactory - * @param LoggerInterface $logger - * @param FetchStrategyInterface $fetchStrategy - * @param ManagerInterface $eventManager - * @param Snapshot $entitySnapshot - * @param AdapterInterface|null $connection - * @param AbstractDb|null $resource - * @param ResolverInterface|null $localeResolver - */ - public function __construct( - EntityFactoryInterface $entityFactory, - LoggerInterface $logger, - FetchStrategyInterface $fetchStrategy, - ManagerInterface $eventManager, - Snapshot $entitySnapshot, - AdapterInterface $connection = null, - AbstractDb $resource = null, - ResolverInterface $localeResolver = null - ) { - $this->localeResolver = $localeResolver ?: ObjectManager::getInstance() - ->get(ResolverInterface::class); - parent::__construct( - $entityFactory, - $logger, - $fetchStrategy, - $eventManager, - $entitySnapshot, - $connection, - $resource - ); - } - /** * Model initialization * @@ -82,21 +37,11 @@ public function __construct( protected function _construct() { $this->_init( - \Magento\Sales\Model\Order\Address::class, - \Magento\Sales\Model\ResourceModel\Order\Address::class + Address::class, + AddressResource::class ); } - /** - * @inheritdoc - */ - protected function _initSelect() - { - parent::_initSelect(); - $this->joinRegions(); - return $this; - } - /** * Redeclare after load method for dispatch event * @@ -110,31 +55,4 @@ protected function _afterLoad() return $this; } - - /** - * Join region name table with current locale - * - * @return $this - */ - private function joinRegions() - { - $locale = $this->localeResolver->getLocale(); - $connection = $this->getConnection(); - - $defaultNameExpr = $connection->getIfNullSql( - $connection->quoteIdentifier('rct.default_name'), - $connection->quoteIdentifier('main_table.region') - ); - $expression = $connection->getIfNullSql($connection->quoteIdentifier('rnt.name'), $defaultNameExpr); - - $regionId = $connection->quoteIdentifier('main_table.region_id'); - $condition = $connection->quoteInto("rnt.locale=?", $locale); - $rctTable = $this->getTable('directory_country_region'); - $rntTable = $this->getTable('directory_country_region_name'); - - $this->getSelect() - ->joinLeft(['rct' => $rctTable], "rct.region_id={$regionId}", []) - ->joinLeft(['rnt' => $rntTable], "rnt.region_id={$regionId} AND {$condition}", ['region' => $expression]); - return $this; - } } diff --git a/app/code/Magento/Sales/Model/ResourceModel/Order/Status.php b/app/code/Magento/Sales/Model/ResourceModel/Order/Status.php index 58284759b2fee..3ff2ed66a846b 100644 --- a/app/code/Magento/Sales/Model/ResourceModel/Order/Status.php +++ b/app/code/Magento/Sales/Model/ResourceModel/Order/Status.php @@ -257,7 +257,7 @@ protected function getStatusByState($state) { return (string)$this->getConnection()->fetchOne( $select = $this->getConnection()->select() - ->from(['sss' => $this->stateTable, []]) + ->from(['sss' => $this->stateTable], []) ->where('state = ?', $state) ->limit(1) ->columns(['status']) diff --git a/app/code/Magento/Sales/Model/ResourceModel/Provider/NotSyncedDataProvider.php b/app/code/Magento/Sales/Model/ResourceModel/Provider/NotSyncedDataProvider.php index 645e411b80b67..10b3ca1bde996 100644 --- a/app/code/Magento/Sales/Model/ResourceModel/Provider/NotSyncedDataProvider.php +++ b/app/code/Magento/Sales/Model/ResourceModel/Provider/NotSyncedDataProvider.php @@ -37,11 +37,11 @@ public function __construct(TMapFactory $tmapFactory, array $providers = []) */ public function getIds($mainTableName, $gridTableName) { - $result = [[]]; + $result = []; foreach ($this->providers as $provider) { $result[] = $provider->getIds($mainTableName, $gridTableName); } - return array_unique(array_merge(...$result)); + return array_unique(array_merge([], ...$result)); } } diff --git a/app/code/Magento/Sales/Model/ShipOrder.php b/app/code/Magento/Sales/Model/ShipOrder.php index 26fe5a8e4b457..3bb8527d6e516 100644 --- a/app/code/Magento/Sales/Model/ShipOrder.php +++ b/app/code/Magento/Sales/Model/ShipOrder.php @@ -5,25 +5,15 @@ */ namespace Magento\Sales\Model; -use DomainException; use Magento\Framework\App\ResourceConnection; -use Magento\Sales\Api\Data\ShipmentCommentCreationInterface; -use Magento\Sales\Api\Data\ShipmentCreationArgumentsInterface; -use Magento\Sales\Api\Data\ShipmentItemCreationInterface; -use Magento\Sales\Api\Data\ShipmentPackageCreationInterface; -use Magento\Sales\Api\Data\ShipmentTrackCreationInterface; -use Magento\Sales\Api\Exception\CouldNotShipExceptionInterface; -use Magento\Sales\Api\Exception\DocumentValidationExceptionInterface; use Magento\Sales\Api\OrderRepositoryInterface; use Magento\Sales\Api\ShipmentRepositoryInterface; use Magento\Sales\Api\ShipOrderInterface; -use Magento\Sales\Exception\CouldNotShipException; -use Magento\Sales\Exception\DocumentValidationException; use Magento\Sales\Model\Order\Config as OrderConfig; use Magento\Sales\Model\Order\OrderStateResolverInterface; +use Magento\Sales\Model\Order\ShipmentDocumentFactory; use Magento\Sales\Model\Order\Shipment\NotifierInterface; use Magento\Sales\Model\Order\Shipment\OrderRegistrarInterface; -use Magento\Sales\Model\Order\ShipmentDocumentFactory; use Magento\Sales\Model\Order\Validation\ShipOrderInterface as ShipOrderValidator; use Psr\Log\LoggerInterface; @@ -126,27 +116,29 @@ public function __construct( * Process the shipment and save shipment and order data * * @param int $orderId - * @param ShipmentItemCreationInterface[] $items + * @param \Magento\Sales\Api\Data\ShipmentItemCreationInterface[] $items * @param bool $notify * @param bool $appendComment - * @param ShipmentCommentCreationInterface|null $comment - * @param ShipmentTrackCreationInterface[] $tracks - * @param ShipmentPackageCreationInterface[] $packages - * @param ShipmentCreationArgumentsInterface|null $arguments + * @param \Magento\Sales\Api\Data\ShipmentCommentCreationInterface|null $comment + * @param \Magento\Sales\Api\Data\ShipmentTrackCreationInterface[] $tracks + * @param \Magento\Sales\Api\Data\ShipmentPackageCreationInterface[] $packages + * @param \Magento\Sales\Api\Data\ShipmentCreationArgumentsInterface|null $arguments * @return int - * @throws DocumentValidationExceptionInterface - * @throws CouldNotShipExceptionInterface - * @throws DomainException + * @throws \Magento\Sales\Api\Exception\DocumentValidationExceptionInterface + * @throws \Magento\Sales\Api\Exception\CouldNotShipExceptionInterface + * @throws \Magento\Framework\Exception\InputException + * @throws \Magento\Framework\Exception\NoSuchEntityException + * @throws \DomainException */ public function execute( $orderId, array $items = [], $notify = false, $appendComment = false, - ShipmentCommentCreationInterface $comment = null, + \Magento\Sales\Api\Data\ShipmentCommentCreationInterface $comment = null, array $tracks = [], array $packages = [], - ShipmentCreationArgumentsInterface $arguments = null + \Magento\Sales\Api\Data\ShipmentCreationArgumentsInterface $arguments = null ) { $connection = $this->resourceConnection->getConnection('sales'); $order = $this->orderRepository->get($orderId); @@ -170,30 +162,30 @@ public function execute( $packages ); if ($validationMessages->hasMessages()) { - throw new DocumentValidationException( + throw new \Magento\Sales\Exception\DocumentValidationException( __("Shipment Document Validation Error(s):\n" . implode("\n", $validationMessages->getMessages())) ); } $connection->beginTransaction(); try { $this->orderRegistrar->register($order, $shipment); - $order->setState( - $this->orderStateResolver->getStateForOrder($order, [OrderStateResolverInterface::IN_PROGRESS]) - ); - $order->setStatus($this->config->getStateDefaultStatus($order->getState())); - $shippingData = $this->shipmentRepository->save($shipment); + $shipment = $this->shipmentRepository->save($shipment); + if ($order->getState() === Order::STATE_NEW) { + $order->setState( + $this->orderStateResolver->getStateForOrder($order, [OrderStateResolverInterface::IN_PROGRESS]) + ); + $order->setStatus($this->config->getStateDefaultStatus($order->getState())); + } $this->orderRepository->save($order); $connection->commit(); } catch (\Exception $e) { $this->logger->critical($e); $connection->rollBack(); - throw new CouldNotShipException( + throw new \Magento\Sales\Exception\CouldNotShipException( __('Could not save a shipment, see error log for details') ); } - if ($shipment && empty($shipment->getEntityId())) { - $shipment->setEntityId($shippingData->getEntityId()); - } + if ($notify) { if (!$appendComment) { $comment = null; diff --git a/app/code/Magento/Sales/Plugin/Model/Service/Invoice/AddTransactionCommentAfterCapture.php b/app/code/Magento/Sales/Plugin/Model/Service/Invoice/AddTransactionCommentAfterCapture.php new file mode 100644 index 0000000000000..256d097b9eef0 --- /dev/null +++ b/app/code/Magento/Sales/Plugin/Model/Service/Invoice/AddTransactionCommentAfterCapture.php @@ -0,0 +1,63 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Sales\Plugin\Model\Service\Invoice; + +use Magento\Framework\DB\TransactionFactory; +use Magento\Sales\Api\InvoiceRepositoryInterface; +use Magento\Sales\Model\Service\InvoiceService; + +/** + * Plugin to add transaction comment after capture invoice + */ +class AddTransactionCommentAfterCapture +{ + /** + * @var InvoiceRepositoryInterface + */ + private $invoiceRepository; + + /** + * @var TransactionFactory + */ + private $transactionFactory; + + /** + * @param InvoiceRepositoryInterface $invoiceRepository + * @param TransactionFactory $transactionFactory + */ + public function __construct( + InvoiceRepositoryInterface $invoiceRepository, + TransactionFactory $transactionFactory + ) { + $this->transactionFactory = $transactionFactory; + $this->invoiceRepository = $invoiceRepository; + } + + /** + * Add transaction comment to the order after capture invoice + * + * @param InvoiceService $subject + * @param bool $result + * @param int $invoiceId + * @return bool + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterSetCapture(InvoiceService $subject, bool $result, $invoiceId): bool + { + if ($result) { + $invoice = $this->invoiceRepository->get($invoiceId); + $invoice->getOrder()->setIsInProcess(true); + $this->transactionFactory->create() + ->addObject($invoice) + ->addObject($invoice->getOrder()) + ->save(); + } + + return $result; + } +} diff --git a/app/code/Magento/Sales/Plugin/ProcessOrderAndShipmentViaAPI.php b/app/code/Magento/Sales/Plugin/ProcessOrderAndShipmentViaAPI.php deleted file mode 100644 index 2f81de65fad74..0000000000000 --- a/app/code/Magento/Sales/Plugin/ProcessOrderAndShipmentViaAPI.php +++ /dev/null @@ -1,241 +0,0 @@ -<?php -/** - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. - */ -declare(strict_types=1); - -namespace Magento\Sales\Plugin; - -use Exception; -use Magento\Framework\DB\Transaction; -use Magento\Framework\Exception\LocalizedException; -use Magento\Sales\Api\Data\ShipmentInterface; -use Magento\Sales\Model\Order; -use Magento\Sales\Model\Order\Shipment\Item; -use Magento\Sales\Model\Order\ShipmentRepository; -use Magento\Shipping\Controller\Adminhtml\Order\ShipmentLoader; - -/** - * Plugin to update order data before and after saving shipment via API - */ -class ProcessOrderAndShipmentViaAPI -{ - /** - * @var ShipmentLoader - */ - private $shipmentLoader; - - /** - * @var Transaction - */ - private $transaction; - - /** - * Init plugin - * - * @param ShipmentLoader $shipmentLoader - * @param Transaction $transaction - */ - public function __construct( - ShipmentLoader $shipmentLoader, - Transaction $transaction - ) { - $this->shipmentLoader = $shipmentLoader; - $this->transaction = $transaction; - } - - /** - * Process shipping details before saving shipment via API - * - * @param ShipmentRepository $shipmentRepository - * @param ShipmentInterface $shipmentData - * @return array - * @throws LocalizedException - * @SuppressWarnings(PHPMD.UnusedFormalParameter) - * @SuppressWarnings(PHPMD.NPathComplexity) - * @SuppressWarnings(PHPMD.CyclomaticComplexity) - */ - public function beforeSave( - ShipmentRepository $shipmentRepository, - ShipmentInterface $shipmentData - ): array { - $this->shipmentLoader->setOrderId($shipmentData->getOrderId()); - $trackData = !empty($shipmentData->getTracks()) ? - $this->getShipmentTracking($shipmentData) : []; - $this->shipmentLoader->setTracking($trackData); - $shipmentItems = !empty($shipmentData) ? - $this->getShipmentItems($shipmentData) : []; - $orderItems = []; - if (!empty($shipmentData)) { - $order = $shipmentData->getOrder(); - $orderItems = $order ? $this->getOrderItems($order) : []; - } - $data = (!empty($shipmentItems) && !empty($orderItems)) ? - $this->getShippingData($shipmentItems, $orderItems) : []; - $this->shipmentLoader->setShipment($data); - $shipment = $this->shipmentLoader->load(); - $shipment = empty($shipment) ? $shipmentData - : $this->processShippingDetails($shipmentData, $shipment); - return [$shipment]; - } - - /** - * Save order data after saving shipment via API - * - * @param ShipmentRepository $shipmentRepository - * @param ShipmentInterface $shipment - * @return ShipmentInterface - * @throws Exception - */ - public function afterSave( - ShipmentRepository $shipmentRepository, - ShipmentInterface $shipment - ): ShipmentInterface { - $shipmentDetails = $shipmentRepository->get($shipment->getEntityId()); - $order = $shipmentDetails->getOrder(); - $shipmentItems = !empty($shipment) ? - $this->getShipmentItems($shipment) : []; - $this->processOrderItems($order, $shipmentItems); - $order->setIsInProcess(true); - $this->transaction - ->addObject($order) - ->save(); - return $shipment; - } - - /** - * Process shipment items - * - * @param ShipmentInterface $shipment - * @return array - * @throws LocalizedException - */ - private function getShipmentItems(ShipmentInterface $shipment): array - { - $shipmentItems = []; - foreach ($shipment->getItems() as $item) { - $sku = $item->getSku(); - if (isset($sku)) { - $shipmentItems[$sku]['qty'] = $item->getQty(); - } - } - return $shipmentItems; - } - - /** - * Get shipment tracking data from the shipment array - * - * @param ShipmentInterface $shipment - * @return array - */ - private function getShipmentTracking(ShipmentInterface $shipment): array - { - $trackData = []; - foreach ($shipment->getTracks() as $key => $track) { - $trackData[$key]['number'] = $track->getTrackNumber(); - $trackData[$key]['title'] = $track->getTitle(); - $trackData[$key]['carrier_code'] = $track->getCarrierCode(); - } - return $trackData; - } - - /** - * Get orderItems from shipment order - * - * @param Order $order - * @return array - */ - private function getOrderItems(Order $order): array - { - $orderItems = []; - foreach ($order->getItems() as $item) { - $orderItems[$item->getSku()] = $item->getItemId(); - } - return $orderItems; - } - - /** - * Get available shipping data from shippingItems and orderItems - * - * @param array $shipmentItems - * @param array $orderItems - * @return array - * @throws LocalizedException - */ - private function getShippingData(array $shipmentItems, array $orderItems): array - { - $data = []; - foreach ($shipmentItems as $shippingItemSku => $shipmentItem) { - if (isset($orderItems[$shippingItemSku])) { - $itemId = (int) $orderItems[$shippingItemSku]; - $data['items'][$itemId] = $shipmentItem['qty']; - } - } - return $data; - } - - /** - * Process shipping comments if available - * - * @param ShipmentInterface $shipmentData - * @param ShipmentInterface $shipment - * @return void - */ - private function processShippingComments(ShipmentInterface $shipmentData, ShipmentInterface $shipment): void - { - foreach ($shipmentData->getComments() as $comment) { - $shipment->addComment( - $comment->getComment(), - $comment->getIsCustomerNotified(), - $comment->getIsVisibleOnFront() - ); - $shipment->setCustomerNote($comment->getComment()); - $shipment->setCustomerNoteNotify((bool) $comment->getIsCustomerNotified()); - } - } - - /** - * Process shipping details - * - * @param ShipmentInterface $shipmentData - * @param ShipmentInterface $shipment - * @return ShipmentInterface - */ - private function processShippingDetails( - ShipmentInterface $shipmentData, - ShipmentInterface $shipment - ): ShipmentInterface { - if (empty($shipment->getItems())) { - $shipment->setItems($shipmentData->getItems()); - } - if (!empty($shipmentData->getComments())) { - $this->processShippingComments($shipmentData, $shipment); - } - if ((int) $shipment->getTotalQty() < 1) { - $shipment->setTotalQty($shipmentData->getTotalQty()); - } - return $shipment; - } - - /** - * Process order items data and set the proper item qty - * - * @param Order $order - * @param array $shipmentItems - * @throws LocalizedException - */ - private function processOrderItems(Order $order, array $shipmentItems): void - { - /** @var Item $item */ - foreach ($order->getAllItems() as $item) { - if (isset($shipmentItems[$item->getSku()])) { - $qty = (float)$shipmentItems[$item->getSku()]['qty']; - $item->setQty($qty); - if ((float)$item->getQtyToShip() > 0) { - $item->setQtyShipped((float)$item->getQtyToShip()); - } - } - } - } -} diff --git a/app/code/Magento/Sales/Setup/Patch/Data/WishlistDataCleanUp.php b/app/code/Magento/Sales/Setup/Patch/Data/WishlistDataCleanUp.php new file mode 100644 index 0000000000000..7a10d5cd191bf --- /dev/null +++ b/app/code/Magento/Sales/Setup/Patch/Data/WishlistDataCleanUp.php @@ -0,0 +1,157 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); +namespace Magento\Sales\Setup\Patch\Data; + +use Magento\Framework\DB\Query\Generator; +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\Serialize\Serializer\Json; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Sales\Setup\SalesSetupFactory; +use Psr\Log\LoggerInterface; + +/** + * Class Clean Up Data Removes unused data + */ +class WishlistDataCleanUp implements DataPatchInterface +{ + /** + * Batch size for query + */ + private const BATCH_SIZE = 1000; + + /** + * @var SalesSetupFactory + */ + private $salesSetupFactory; + + /** + * @var Generator + */ + private $queryGenerator; + + /** + * @var Json + */ + private $json; + + /** + * @var LoggerInterface + */ + private $logger; + + /** + * RemoveData constructor. + * @param Json $json + * @param Generator $queryGenerator + * @param SalesSetupFactory $salesSetupFactory + * @param LoggerInterface $logger + */ + public function __construct( + Json $json, + Generator $queryGenerator, + SalesSetupFactory $salesSetupFactory, + LoggerInterface $logger + ) { + $this->json = $json; + $this->queryGenerator = $queryGenerator; + $this->salesSetupFactory = $salesSetupFactory; + $this->logger = $logger; + } + + /** + * @inheritdoc + * + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + */ + public function apply() + { + try { + $this->cleanSalesOrderItemTable(); + } catch (\Throwable $e) { + $this->logger->warning( + 'Sales module WishlistDataCleanUp patch experienced an error and could not be completed.' + . ' Please submit a support ticket or email us at security@magento.com.' + ); + + return $this; + } + + return $this; + } + + /** + * Remove login data from sales_order_item table. + * + * @throws LocalizedException + */ + private function cleanSalesOrderItemTable() + { + $salesSetup = $this->salesSetupFactory->create(); + $tableName = $salesSetup->getTable('sales_order_item'); + $select = $salesSetup + ->getConnection() + ->select() + ->from( + $tableName, + ['item_id', 'product_options'] + ) + ->where( + 'product_options LIKE ?', + '%login%' + ); + $iterator = $this->queryGenerator->generate('item_id', $select, self::BATCH_SIZE); + $rowErrorFlag = false; + foreach ($iterator as $selectByRange) { + $itemRows = $salesSetup->getConnection()->fetchAll($selectByRange); + foreach ($itemRows as $itemRow) { + try { + $rowValue = $this->json->unserialize($itemRow['product_options']); + if (is_array($rowValue) + && array_key_exists('info_buyRequest', $rowValue) + && array_key_exists('login', $rowValue['info_buyRequest']) + ) { + unset($rowValue['info_buyRequest']['login']); + } + $rowValue = $this->json->serialize($rowValue); + $salesSetup->getConnection()->update( + $tableName, + ['product_options' => $rowValue], + ['item_id = ?' => $itemRow['item_id']] + ); + } catch (\Throwable $e) { + $rowErrorFlag = true; + continue; + } + } + } + if ($rowErrorFlag) { + $this->logger->warning( + 'Data clean up could not be completed due to unexpected data format in the table "' + . $tableName + . '". Please submit a support ticket or email us at security@magento.com.' + ); + } + } + + /** + * @inheritdoc + */ + public static function getDependencies() + { + return [ + ConvertSerializedDataToJson::class + ]; + } + + /** + * @inheritdoc + */ + public function getAliases() + { + return []; + } +} diff --git a/app/code/Magento/Sales/Test/Mftf/ActionGroup/AdminAssertOrderShippingMethodActionGroup.xml b/app/code/Magento/Sales/Test/Mftf/ActionGroup/AdminAssertOrderShippingMethodActionGroup.xml new file mode 100644 index 0000000000000..d6d5c9e7315d9 --- /dev/null +++ b/app/code/Magento/Sales/Test/Mftf/ActionGroup/AdminAssertOrderShippingMethodActionGroup.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminAssertOrderShippingMethodActionGroup"> + <annotations> + <description>Assert that shipping method and shipping price is present for the order.</description> + </annotations> + <arguments> + <argument name="shippingMethod" type="string" defaultValue="{{flatRateTitleDefault.value}} - {{flatRateNameDefault.value}}"/> + <argument name="shippingPrice" type="string" defaultValue="$5.00"/> + </arguments> + <see selector="{{AdminOrderShippingInformationSection.shippingMethod}}" userInput="{{shippingMethod}}" stepKey="seeShippingMethod"/> + <see selector="{{AdminOrderShippingInformationSection.shippingPrice}}" userInput="{{shippingPrice}}" stepKey="seeShippingMethodPrice"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Sales/Test/Mftf/ActionGroup/AdminClickInvoiceButtonOrderViewActionGroup.xml b/app/code/Magento/Sales/Test/Mftf/ActionGroup/AdminClickInvoiceButtonOrderViewActionGroup.xml new file mode 100644 index 0000000000000..4617437595c9c --- /dev/null +++ b/app/code/Magento/Sales/Test/Mftf/ActionGroup/AdminClickInvoiceButtonOrderViewActionGroup.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminClickInvoiceButtonOrderViewActionGroup"> + <annotations> + <description>Click 'Invoice' button on the order view page.</description> + </annotations> + + <click selector="{{AdminOrderDetailsMainActionsSection.invoice}}" stepKey="clickInvoiceAction"/> + <waitForPageLoad stepKey="waitForProductPage"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Sales/Test/Mftf/ActionGroup/AdminOrdersGridClearFiltersActionGroup.xml b/app/code/Magento/Sales/Test/Mftf/ActionGroup/AdminOrdersGridClearFiltersActionGroup.xml index b301864212c8b..877f7946d7609 100644 --- a/app/code/Magento/Sales/Test/Mftf/ActionGroup/AdminOrdersGridClearFiltersActionGroup.xml +++ b/app/code/Magento/Sales/Test/Mftf/ActionGroup/AdminOrdersGridClearFiltersActionGroup.xml @@ -16,5 +16,6 @@ <amOnPage url="{{AdminOrdersPage.url}}" stepKey="goToGridOrdersPage"/> <waitForPageLoad stepKey="waitForPageToLoad"/> <conditionalClick selector="{{AdminOrdersGridSection.clearFilters}}" dependentSelector="{{AdminOrdersGridSection.enabledFilters}}" visible="true" stepKey="clickOnButtonToRemoveFiltersIfPresent"/> + <waitForLoadingMaskToDisappear stepKey="waitAfterClearFilters"/> </actionGroup> </actionGroups> diff --git a/app/code/Magento/Sales/Test/Mftf/ActionGroup/AdminRemoveCouponFromOrderActionGroup.xml b/app/code/Magento/Sales/Test/Mftf/ActionGroup/AdminRemoveCouponFromOrderActionGroup.xml new file mode 100644 index 0000000000000..75457401eea5d --- /dev/null +++ b/app/code/Magento/Sales/Test/Mftf/ActionGroup/AdminRemoveCouponFromOrderActionGroup.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminRemoveCouponFromOrderActionGroup"> + <click selector="{{AdminOrderFormItemsSection.removeCoupon}}" stepKey="removeCoupon"/> + <waitForPageLoad stepKey="waitForRemovingCoupon"/> + <dontSee selector="{{AdminOrderDetailsMessagesSection.successMessage}}" userInput="The coupon code has been accepted." stepKey="dontSeePreviousMessage"/> + <see selector="{{AdminOrderDetailsMessagesSection.successMessage}}" userInput="The coupon code has been removed." stepKey="seeSuccessMessage"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Sales/Test/Mftf/ActionGroup/AdminSelectFieldToColumnActionGroup.xml b/app/code/Magento/Sales/Test/Mftf/ActionGroup/AdminSelectFieldToColumnActionGroup.xml new file mode 100644 index 0000000000000..361787948a133 --- /dev/null +++ b/app/code/Magento/Sales/Test/Mftf/ActionGroup/AdminSelectFieldToColumnActionGroup.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminSelectFieldToColumnActionGroup"> + <annotations> + <description>Select or clear the checkbox to display the column on the Orders grid page.</description> + </annotations> + <arguments> + <argument name="column" type="string" defaultValue="Purchase Point"/> + </arguments> + <click selector="{{AdminOrdersGridSection.columnsDropdown}}" stepKey="openColumnsDropdown" /> + <click selector="{{AdminOrdersGridSection.viewColumnCheckbox(column)}}" stepKey="disableColumn"/> + <click selector="{{AdminOrdersGridSection.columnsDropdown}}" stepKey="closeColumnsDropdown" /> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Sales/Test/Mftf/ActionGroup/AdminStartReorderFromOrderPageActionGroup.xml b/app/code/Magento/Sales/Test/Mftf/ActionGroup/AdminStartReorderFromOrderPageActionGroup.xml new file mode 100644 index 0000000000000..28a179faff9ac --- /dev/null +++ b/app/code/Magento/Sales/Test/Mftf/ActionGroup/AdminStartReorderFromOrderPageActionGroup.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminStartReorderFromOrderPageActionGroup"> + <annotations> + <description>Reorder existing order. Requires admin order page to be opened.</description> + </annotations> + + <click selector="{{AdminOrderDetailsMainActionsSection.reorder}}" stepKey="clickReorder"/> + <waitForPageLoad stepKey="waitPageLoad"/> + <waitForElementVisible selector="{{AdminHeaderSection.pageTitle}}" stepKey="waitForPageTitle"/> + <see selector="{{AdminHeaderSection.pageTitle}}" userInput="Create New Order" stepKey="seeCreateNewOrderPageTitle"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Sales/Test/Mftf/ActionGroup/AssertAdminFreePaymentMethodExistsOnCreateOrderPageActionGroup.xml b/app/code/Magento/Sales/Test/Mftf/ActionGroup/AssertAdminFreePaymentMethodExistsOnCreateOrderPageActionGroup.xml new file mode 100644 index 0000000000000..75146e891a02a --- /dev/null +++ b/app/code/Magento/Sales/Test/Mftf/ActionGroup/AssertAdminFreePaymentMethodExistsOnCreateOrderPageActionGroup.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AssertAdminFreePaymentMethodExistsOnCreateOrderPageActionGroup"> + <annotations> + <description>Checks the free payment on the Admin Create Order page.</description> + </annotations> + <click selector="{{AdminOrderFormPaymentSection.linkPaymentOptions}}" stepKey="clickPaymentMethods"/> + <waitForElementVisible selector="{{AdminOrderFormPaymentSection.freePaymentLabel}}" stepKey="waitForPaymentLabelVisible"/> + <see selector="{{AdminOrderFormPaymentSection.freePaymentLabel}}" userInput="No Payment Information Required" stepKey="checkFreePaymentLabel"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Sales/Test/Mftf/ActionGroup/StorefrontClickRefundTabCustomerOrderViewActionGroup.xml b/app/code/Magento/Sales/Test/Mftf/ActionGroup/StorefrontClickRefundTabCustomerOrderViewActionGroup.xml new file mode 100644 index 0000000000000..fda22395f359c --- /dev/null +++ b/app/code/Magento/Sales/Test/Mftf/ActionGroup/StorefrontClickRefundTabCustomerOrderViewActionGroup.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="StorefrontClickRefundTabCustomerOrderViewActionGroup"> + <annotations> + <description>Click "Refund" tab for customer order view.</description> + </annotations> + + <click selector="{{StorefrontCustomerOrderSection.tabRefund}}" stepKey="clickRefundTab"/> + <waitForPageLoad stepKey="waitForPageLoad"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Sales/Test/Mftf/ActionGroup/StorefrontFillOrdersAndReturnsFormTypeZipActionGroup.xml b/app/code/Magento/Sales/Test/Mftf/ActionGroup/StorefrontFillOrdersAndReturnsFormTypeZipActionGroup.xml new file mode 100644 index 0000000000000..ad7f5011af954 --- /dev/null +++ b/app/code/Magento/Sales/Test/Mftf/ActionGroup/StorefrontFillOrdersAndReturnsFormTypeZipActionGroup.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="StorefrontFillOrdersAndReturnsFormTypeZipActionGroup"> + <arguments> + <argument name="orderNumber" type="string"/> + <argument name="customer" type="entity"/> + <argument name="address" type="entity"/> + </arguments> + <fillField selector="{{StorefrontGuestOrderSearchSection.orderId}}" userInput="{{orderNumber}}" stepKey="inputOrderId"/> + <fillField selector="{{StorefrontGuestOrderSearchSection.billingLastName}}" userInput="{{customer.lastname}}" stepKey="inputBillingLastName"/> + <selectOption selector="{{StorefrontGuestOrderSearchSection.findOrderBy}}" userInput="zip" stepKey="selectFindOrderByZip"/> + <fillField selector="{{StorefrontGuestOrderSearchSection.zip}}" userInput="{{address.postcode}}" stepKey="inputZip"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Sales/Test/Mftf/Data/AddressData.xml b/app/code/Magento/Sales/Test/Mftf/Data/AddressData.xml index 920618a70dfb8..f96028405c4e5 100644 --- a/app/code/Magento/Sales/Test/Mftf/Data/AddressData.xml +++ b/app/code/Magento/Sales/Test/Mftf/Data/AddressData.xml @@ -24,6 +24,7 @@ <data key="postcode">78758</data> <data key="email" unique="prefix">joe.buyer@email.com</data> <data key="telephone">512-345-6789</data> + <data key="country">United States</data> </entity> <entity name="BillingAddressTX" type="billing_address"> <data key="firstname">Joe</data> @@ -41,5 +42,6 @@ <data key="postcode">78758</data> <data key="email" unique="prefix">joe.buyer@email.com</data> <data key="telephone">512-345-6789</data> + <data key="country">United States</data> </entity> </entities> diff --git a/app/code/Magento/Sales/Test/Mftf/Section/AdminOrderFormItemsSection.xml b/app/code/Magento/Sales/Test/Mftf/Section/AdminOrderFormItemsSection.xml index a8b499ca7b02a..28573b4a68309 100644 --- a/app/code/Magento/Sales/Test/Mftf/Section/AdminOrderFormItemsSection.xml +++ b/app/code/Magento/Sales/Test/Mftf/Section/AdminOrderFormItemsSection.xml @@ -35,5 +35,6 @@ <element name="configure" type="button" selector=".product-configure-block button.action-default.scalable" timeout="30"/> <element name="couponCode" type="input" selector="#order-coupons input" timeout="30"/> <element name="applyCoupon" type="button" selector="#order-coupons button"/> + <element name="removeCoupon" type="button" selector=".added-coupon-code .action-remove"/> </section> </sections> \ No newline at end of file diff --git a/app/code/Magento/Sales/Test/Mftf/Section/AdminOrderFormPaymentSection.xml b/app/code/Magento/Sales/Test/Mftf/Section/AdminOrderFormPaymentSection.xml index a478d79d8553f..72fe45465c67b 100644 --- a/app/code/Magento/Sales/Test/Mftf/Section/AdminOrderFormPaymentSection.xml +++ b/app/code/Magento/Sales/Test/Mftf/Section/AdminOrderFormPaymentSection.xml @@ -15,7 +15,7 @@ <element name="flatRateOption" type="radio" selector="#s_method_flatrate_flatrate" timeout="30"/> <element name="shippingError" type="text" selector="#order[has_shipping]-error"/> <element name="freeShippingOption" type="radio" selector="#s_method_freeshipping_freeshipping" timeout="30"/> - <element name="linkPaymentOptions" type="button" selector="#order-billing_method_summary>a"/> + <element name="linkPaymentOptions" type="button" selector="#order-billing_method_summary>a" timeout="30"/> <element name="blockPayment" type="text" selector="#order-billing_method"/> <element name="checkMoneyOption" type="radio" selector="#p_method_checkmo" timeout="30"/> <element name="checkBankTransfer" type="radio" selector="#p_method_banktransfer" timeout="30"/> @@ -28,5 +28,6 @@ <element name="cashOnDeliveryOption" type="radio" selector="#p_method_cashondelivery" timeout="30"/> <element name="purchaseOrderOption" type="radio" selector="#p_method_purchaseorder" timeout="30"/> <element name="purchaseOrderNumber" type="input" selector="#po_number"/> + <element name="freePaymentLabel" type="text" selector="#order-billing_method_form label[for='p_method_free']"/> </section> </sections> diff --git a/app/code/Magento/Sales/Test/Mftf/Section/AdminOrdersGridSection.xml b/app/code/Magento/Sales/Test/Mftf/Section/AdminOrdersGridSection.xml index a18ca0c415567..02878e79f3d70 100644 --- a/app/code/Magento/Sales/Test/Mftf/Section/AdminOrdersGridSection.xml +++ b/app/code/Magento/Sales/Test/Mftf/Section/AdminOrdersGridSection.xml @@ -18,6 +18,7 @@ <element name="idFilter" type="input" selector=".admin__data-grid-filters input[name='increment_id']"/> <element name="selectStatus" type="select" selector="select[name='status']" timeout="60"/> <element name="billToNameFilter" type="input" selector=".admin__data-grid-filters input[name='billing_name']"/> + <element name="purchasePoint" type="select" selector=".admin__data-grid-filters select[name='store_id']"/> <element name="enabledFilters" type="block" selector=".admin__data-grid-header .admin__data-grid-filters-current._show"/> <element name="clearFilters" type="button" selector=".admin__data-grid-header [data-action='grid-filter-reset']" timeout="30"/> <element name="applyFilters" type="button" selector="button[data-action='grid-filter-apply']" timeout="30"/> diff --git a/app/code/Magento/Sales/Test/Mftf/Section/SalesOrderPrintSection.xml b/app/code/Magento/Sales/Test/Mftf/Section/SalesOrderPrintSection.xml index 7c1d7319e30ea..5970a645e212c 100644 --- a/app/code/Magento/Sales/Test/Mftf/Section/SalesOrderPrintSection.xml +++ b/app/code/Magento/Sales/Test/Mftf/Section/SalesOrderPrintSection.xml @@ -9,6 +9,6 @@ <sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> <section name="SalesOrderPrintSection"> - <element name="isOrderPrintPage" type="block" selector=".preview-area"/> + <element name="isOrderPrintPage" type="block" selector="print-preview-app"/> </section> </sections> diff --git a/app/code/Magento/Sales/Test/Mftf/Section/StorefrontGuestOrderSearchSection.xml b/app/code/Magento/Sales/Test/Mftf/Section/StorefrontGuestOrderSearchSection.xml index 5e420ee03bf75..efee68f2bd25f 100644 --- a/app/code/Magento/Sales/Test/Mftf/Section/StorefrontGuestOrderSearchSection.xml +++ b/app/code/Magento/Sales/Test/Mftf/Section/StorefrontGuestOrderSearchSection.xml @@ -13,6 +13,7 @@ <element name="billingLastName" type="input" selector="#oar-billing-lastname"/> <element name="findOrderBy" type="select" selector="#quick-search-type-id"/> <element name="email" type="input" selector="#oar_email"/> + <element name="zip" type="input" selector="#oar_zip"/> <element name="continue" type="button" selector="//*/span[contains(text(), 'Continue')]"/> </section> </sections> diff --git a/app/code/Magento/Sales/Test/Mftf/Test/AddSimpleProductToOrderFromShoppingCartTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/AddSimpleProductToOrderFromShoppingCartTest.xml index 701b7ebe4a958..24e6c5eddf7db 100644 --- a/app/code/Magento/Sales/Test/Mftf/Test/AddSimpleProductToOrderFromShoppingCartTest.xml +++ b/app/code/Magento/Sales/Test/Mftf/Test/AddSimpleProductToOrderFromShoppingCartTest.xml @@ -19,23 +19,22 @@ <group value="sales"/> <group value="mtf_migrated"/> </annotations> + <before> <!-- Create customer --> <createData entity="Simple_US_Customer" stepKey="createCustomer"/> - <!-- Create product --> <createData entity="SimpleProduct2" stepKey="createProduct"/> + <magentoCron groups="index" stepKey="reindexInvalidatedIndices"/> </before> + <after> <!-- Admin log out --> <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> - <!-- Customer log out --> <actionGroup ref="StorefrontCustomerLogoutActionGroup" stepKey="customerLogout"/> - <!-- Delete customer --> <deleteData createDataKey="createCustomer" stepKey="deleteCustomer"/> - <!-- Delete product --> <deleteData createDataKey="createProduct" stepKey="deleteProduct"/> </after> diff --git a/app/code/Magento/Sales/Test/Mftf/Test/AdminCreateCreditMemoBankTransferPaymentTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/AdminCreateCreditMemoBankTransferPaymentTest.xml index c4656e394d349..6ed8510db777c 100644 --- a/app/code/Magento/Sales/Test/Mftf/Test/AdminCreateCreditMemoBankTransferPaymentTest.xml +++ b/app/code/Magento/Sales/Test/Mftf/Test/AdminCreateCreditMemoBankTransferPaymentTest.xml @@ -129,13 +129,11 @@ </actionGroup> <!-- Assert refunded Grand Total on frontend --> - <amOnPage url="{{StorefrontCustomerDashboardPage.url}}" stepKey="onAccountPage"/> - <waitForPageLoad stepKey="waitForPage"/> + <actionGroup ref="StorefrontOpenMyAccountPageActionGroup" stepKey="onAccountPage"/> <scrollTo selector="{{StorefrontCustomerResentOrdersSection.blockResentOrders}}" stepKey="scrollToResent"/> <click selector="{{StorefrontCustomerResentOrdersSection.viewOrder({$grabOrderId})}}" stepKey="clickOnOrder"/> <waitForPageLoad stepKey="waitForViewOrder"/> - <click selector="{{StorefrontCustomerOrderSection.tabRefund}}" stepKey="clickRefund"/> - <waitForPageLoad stepKey="waitRefundsLoad"/> + <actionGroup ref="StorefrontClickRefundTabCustomerOrderViewActionGroup" stepKey="clickRefund"/> <scrollTo selector="{{StorefrontCustomerOrderSection.grandTotalRefund}}" stepKey="scrollToGrandTotal"/> <see selector="{{StorefrontCustomerOrderSection.grandTotalRefund}}" userInput="555.00" stepKey="seeGrandTotal"/> </test> diff --git a/app/code/Magento/Sales/Test/Mftf/Test/AdminCreateCreditMemoPartialRefundTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/AdminCreateCreditMemoPartialRefundTest.xml index eb3d4ad991915..68301187d3d31 100644 --- a/app/code/Magento/Sales/Test/Mftf/Test/AdminCreateCreditMemoPartialRefundTest.xml +++ b/app/code/Magento/Sales/Test/Mftf/Test/AdminCreateCreditMemoPartialRefundTest.xml @@ -123,13 +123,11 @@ </actionGroup> <!-- Assert refunded Grand Total on frontend --> - <amOnPage url="{{StorefrontCustomerDashboardPage.url}}" stepKey="onAccountPage"/> - <waitForPageLoad stepKey="waitForPage"/> + <actionGroup ref="StorefrontOpenMyAccountPageActionGroup" stepKey="onAccountPage"/> <scrollTo selector="{{StorefrontCustomerResentOrdersSection.blockResentOrders}}" stepKey="scrollToResent"/> <click selector="{{StorefrontCustomerResentOrdersSection.viewOrder({$grabOrderId})}}" stepKey="clickOnOrder"/> <waitForPageLoad stepKey="waitForViewOrder"/> - <click selector="{{StorefrontCustomerOrderSection.tabRefund}}" stepKey="clickRefund"/> - <waitForPageLoad stepKey="waitRefundsLoad"/> + <actionGroup ref="StorefrontClickRefundTabCustomerOrderViewActionGroup" stepKey="clickRefund"/> <scrollTo selector="{{StorefrontCustomerOrderSection.grandTotalRefund}}" stepKey="scrollToGrandTotal"/> <see selector="{{StorefrontCustomerOrderSection.grandTotalRefund}}" userInput="110.00" stepKey="seeGrandTotal"/> diff --git a/app/code/Magento/Sales/Test/Mftf/Test/AdminCreateCreditMemoWithCashOnDeliveryTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/AdminCreateCreditMemoWithCashOnDeliveryTest.xml index 4383820ba6bee..a1027a9987b1f 100644 --- a/app/code/Magento/Sales/Test/Mftf/Test/AdminCreateCreditMemoWithCashOnDeliveryTest.xml +++ b/app/code/Magento/Sales/Test/Mftf/Test/AdminCreateCreditMemoWithCashOnDeliveryTest.xml @@ -116,13 +116,11 @@ </actionGroup> <!-- Assert refunded Grand Total on frontend --> - <amOnPage url="{{StorefrontCustomerDashboardPage.url}}" stepKey="onAccountPage"/> - <waitForPageLoad stepKey="waitForPage"/> + <actionGroup ref="StorefrontOpenMyAccountPageActionGroup" stepKey="onAccountPage"/> <scrollTo selector="{{StorefrontCustomerResentOrdersSection.blockResentOrders}}" stepKey="scrollToResent"/> <click selector="{{StorefrontCustomerResentOrdersSection.viewOrder({$grabOrderId})}}" stepKey="clickOnOrder"/> <waitForPageLoad stepKey="waitForViewOrder"/> - <click selector="{{StorefrontCustomerOrderSection.tabRefund}}" stepKey="clickRefund"/> - <waitForPageLoad stepKey="waitRefundsLoad"/> + <actionGroup ref="StorefrontClickRefundTabCustomerOrderViewActionGroup" stepKey="clickRefund"/> <scrollTo selector="{{StorefrontCustomerOrderSection.grandTotalRefund}}" stepKey="scrollToGrandTotal"/> <see selector="{{StorefrontCustomerOrderSection.grandTotalRefund}}" userInput="555.00" stepKey="seeGrandTotal"/> </test> diff --git a/app/code/Magento/Sales/Test/Mftf/Test/AdminCreateCreditMemoWithPurchaseOrderTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/AdminCreateCreditMemoWithPurchaseOrderTest.xml index 56399401b205e..141fa2a9e5d06 100644 --- a/app/code/Magento/Sales/Test/Mftf/Test/AdminCreateCreditMemoWithPurchaseOrderTest.xml +++ b/app/code/Magento/Sales/Test/Mftf/Test/AdminCreateCreditMemoWithPurchaseOrderTest.xml @@ -115,13 +115,11 @@ </actionGroup> <!-- Assert refunded Grand Total on frontend --> - <amOnPage url="{{StorefrontCustomerDashboardPage.url}}" stepKey="onAccountPage"/> - <waitForPageLoad stepKey="waitForPage"/> + <actionGroup ref="StorefrontOpenMyAccountPageActionGroup" stepKey="onAccountPage"/> <scrollTo selector="{{StorefrontCustomerResentOrdersSection.blockResentOrders}}" stepKey="scrollToResent"/> <click selector="{{StorefrontCustomerResentOrdersSection.viewOrder({$grabOrderId})}}" stepKey="clickOnOrder"/> <waitForPageLoad stepKey="waitForViewOrder"/> - <click selector="{{StorefrontCustomerOrderSection.tabRefund}}" stepKey="clickRefund"/> - <waitForPageLoad stepKey="waitRefundsLoad"/> + <actionGroup ref="StorefrontClickRefundTabCustomerOrderViewActionGroup" stepKey="clickRefund"/> <scrollTo selector="{{StorefrontCustomerOrderSection.grandTotalRefund}}" stepKey="scrollToGrandTotal"/> <see selector="{{StorefrontCustomerOrderSection.grandTotalRefund}}" userInput="555.00" stepKey="seeGrandTotal"/> </test> diff --git a/app/code/Magento/Sales/Test/Mftf/Test/AdminCreateInvoiceTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/AdminCreateInvoiceTest.xml index 30f8386b3bb91..91a8f95880fbc 100644 --- a/app/code/Magento/Sales/Test/Mftf/Test/AdminCreateInvoiceTest.xml +++ b/app/code/Magento/Sales/Test/Mftf/Test/AdminCreateInvoiceTest.xml @@ -68,8 +68,7 @@ <waitForLoadingMaskToDisappear stepKey="waitForLoadingMask4"/> <actionGroup ref="AdminOrderGridClickFirstRowActionGroup" stepKey="clickOrderRow"/> - <click selector="{{AdminOrderDetailsMainActionsSection.invoice}}" stepKey="clickInvoice"/> - <waitForPageLoad stepKey="waitForNewInvoicePageToLoad"/> + <actionGroup ref="AdminClickInvoiceButtonOrderViewActionGroup" stepKey="clickInvoice"/> <actionGroup ref="AdminInvoiceClickSubmitActionGroup" stepKey="clickSubmitInvoice"/> <see selector="{{AdminOrderDetailsMessagesSection.successMessage}}" userInput="The invoice has been created." stepKey="seeSuccessMessage"/> <click selector="{{AdminOrderDetailsOrderViewSection.invoices}}" stepKey="clickInvoices"/> diff --git a/app/code/Magento/Sales/Test/Mftf/Test/AdminCreateOrderToVerifyApplyAndRemoveCouponCodeTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/AdminCreateOrderToVerifyApplyAndRemoveCouponCodeTest.xml new file mode 100644 index 0000000000000..9aabc17edc610 --- /dev/null +++ b/app/code/Magento/Sales/Test/Mftf/Test/AdminCreateOrderToVerifyApplyAndRemoveCouponCodeTest.xml @@ -0,0 +1,70 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminCreateOrderToVerifyApplyAndRemoveCouponCodeTest"> + <annotations> + <stories value="Create Order with offline payment methods"/> + <title value="Create Order to verify apply and remove coupon code test"/> + <description value="Create Order to verify apply and remove coupon code test"/> + <severity value="AVERAGE"/> + <testCaseId value="MC-38919"/> + <group value="sales"/> + </annotations> + <before> + <actionGroup ref="AdminLoginActionGroup" stepKey="LoginAsAdmin"/> + <createData entity="Simple_US_Customer" stepKey="createCustomer"/> + <createData entity="SimpleProduct2" stepKey="createSimpleProduct"> + <field key="price">10</field> + </createData> + <createData entity="SalesRuleSpecificCouponWithFixedDiscount" stepKey="createCartPriceRule"/> + <createData entity="SimpleSalesRuleCoupon" stepKey="createCouponForCartPriceRule"> + <requiredEntity createDataKey="createCartPriceRule"/> + </createData> + <magentoCLI + command="config:set {{EnablePaymentBankTransferConfigData.path}} {{EnablePaymentBankTransferConfigData.value}}" + stepKey="enableBankTransferPayment"/> + <magentoCLI command="config:set {{EnableFlatRateConfigData.path}} {{EnableFlatRateConfigData.value}}" + stepKey="enableFlatRate"/> + </before> + <after> + <magentoCLI + command="config:set {{DisablePaymentBankTransferConfigData.path}} {{DisablePaymentBankTransferConfigData.value}}" + stepKey="disableBankTransferPayment"/> + <deleteData createDataKey="createCartPriceRule" stepKey="deleteCartPriceRule"/> + <deleteData createDataKey="createCustomer" stepKey="deleteCustomer"/> + <deleteData createDataKey="createSimpleProduct" stepKey="deleteSimpleProduct"/> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> + </after> + <actionGroup ref="NavigateToNewOrderPageExistingCustomerActionGroup" stepKey="goToCreateOrderPage"> + <argument name="customer" value="$$createCustomer$$"/> + </actionGroup> + <actionGroup ref="AddSimpleProductToOrderActionGroup" stepKey="addProductToOrder"> + <argument name="product" value="$$createSimpleProduct$$"/> + </actionGroup> + <actionGroup ref="AdminApplyCouponToOrderActionGroup" stepKey="applyCoupon"> + <argument name="couponCode" value="$$createCouponForCartPriceRule.code$$"/> + </actionGroup> + <actionGroup ref="AdminRemoveCouponFromOrderActionGroup" stepKey="removeCoupon"/> + <actionGroup ref="AdminSelectFlatRateShippingMethodActionGroup" stepKey="selectFlatRateShippingMethod"/> + <waitForElementVisible selector="{{AdminOrderFormPaymentSection.paymentBlock}}" + stepKey="waitForPaymentOptions"/> + <conditionalClick selector="{{AdminOrderFormPaymentSection.bankTransferOption}}" + dependentSelector="{{AdminOrderFormPaymentSection.bankTransferOption}}" visible="true" + stepKey="checkBankTransferOption"/> + <actionGroup ref="AdminSubmitOrderActionGroup" stepKey="submitOrder"/> + <grabTextFrom selector="|Order # (\d+)|" stepKey="getOrderId"/> + <amOnPage url="{{AdminOrdersPage.url}}" stepKey="goToOrdersPage"/> + <waitForPageLoad stepKey="waitForOrdersPageLoad"/> + <actionGroup ref="FilterOrderGridByIdActionGroup" stepKey="filterOrdersGridById"> + <argument name="orderId" value="$getOrderId"/> + </actionGroup> + <click selector="{{AdminDataGridTableSection.firstRow}}" stepKey="clickCreatedOrderInGrid"/> + </test> +</tests> diff --git a/app/code/Magento/Sales/Test/Mftf/Test/AdminMassOrdersCancelCompleteAndClosedTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/AdminMassOrdersCancelCompleteAndClosedTest.xml index 188002f3938a6..b337af3753db3 100644 --- a/app/code/Magento/Sales/Test/Mftf/Test/AdminMassOrdersCancelCompleteAndClosedTest.xml +++ b/app/code/Magento/Sales/Test/Mftf/Test/AdminMassOrdersCancelCompleteAndClosedTest.xml @@ -65,7 +65,6 @@ <!-- Navigate to backend: Go to Sales > Orders --> <actionGroup ref="AdminOrdersPageOpenActionGroup" stepKey="onOrderPage"/> <actionGroup ref="AdminOrdersGridClearFiltersActionGroup" stepKey="clearFilters"/> - <waitForLoadingMaskToDisappear stepKey="waitForLoading"/> <!-- Select Mass Action according to dataset: Cancel --> <actionGroup ref="AdminTwoOrderActionOnGridActionGroup" stepKey="massActionCancel"> diff --git a/app/code/Magento/Sales/Test/Mftf/Test/AdminMassOrdersCancelProcessingAndClosedTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/AdminMassOrdersCancelProcessingAndClosedTest.xml index 055388570479e..6eb4195524224 100644 --- a/app/code/Magento/Sales/Test/Mftf/Test/AdminMassOrdersCancelProcessingAndClosedTest.xml +++ b/app/code/Magento/Sales/Test/Mftf/Test/AdminMassOrdersCancelProcessingAndClosedTest.xml @@ -65,7 +65,6 @@ <!-- Navigate to backend: Go to Sales > Orders --> <actionGroup ref="AdminOrdersPageOpenActionGroup" stepKey="onOrderPage"/> <actionGroup ref="AdminOrdersGridClearFiltersActionGroup" stepKey="clearFilters"/> - <waitForLoadingMaskToDisappear stepKey="waitForLoading"/> <!-- Select Mass Action according to dataset: Cancel --> <actionGroup ref="AdminTwoOrderActionOnGridActionGroup" stepKey="massActionCancel"> diff --git a/app/code/Magento/Sales/Test/Mftf/Test/AdminMassOrdersHoldOnCompleteTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/AdminMassOrdersHoldOnCompleteTest.xml index 9b2ded574b737..41964cbf605da 100644 --- a/app/code/Magento/Sales/Test/Mftf/Test/AdminMassOrdersHoldOnCompleteTest.xml +++ b/app/code/Magento/Sales/Test/Mftf/Test/AdminMassOrdersHoldOnCompleteTest.xml @@ -52,7 +52,6 @@ <!-- Navigate to backend: Go to Sales > Orders --> <actionGroup ref="AdminOrdersPageOpenActionGroup" stepKey="onOrderPage"/> <actionGroup ref="AdminOrdersGridClearFiltersActionGroup" stepKey="clearFilters"/> - <waitForLoadingMaskToDisappear stepKey="waitForLoading"/> <!-- Select Mass Action according to dataset: Hold --> <actionGroup ref="AdminOrderActionOnGridActionGroup" stepKey="actionHold"> diff --git a/app/code/Magento/Sales/Test/Mftf/Test/AdminMassOrdersHoldOnPendingAndProcessingTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/AdminMassOrdersHoldOnPendingAndProcessingTest.xml index 1a89c5656b2f1..2a4ad174abae0 100644 --- a/app/code/Magento/Sales/Test/Mftf/Test/AdminMassOrdersHoldOnPendingAndProcessingTest.xml +++ b/app/code/Magento/Sales/Test/Mftf/Test/AdminMassOrdersHoldOnPendingAndProcessingTest.xml @@ -62,7 +62,6 @@ <!-- Navigate to backend: Go to Sales > Orders --> <actionGroup ref="AdminOrdersPageOpenActionGroup" stepKey="onOrderPage"/> <actionGroup ref="AdminOrdersGridClearFiltersActionGroup" stepKey="clearFilters"/> - <waitForLoadingMaskToDisappear stepKey="waitForLoading"/> <!-- Select Mass Action according to dataset: Hold --> <actionGroup ref="AdminTwoOrderActionOnGridActionGroup" stepKey="massActionHold"> diff --git a/app/code/Magento/Sales/Test/Mftf/Test/AdminMassOrdersReleasePendingOrderTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/AdminMassOrdersReleasePendingOrderTest.xml index 2252c0a813bcf..27ed62fee35e2 100644 --- a/app/code/Magento/Sales/Test/Mftf/Test/AdminMassOrdersReleasePendingOrderTest.xml +++ b/app/code/Magento/Sales/Test/Mftf/Test/AdminMassOrdersReleasePendingOrderTest.xml @@ -49,7 +49,6 @@ <!-- Navigate to backend: Go to Sales > Orders --> <actionGroup ref="AdminOrdersPageOpenActionGroup" stepKey="onOrderPage"/> <actionGroup ref="AdminOrdersGridClearFiltersActionGroup" stepKey="clearFilters"/> - <waitForLoadingMaskToDisappear stepKey="waitForLoading"/> <!-- Select Mass Action according to dataset: Unhold --> <actionGroup ref="AdminOrderActionOnGridActionGroup" stepKey="actionUnhold"> diff --git a/app/code/Magento/Sales/Test/Mftf/Test/AdminMassOrdersUpdateCancelPendingOrderTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/AdminMassOrdersUpdateCancelPendingOrderTest.xml index d4004c519b7df..163da4917b50a 100644 --- a/app/code/Magento/Sales/Test/Mftf/Test/AdminMassOrdersUpdateCancelPendingOrderTest.xml +++ b/app/code/Magento/Sales/Test/Mftf/Test/AdminMassOrdersUpdateCancelPendingOrderTest.xml @@ -48,7 +48,6 @@ <!-- Navigate to backend: Go to Sales > Orders --> <actionGroup ref="AdminOrdersPageOpenActionGroup" stepKey="onOrderPage"/> <actionGroup ref="AdminOrdersGridClearFiltersActionGroup" stepKey="clearFilters"/> - <waitForLoadingMaskToDisappear stepKey="waitForLoading"/> <!-- Select Mass Action according to dataset: Cancel --> <actionGroup ref="AdminOrderActionOnGridActionGroup" stepKey="ActionCancel"> diff --git a/app/code/Magento/Sales/Test/Mftf/Test/AdminOrdersReleaseInUnholdStatusTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/AdminOrdersReleaseInUnholdStatusTest.xml index 28ce9661e259b..d2ded1cc73d2b 100644 --- a/app/code/Magento/Sales/Test/Mftf/Test/AdminOrdersReleaseInUnholdStatusTest.xml +++ b/app/code/Magento/Sales/Test/Mftf/Test/AdminOrdersReleaseInUnholdStatusTest.xml @@ -54,7 +54,6 @@ <!-- Navigate to backend: Go to Sales > Orders --> <actionGroup ref="AdminOrdersPageOpenActionGroup" stepKey="onOrderPage"/> <actionGroup ref="AdminOrdersGridClearFiltersActionGroup" stepKey="clearFilters"/> - <waitForLoadingMaskToDisappear stepKey="waitForLoading"/> <!-- Select Mass Action according to dataset: Unhold --> <actionGroup ref="AdminOrderActionOnGridActionGroup" stepKey="actionUnold"> diff --git a/app/code/Magento/Sales/Test/Mftf/Test/AdminReorderAddressNotSavedInAddressBookTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/AdminReorderAddressNotSavedInAddressBookTest.xml new file mode 100644 index 0000000000000..1c3ab70857151 --- /dev/null +++ b/app/code/Magento/Sales/Test/Mftf/Test/AdminReorderAddressNotSavedInAddressBookTest.xml @@ -0,0 +1,59 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminReorderAddressNotSavedInAddressBookTest"> + <annotations> + <features value="Sales"/> + <stories value="Reorder"/> + <title value="Same shipping address is not repeating multiple times in storefront checkout when Reordered"/> + <description value="Same shipping address is not repeating multiple times in storefront checkout when Reordered"/> + <testCaseId value="MC-38412"/> + <useCaseId value="MC-38113"/> + <severity value="MAJOR"/> + <group value="sales"/> + </annotations> + <before> + <createData entity="ApiCategory" stepKey="category"/> + <createData entity="ApiSimpleProduct" stepKey="product"> + <requiredEntity createDataKey="category"/> + </createData> + <createData entity="Simple_Customer_Without_Address" stepKey="customer"/> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + <actionGroup ref="LoginToStorefrontActionGroup" stepKey="loginToStorefrontAccount"> + <argument name="Customer" value="$customer$"/> + </actionGroup> + </before> + <after> + <deleteData createDataKey="product" stepKey="deleteProduct"/> + <deleteData createDataKey="category" stepKey="deleteCategory"/> + <actionGroup ref="StorefrontCustomerLogoutActionGroup" stepKey="customerLogout"/> + <deleteData createDataKey="customer" stepKey="deleteCustomer"/> + <actionGroup ref="AdminLogoutActionGroup" stepKey="adminLogout"/> + </after> + + <!-- Create order for registered customer --> + <actionGroup ref="AddSimpleProductToCartActionGroup" stepKey="addSimpleProductToOrder"> + <argument name="product" value="$product$"/> + </actionGroup> + <actionGroup ref="GoToCheckoutFromMinicartActionGroup" stepKey="openCheckoutPage"/> + <actionGroup ref="LoggedInUserCheckoutFillingShippingSectionActionGroup" stepKey="fillAddressForm"/> + <actionGroup ref="ClickPlaceOrderActionGroup" stepKey="clickPlaceOrder"/> + <grabTextFrom selector="{{CheckoutSuccessMainSection.orderNumber22}}" stepKey="grabOrderNumber"/> + + <!-- Reorder created order --> + <actionGroup ref="OpenOrderByIdActionGroup" stepKey="openOrderById"> + <argument name="orderId" value="{$grabOrderNumber}"/> + </actionGroup> + <actionGroup ref="AdminStartReorderFromOrderPageActionGroup" stepKey="startReorder"/> + <actionGroup ref="AdminSubmitOrderActionGroup" stepKey="submitOrder"/> + + <!-- Assert no additional addresses saved --> + <actionGroup ref="AssertStorefrontCustomerHasNoOtherAddressesActionGroup" stepKey="assertAddresses"/> + </test> +</tests> diff --git a/app/code/Magento/Sales/Test/Mftf/Test/AdminReorderOrderWithOfflinePaymentMethodTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/AdminReorderOrderWithOfflinePaymentMethodTest.xml new file mode 100644 index 0000000000000..874164fdcdcf0 --- /dev/null +++ b/app/code/Magento/Sales/Test/Mftf/Test/AdminReorderOrderWithOfflinePaymentMethodTest.xml @@ -0,0 +1,74 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminReorderOrderWithOfflinePaymentMethodTest"> + <annotations> + <features value="Sales"/> + <stories value="Reorder"/> + <title value="Reorder Order from Admin for Offline Payment Methods"/> + <description value="Create reorder for order with two products and Check Money payment method"/> + <severity value="MAJOR"/> + <testCaseId value="MC-37495"/> + <group value="sales"/> + </annotations> + <before> + <magentoCLI command="config:set {{enabledCheckMoneyOrder.label}} {{enabledCheckMoneyOrder.value}}" stepKey="enableCheckMoneyOrder"/> + <createData entity="FlatRateShippingMethodDefault" stepKey="setDefaultFlatRateShippingMethod"/> + <createData entity="Simple_Customer_Without_Address" stepKey="createCustomer"/> + <createData entity="SimpleProduct2" stepKey="createFirstSimpleProduct"/> + <createData entity="SimpleProduct2" stepKey="createSecondSimpleProduct"/> + <createData entity="CustomerCart" stepKey="createCartForCustomer"> + <requiredEntity createDataKey="createCustomer"/> + </createData> + <createData entity="CustomerCartItem" stepKey="addFirstProductToCustomerCart"> + <requiredEntity createDataKey="createCartForCustomer"/> + <requiredEntity createDataKey="createFirstSimpleProduct"/> + </createData> + <createData entity="CustomerCartItem" stepKey="addSecondProductToCustomerCart"> + <requiredEntity createDataKey="createCartForCustomer"/> + <requiredEntity createDataKey="createSecondSimpleProduct"/> + </createData> + <createData entity="CustomerAddressInformation" stepKey="addCustomerOrderAddress"> + <requiredEntity createDataKey="createCartForCustomer"/> + </createData> + <updateData createDataKey="createCartForCustomer" entity="CustomerOrderPaymentMethod" stepKey="sendCustomerPaymentInformation"> + <requiredEntity createDataKey="createCartForCustomer"/> + </updateData> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginToAdminPanel"/> + </before> + <after> + <deleteData createDataKey="createCustomer" stepKey="deleteCustomer"/> + <deleteData createDataKey="createFirstSimpleProduct" stepKey="deleteFirstSimpleProduct"/> + <deleteData createDataKey="createSecondSimpleProduct" stepKey="deleteSecondSimpleProduct"/> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logoutFromAdminPanel"/> + </after> + <actionGroup ref="AdminOpenOrderByEntityIdActionGroup" stepKey="openOrderById"> + <argument name="entityId" value="$createCartForCustomer.return$"/> + </actionGroup> + <click selector="{{AdminOrderDetailsMainActionsSection.reorder}}" stepKey="clickReorderButton"/> + <actionGroup ref="AdminOrderClickSubmitOrderActionGroup" stepKey="submitReorder"/> + <actionGroup ref="VerifyCreatedOrderInformationActionGroup" stepKey="verifyCreatedOrderInformation"/> + <actionGroup ref="AssertOrderAddressInformationActionGroup" stepKey="verifyOrderAddressInformation"> + <argument name="customer" value="$createCustomer$"/> + <argument name="shippingAddress" value="ShippingAddressTX"/> + <argument name="billingAddress" value="BillingAddressTX"/> + </actionGroup> + <see selector="{{AdminOrderDetailsInformationSection.paymentInformation}}" userInput="Check / Money order" stepKey="seePaymentMethod"/> + <actionGroup ref="AdminAssertOrderShippingMethodActionGroup" stepKey="assertShippingOrderInformation"> + <argument name="shippingPrice" value="$10.00"/> + </actionGroup> + <actionGroup ref="SeeProductInItemsOrderedActionGroup" stepKey="seeFirstProductInItemsOrdered"> + <argument name="product" value="$createFirstSimpleProduct$"/> + </actionGroup> + <actionGroup ref="SeeProductInItemsOrderedActionGroup" stepKey="seeSecondProductInItemsOrdered"> + <argument name="product" value="$createSecondSimpleProduct$"/> + </actionGroup> + </test> +</tests> diff --git a/app/code/Magento/Sales/Test/Mftf/Test/AdminVerifyFieldToFilterOnOrdersGridTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/AdminVerifyFieldToFilterOnOrdersGridTest.xml new file mode 100644 index 0000000000000..b0c6b3a2fc6ca --- /dev/null +++ b/app/code/Magento/Sales/Test/Mftf/Test/AdminVerifyFieldToFilterOnOrdersGridTest.xml @@ -0,0 +1,38 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminVerifyFieldToFilterOnOrdersGridTest"> + <annotations> + <features value="Sales"/> + <stories value="Github issue: #28385 Resolve issue with filter visibility with column visibility in grid"/> + <title value="Verify field to filter"/> + <description value="Verify not appear fields to filter on Orders grid if it disables in columns dropdown."/> + <severity value="MAJOR"/> + </annotations> + + <before> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin" /> + </before> + + <after> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logout" /> + </after> + + <actionGroup ref="AdminOrdersGridClearFiltersActionGroup" stepKey="goToOrders"/> + <actionGroup ref="AdminSelectFieldToColumnActionGroup" stepKey="unSelectPurchasePoint" /> + <click selector="{{AdminOrdersGridSection.filters}}" stepKey="openColumnsDropdown" /> + <dontSeeElement selector="{{AdminOrdersGridSection.purchasePoint}}" stepKey="dontSeeElement"/> + + <click selector="{{AdminOrdersGridSection.filters}}" stepKey="closeColumnsDropdown" /> + <actionGroup ref="AdminSelectFieldToColumnActionGroup" stepKey="selectPurchasePoint" /> + <click selector="{{AdminOrdersGridSection.filters}}" stepKey="openColumnsDropdown2" /> + <seeElement selector="{{AdminOrdersGridSection.purchasePoint}}" stepKey="seeElement"/> + </test> +</tests> \ No newline at end of file diff --git a/app/code/Magento/Sales/Test/Mftf/Test/AssignCustomOrderStatusNotVisibleOnStorefrontTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/AssignCustomOrderStatusNotVisibleOnStorefrontTest.xml index 885f019b864de..a5d210a9765ad 100644 --- a/app/code/Magento/Sales/Test/Mftf/Test/AssignCustomOrderStatusNotVisibleOnStorefrontTest.xml +++ b/app/code/Magento/Sales/Test/Mftf/Test/AssignCustomOrderStatusNotVisibleOnStorefrontTest.xml @@ -99,8 +99,7 @@ <waitForPageLoad stepKey="waitForCustomerLogin"/> <!-- Open My Orders --> - <amOnPage url="{{StorefrontCustomerDashboardPage.url}}" stepKey="goToCustomerDashboardPage"/> - <waitForPageLoad stepKey="waitForCustomerDashboardPageLoad"/> + <actionGroup ref="StorefrontOpenMyAccountPageActionGroup" stepKey="goToCustomerDashboardPage"/> <actionGroup ref="StorefrontCustomerGoToSidebarMenu" stepKey="goToMyOrdersPage"> <argument name="menu" value="My Orders"/> </actionGroup> diff --git a/app/code/Magento/Sales/Test/Mftf/Test/CreateInvoiceAndCheckInvoiceOrderTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/CreateInvoiceAndCheckInvoiceOrderTest.xml index d0c1b51008684..de6e7ff22b7af 100644 --- a/app/code/Magento/Sales/Test/Mftf/Test/CreateInvoiceAndCheckInvoiceOrderTest.xml +++ b/app/code/Magento/Sales/Test/Mftf/Test/CreateInvoiceAndCheckInvoiceOrderTest.xml @@ -84,7 +84,7 @@ <click selector="{{AdminDataGridTableSection.firstRow}}" stepKey="clickCreatedOrderInGrid"/> <!-- Go to invoice tab and fill data --> - <click selector="{{AdminOrderDetailsMainActionsSection.invoice}}" stepKey="clickInvoiceAction"/> + <actionGroup ref="AdminClickInvoiceButtonOrderViewActionGroup" stepKey="clickInvoiceAction"/> <fillField selector="{{AdminOrderInvoiceViewSection.invoiceQty}}" userInput="1" stepKey="fillInvoiceQuantity"/> <click selector="{{AdminOrderInvoiceViewSection.updateInvoiceBtn}}" stepKey="clickUpdateQtyInvoiceBtn"/> <fillField selector="{{AdminInvoiceTotalSection.invoiceComment}}" userInput="comment" stepKey="writeComment"/> @@ -133,8 +133,7 @@ <waitForPageLoad stepKey="waitForCustomerLogin"/> <!-- Open My Account > My Orders --> - <amOnPage stepKey="goToMyAccountPage" url="{{StorefrontCustomerDashboardPage.url}}"/> - <waitForPageLoad stepKey="waitForPageLoad"/> + <actionGroup ref="StorefrontOpenMyAccountPageActionGroup" stepKey="goToMyAccountPage"/> <actionGroup ref="StorefrontCustomerGoToSidebarMenu" stepKey="goToSidebarMenu"> <argument name="menu" value="My Orders"/> </actionGroup> diff --git a/app/code/Magento/Sales/Test/Mftf/Test/CreateInvoiceWithCashOnDeliveryPaymentMethodTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/CreateInvoiceWithCashOnDeliveryPaymentMethodTest.xml index e5cfd5dc4afa0..57e42e9b190e3 100644 --- a/app/code/Magento/Sales/Test/Mftf/Test/CreateInvoiceWithCashOnDeliveryPaymentMethodTest.xml +++ b/app/code/Magento/Sales/Test/Mftf/Test/CreateInvoiceWithCashOnDeliveryPaymentMethodTest.xml @@ -84,7 +84,7 @@ <click selector="{{AdminDataGridTableSection.firstRow}}" stepKey="clickCreatedOrderInGrid"/> <!-- Go to invoice tab and fill data --> - <click selector="{{AdminOrderDetailsMainActionsSection.invoice}}" stepKey="clickInvoiceAction"/> + <actionGroup ref="AdminClickInvoiceButtonOrderViewActionGroup" stepKey="clickInvoiceAction"/> <fillField selector="{{AdminOrderInvoiceViewSection.invoiceQty}}" userInput="1" stepKey="fillInvoiceQuantity"/> <click selector="{{AdminOrderInvoiceViewSection.updateInvoiceBtn}}" stepKey="clickUpdateQtyInvoiceBtn"/> <fillField selector="{{AdminInvoiceTotalSection.invoiceComment}}" userInput="comment" stepKey="writeComment"/> @@ -100,8 +100,7 @@ <waitForPageLoad stepKey="waitForCustomerLogin"/> <!-- Open My Account > My Orders --> - <amOnPage stepKey="goToMyAccountPage" url="{{StorefrontCustomerDashboardPage.url}}"/> - <waitForPageLoad stepKey="waitForPageLoad"/> + <actionGroup ref="StorefrontOpenMyAccountPageActionGroup" stepKey="goToMyAccountPage"/> <actionGroup ref="StorefrontCustomerGoToSidebarMenu" stepKey="goToSidebarMenu"> <argument name="menu" value="My Orders"/> </actionGroup> diff --git a/app/code/Magento/Sales/Test/Mftf/Test/CreateInvoiceWithShipmentAndCheckInvoicedOrderTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/CreateInvoiceWithShipmentAndCheckInvoicedOrderTest.xml index 12b956be22cfb..10c6be60f5ba1 100644 --- a/app/code/Magento/Sales/Test/Mftf/Test/CreateInvoiceWithShipmentAndCheckInvoicedOrderTest.xml +++ b/app/code/Magento/Sales/Test/Mftf/Test/CreateInvoiceWithShipmentAndCheckInvoicedOrderTest.xml @@ -78,7 +78,7 @@ <click selector="{{AdminDataGridTableSection.firstRow}}" stepKey="clickCreatedOrderInGrid"/> <!-- Go to invoice tab and fill data --> - <click selector="{{AdminOrderDetailsMainActionsSection.invoice}}" stepKey="clickInvoiceAction"/> + <actionGroup ref="AdminClickInvoiceButtonOrderViewActionGroup" stepKey="clickInvoiceAction"/> <click selector="{{AdminInvoicePaymentShippingSection.CreateShipment}}" stepKey="createShipment"/> <fillField selector="{{AdminInvoiceTotalSection.invoiceComment}}" userInput="comment" stepKey="writeComment"/> <actionGroup ref="AdminInvoiceClickSubmitActionGroup" stepKey="clickSubmitInvoice"/> @@ -124,8 +124,7 @@ <waitForPageLoad stepKey="waitForCustomerLogin"/> <!-- Open My Account > My Orders --> - <amOnPage stepKey="goToMyAccountPage" url="{{StorefrontCustomerDashboardPage.url}}"/> - <waitForPageLoad stepKey="waitForPageLoad"/> + <actionGroup ref="StorefrontOpenMyAccountPageActionGroup" stepKey="goToMyAccountPage"/> <actionGroup ref="StorefrontCustomerGoToSidebarMenu" stepKey="goToSidebarMenu"> <argument name="menu" value="My Orders"/> </actionGroup> diff --git a/app/code/Magento/Sales/Test/Mftf/Test/CreateInvoiceWithZeroSubtotalCheckoutTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/CreateInvoiceWithZeroSubtotalCheckoutTest.xml index 780bffd359ba7..cd36547a877ec 100644 --- a/app/code/Magento/Sales/Test/Mftf/Test/CreateInvoiceWithZeroSubtotalCheckoutTest.xml +++ b/app/code/Magento/Sales/Test/Mftf/Test/CreateInvoiceWithZeroSubtotalCheckoutTest.xml @@ -93,7 +93,7 @@ <click selector="{{AdminDataGridTableSection.firstRow}}" stepKey="clickCreatedOrderInGrid"/> <!-- Go to invoice tab and fill data --> - <click selector="{{AdminOrderDetailsMainActionsSection.invoice}}" stepKey="clickInvoiceAction"/> + <actionGroup ref="AdminClickInvoiceButtonOrderViewActionGroup" stepKey="clickInvoiceAction"/> <fillField selector="{{AdminInvoiceTotalSection.invoiceComment}}" userInput="comment" stepKey="writeComment"/> <actionGroup ref="AdminInvoiceClickSubmitActionGroup" stepKey="clickSubmitInvoice"/> @@ -107,8 +107,7 @@ <waitForPageLoad stepKey="waitForCustomerLogin"/> <!-- Open My Account > My Orders --> - <amOnPage stepKey="goToMyAccountPage" url="{{StorefrontCustomerDashboardPage.url}}"/> - <waitForPageLoad stepKey="waitForPageLoad"/> + <actionGroup ref="StorefrontOpenMyAccountPageActionGroup" stepKey="goToMyAccountPage"/> <actionGroup ref="StorefrontCustomerGoToSidebarMenu" stepKey="goToSidebarMenu"> <argument name="menu" value="My Orders"/> </actionGroup> diff --git a/app/code/Magento/Sales/Test/Mftf/Test/CreateOrderFromEditCustomerPageTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/CreateOrderFromEditCustomerPageTest.xml index ca705405809bd..77b119dd583de 100644 --- a/app/code/Magento/Sales/Test/Mftf/Test/CreateOrderFromEditCustomerPageTest.xml +++ b/app/code/Magento/Sales/Test/Mftf/Test/CreateOrderFromEditCustomerPageTest.xml @@ -91,7 +91,7 @@ <deleteData createDataKey="createConfigProduct" stepKey="deleteConfigurableProduct"/> <deleteData createDataKey="createConfigProductAttribute" stepKey="deleteProductAttribute"/> <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> - <amOnPage url="{{AdminCustomerPage.url}}" stepKey="openCustomerIndexPage"/> + <actionGroup ref="AdminOpenCustomersGridActionGroup" stepKey="openCustomerIndexPage"/> <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearCustomerGridFilter"/> <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> diff --git a/app/code/Magento/Sales/Test/Mftf/Test/CreditMemoTotalAfterShippingDiscountTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/CreditMemoTotalAfterShippingDiscountTest.xml index e41e3acbae380..b2bdf8ce5d90b 100644 --- a/app/code/Magento/Sales/Test/Mftf/Test/CreditMemoTotalAfterShippingDiscountTest.xml +++ b/app/code/Magento/Sales/Test/Mftf/Test/CreditMemoTotalAfterShippingDiscountTest.xml @@ -39,8 +39,7 @@ </after> <!-- Create a cart price rule for $10 Fixed amount discount --> - <amOnPage url="{{AdminCartPriceRulesPage.url}}" stepKey="amOnCartPriceList"/> - <waitForPageLoad stepKey="waitForRulesPage"/> + <actionGroup ref="AdminOpenCartPriceRulesPageActionGroup" stepKey="amOnCartPriceList"/> <click selector="{{AdminCartPriceRulesSection.addNewRuleButton}}" stepKey="clickAddNewRule"/> <fillField selector="{{AdminCartPriceRulesFormSection.ruleName}}" userInput="{{ApiSalesRule.name}}" stepKey="fillRuleName"/> <selectOption selector="{{AdminCartPriceRulesFormSection.websites}}" userInput="Main Website" stepKey="selectWebsite"/> @@ -102,8 +101,7 @@ <!-- Create invoice --> <actionGroup ref="AdminOrderGridClickFirstRowActionGroup" stepKey="clickOrderRow"/> - <click selector="{{AdminOrderDetailsMainActionsSection.invoice}}" stepKey="clickInvoiceButton"/> - <waitForPageLoad stepKey="waitForNewInvoicePageToLoad"/> + <actionGroup ref="AdminClickInvoiceButtonOrderViewActionGroup" stepKey="clickInvoiceButton"/> <see selector="{{AdminHeaderSection.pageTitle}}" userInput="New Invoice" stepKey="seeNewInvoiceInPageTitle" after="clickInvoiceButton"/> <!-- Verify Invoice Totals including subTotal Shipping Discount and GrandTotal --> diff --git a/app/code/Magento/Sales/Test/Mftf/Test/MoveConfigurableProductsInComparedOnOrderPageTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/MoveConfigurableProductsInComparedOnOrderPageTest.xml index c3058ca6ede87..bace51cea17d5 100644 --- a/app/code/Magento/Sales/Test/Mftf/Test/MoveConfigurableProductsInComparedOnOrderPageTest.xml +++ b/app/code/Magento/Sales/Test/Mftf/Test/MoveConfigurableProductsInComparedOnOrderPageTest.xml @@ -132,9 +132,7 @@ <!-- Login as admin --> <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> - <!-- Open Customers -> All Customers --> - <amOnPage url="{{AdminCustomerPage.url}}" stepKey="openCustomersGridPage"/> - <waitForPageLoad stepKey="waitForCustomerPageLoad"/> + <actionGroup ref="AdminOpenCustomersGridActionGroup" stepKey="openCustomersGridPage"/> <actionGroup ref="OpenEditCustomerFromAdminActionGroup" stepKey="openEditCustomerPage"> <argument name="customer" value="$$createCustomer$$"/> </actionGroup> diff --git a/app/code/Magento/Sales/Test/Mftf/Test/MoveSimpleProductsInComparedOnOrderPageTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/MoveSimpleProductsInComparedOnOrderPageTest.xml index 176fb05bc74b3..00e401941036e 100644 --- a/app/code/Magento/Sales/Test/Mftf/Test/MoveSimpleProductsInComparedOnOrderPageTest.xml +++ b/app/code/Magento/Sales/Test/Mftf/Test/MoveSimpleProductsInComparedOnOrderPageTest.xml @@ -70,9 +70,7 @@ <!-- Login as admin --> <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> - <!-- Open Customers -> All Customers --> - <amOnPage url="{{AdminCustomerPage.url}}" stepKey="openCustomersGridPage"/> - <waitForPageLoad stepKey="waitForCustomerPageLoad"/> + <actionGroup ref="AdminOpenCustomersGridActionGroup" stepKey="openCustomersGridPage"/> <actionGroup ref="OpenEditCustomerFromAdminActionGroup" stepKey="openEditCustomerPage"> <argument name="customer" value="$$createCustomer$$"/> </actionGroup> diff --git a/app/code/Magento/Sales/Test/Mftf/Test/StorefrontOrderPagerDisplayedTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/StorefrontOrderPagerDisplayedTest.xml index e99ffa95495ff..6b6b0b2ef4a16 100644 --- a/app/code/Magento/Sales/Test/Mftf/Test/StorefrontOrderPagerDisplayedTest.xml +++ b/app/code/Magento/Sales/Test/Mftf/Test/StorefrontOrderPagerDisplayedTest.xml @@ -213,8 +213,7 @@ <grabTextFrom selector="{{CheckoutSuccessMainSection.orderNumber22}}" stepKey="grabOrderNumber"/> <!-- Go to My Account > My Orders page --> - <amOnPage url="{{StorefrontCustomerDashboardPage.url}}" stepKey="onMyAccount"/> - <waitForPageLoad stepKey="waitForAccountPage"/> + <actionGroup ref="StorefrontOpenMyAccountPageActionGroup" stepKey="onMyAccount"/> <click selector="{{StorefrontCustomerSidebarSection.sidebarTab('My Orders')}}" stepKey="clickOnMyOrders"/> <waitForPageLoad stepKey="waitForOrdersLoad"/> diff --git a/app/code/Magento/Sales/Test/Mftf/Test/StorefrontOrderPagerIsAbsentTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/StorefrontOrderPagerIsAbsentTest.xml index cba141e2ab271..9fba25688702d 100644 --- a/app/code/Magento/Sales/Test/Mftf/Test/StorefrontOrderPagerIsAbsentTest.xml +++ b/app/code/Magento/Sales/Test/Mftf/Test/StorefrontOrderPagerIsAbsentTest.xml @@ -199,8 +199,7 @@ <grabTextFrom selector="{{CheckoutSuccessMainSection.orderNumber22}}" stepKey="grabOrderNumber"/> <!-- Go to My Account > My Orders page --> - <amOnPage url="{{StorefrontCustomerDashboardPage.url}}" stepKey="onMyAccount"/> - <waitForPageLoad stepKey="waitForAccountPage"/> + <actionGroup ref="StorefrontOpenMyAccountPageActionGroup" stepKey="onMyAccount"/> <click selector="{{StorefrontCustomerSidebarSection.sidebarTab('My Orders')}}" stepKey="clickOnMyOrders"/> <waitForPageLoad stepKey="waitForOrdersLoad"/> diff --git a/app/code/Magento/Sales/Test/Mftf/Test/StorefrontPrintOrderFindByZipGuestTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/StorefrontPrintOrderFindByZipGuestTest.xml new file mode 100644 index 0000000000000..c99a02750d6ce --- /dev/null +++ b/app/code/Magento/Sales/Test/Mftf/Test/StorefrontPrintOrderFindByZipGuestTest.xml @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="StorefrontPrintOrderFindByZipGuestTest" extends="StorefrontPrintOrderGuestTest"> + <annotations> + <stories value="Print Order"/> + <title value="Print Order from Guest on Frontend using Zip for search"/> + <description value="Print Order from Guest on Frontend"/> + <severity value="MINOR"/> + <testCaseId value="MC-37449"/> + <group value="sales"/> + </annotations> + + <remove keyForRemoval="fillOrder"/> + + <!-- Fill the form with correspondent Order data using search by Zip --> + <actionGroup ref="StorefrontFillOrdersAndReturnsFormTypeZipActionGroup" stepKey="fillOrderZip" before="clickContinue"> + <argument name="orderNumber" value="{$getOrderId}"/> + <argument name="customer" value="$$createCustomer$$"/> + <argument name="address" value="US_Address_TX"/> + </actionGroup> + </test> +</tests> diff --git a/app/code/Magento/Sales/Test/Mftf/Test/StorefrontPrintOrderGuestTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/StorefrontPrintOrderGuestTest.xml index 9fdf577abd873..807437510d045 100644 --- a/app/code/Magento/Sales/Test/Mftf/Test/StorefrontPrintOrderGuestTest.xml +++ b/app/code/Magento/Sales/Test/Mftf/Test/StorefrontPrintOrderGuestTest.xml @@ -17,6 +17,9 @@ <testCaseId value="MC-16225"/> <group value="sales"/> <group value="mtf_migrated"/> + <skip> + <issueId value="MQE-2288" /> + </skip> </annotations> <before> <magentoCLI command="downloadable:domains:add" arguments="example.com static.magento.com" stepKey="addDownloadableDomain"/> @@ -262,8 +265,11 @@ <!-- Click on the "Print Order" button --> <click selector="{{StorefrontGuestOrderViewSection.printOrder}}" stepKey="printOrder"/> + <wait time="5" stepKey="waitForPrintWindowToOpen" /> <switchToWindow stepKey="switchToWindow"/> + <wait time="5" stepKey="waitForPrintTabToOpen" /> <switchToNextTab stepKey="switchToTab"/> + <wait stepKey="waitForPrintPreviewToLoad" time="5" /> <seeInCurrentUrl url="sales/guest/print/order_id/" stepKey="seePrintPage"/> <!-- AssertSalesPrintOrderProducts --> diff --git a/app/code/Magento/Sales/Test/Mftf/Test/StorefrontRedirectToOrderHistoryTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/StorefrontRedirectToOrderHistoryTest.xml index ceb8c5f9b1aa2..edc92bd2fac03 100644 --- a/app/code/Magento/Sales/Test/Mftf/Test/StorefrontRedirectToOrderHistoryTest.xml +++ b/app/code/Magento/Sales/Test/Mftf/Test/StorefrontRedirectToOrderHistoryTest.xml @@ -18,6 +18,9 @@ <severity value="MAJOR"/> <testCaseId value="MAGETWO-92854"/> <group value="sales"/> + <skip> + <issueId value="MQE-2288" /> + </skip> </annotations> <before> <createData entity="_defaultCategory" stepKey="createCategory"/> @@ -46,15 +49,25 @@ <!--Go to 'print order' page by grabbed order id--> <grabFromCurrentUrl regex="~/order_id/(\d+)/~" stepKey="grabOrderIdFromURL"/> - <switchToNextTab stepKey="switchToPrintPage"/> + <wait time="5" stepKey="waitForPrintWindowToOpen" /> + <switchToWindow stepKey="switchToPrintPage"/> <waitForElement selector="{{SalesOrderPrintSection.isOrderPrintPage}}" stepKey="checkPrintPage"/> <openNewTab stepKey="openNewTab"/> + <wait time="5" stepKey="waitForNewTabToOpen" /> <switchToNextTab stepKey="switchForward"/> + <waitForElement selector="body" stepKey="waitForNewTab3HTML" /> <amOnPage url="{{StorefrontSalesOrderPrintPage.url({$grabOrderIdFromURL})}}" stepKey="duplicatePrintPage"/> + <wait time="5" stepKey="waitForDuplicatePrintWindowToOpen" /> + <switchToWindow stepKey="switchToDuplicatePrintPage"/> + <waitForElement selector="{{SalesOrderPrintSection.isOrderPrintPage}}" stepKey="checkDuplicatePrintPage"/> + <!--Log out as customer 1--> - <switchToNextTab stepKey="switchForward2"/> <openNewTab stepKey="openNewTab2"/> + <wait time="5" stepKey="waitForNewTabToOpen1" /> + <switchToNextTab stepKey="switchForward2"/> + <waitForElement selector="body" stepKey="waitForNewTab2HTML" /> + <amOnPage url="{{StorefrontCustomerSignOutPage.url}}" stepKey="signOut"/> <waitForLoadingMaskToDisappear stepKey="waitSignOutPage"/> @@ -69,10 +82,14 @@ </actionGroup> <!--Try to load 'print order' page with not relevant order id to be redirected to 'order history' page--> - <switchToNextTab stepKey="switchToPrintPage2"/> + <wait time="5" stepKey="waitForPrintWindowToOpen2" /> + <switchToWindow stepKey="switchToPrintPage2"/> <waitForElement selector="{{SalesOrderPrintSection.isOrderPrintPage}}" stepKey="checkPrintPage2"/> + <openNewTab stepKey="openNewTab3"/> + <wait time="5" stepKey="waitForNewTabToOpen2" /> <switchToNextTab stepKey="switchForward4"/> + <waitForElement selector="body" stepKey="waitForNewTabHTML" /> <amOnPage url="{{StorefrontSalesOrderPrintPage.url({$grabOrderIdFromURL})}}" stepKey="duplicatePrintPage2"/> <seeElement selector="{{StorefrontCustomerOrderSection.isMyOrdersSection}}" stepKey="waitOrderHistoryPage"/> </test> diff --git a/app/code/Magento/Sales/Test/Unit/Controller/Adminhtml/Order/ViewTest.php b/app/code/Magento/Sales/Test/Unit/Controller/Adminhtml/Order/ViewTest.php index 69aa43ceca1e0..2ae5be872e6c4 100644 --- a/app/code/Magento/Sales/Test/Unit/Controller/Adminhtml/Order/ViewTest.php +++ b/app/code/Magento/Sales/Test/Unit/Controller/Adminhtml/Order/ViewTest.php @@ -203,7 +203,7 @@ public function testExecute() $id = 111; $titlePart = '#111'; $this->initOrder(); - $this->initOrderSuccess($id); + $this->initOrderSuccess(); $this->prepareRedirect(); $this->initAction(); @@ -264,10 +264,9 @@ public function testExecuteNoOrder() */ public function testGlobalException() { - $id = 111; $exception = new \Exception(); $this->initOrder(); - $this->initOrderSuccess($id); + $this->initOrderSuccess(); $this->prepareRedirect(); $this->resultPageFactoryMock->expects($this->once()) diff --git a/app/code/Magento/Sales/Test/Unit/Helper/DataTest.php b/app/code/Magento/Sales/Test/Unit/Helper/DataTest.php index 9b2f818aeebb3..75ab7ff0378da 100644 --- a/app/code/Magento/Sales/Test/Unit/Helper/DataTest.php +++ b/app/code/Magento/Sales/Test/Unit/Helper/DataTest.php @@ -9,8 +9,6 @@ use Magento\Framework\App\Config\ScopeConfigInterface; use Magento\Framework\App\Helper\Context; -use Magento\Framework\App\State; -use Magento\Framework\Pricing\PriceCurrencyInterface; use Magento\Sales\Helper\Data; use Magento\Sales\Model\Order\Email\Container\CreditmemoCommentIdentity; use Magento\Sales\Model\Order\Email\Container\CreditmemoIdentity; @@ -21,7 +19,7 @@ use Magento\Sales\Model\Order\Email\Container\ShipmentCommentIdentity; use Magento\Sales\Model\Order\Email\Container\ShipmentIdentity; use Magento\Store\Model\ScopeInterface; -use Magento\Store\Model\StoreManagerInterface; +use Magento\Store\Model\Store; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -41,7 +39,7 @@ class DataTest extends TestCase protected $scopeConfigMock; /** - * @var MockObject|\Magento\Sales\Model\Store + * @var MockObject|Store */ protected $storeMock; @@ -60,26 +58,9 @@ protected function setUp(): void ->method('getScopeConfig') ->willReturn($this->scopeConfigMock); - $storeManagerMock = $this->getMockBuilder(StoreManagerInterface::class) - ->disableOriginalConstructor() - ->getMockForAbstractClass(); - - $appStateMock = $this->getMockBuilder(State::class) - ->disableOriginalConstructor() - ->getMock(); - - $pricingCurrencyMock = $this->getMockBuilder(PriceCurrencyInterface::class) - ->disableOriginalConstructor() - ->getMockForAbstractClass(); - - $this->helper = new Data( - $contextMock, - $storeManagerMock, - $appStateMock, - $pricingCurrencyMock - ); + $this->helper = new Data($contextMock); - $this->storeMock = $this->getMockBuilder(\Magento\Sales\Model\Store::class) + $this->storeMock = $this->getMockBuilder(Store::class) ->disableOriginalConstructor() ->getMock(); } diff --git a/app/code/Magento/Sales/Test/Unit/Helper/GuestTest.php b/app/code/Magento/Sales/Test/Unit/Helper/GuestTest.php index 0ee1e4249e27d..07f740f7c1fd8 100644 --- a/app/code/Magento/Sales/Test/Unit/Helper/GuestTest.php +++ b/app/code/Magento/Sales/Test/Unit/Helper/GuestTest.php @@ -112,7 +112,6 @@ protected function setUp(): void ->setMethods(['getTotalCount', 'getItems']) ->disableOriginalConstructor() ->getMockForAbstractClass(); - $this->searchCriteriaBuilder->method('addFilter')->willReturnSelf(); $resultRedirectFactory = $this->getMockBuilder(RedirectFactory::class) ->setMethods(['create']) @@ -148,29 +147,45 @@ protected function setUp(): void ); } - public function testLoadValidOrderNotEmptyPost() + /** + * Test load valid order with non empty post data. + * + * @param array $post + * @dataProvider loadValidOrderNotEmptyPostDataProvider + * @throws \Magento\Framework\Exception\InputException + * @throws \Magento\Framework\Stdlib\Cookie\CookieSizeLimitReachedException + * @throws \Magento\Framework\Stdlib\Cookie\FailureToSendException + */ + public function testLoadValidOrderNotEmptyPost($post) { - $post = [ - 'oar_order_id' => 1, - 'oar_type' => 'email', - 'oar_billing_lastname' => 'oar_billing_lastname', - 'oar_email' => 'oar_email', - 'oar_zip' => 'oar_zip', - - ]; $incrementId = $post['oar_order_id']; $protectedCode = 'protectedCode'; $this->sessionMock->expects($this->once())->method('isLoggedIn')->willReturn(false); $requestMock = $this->createMock(Http::class); $requestMock->expects($this->once())->method('getPostValue')->willReturn($post); + + $this->searchCriteriaBuilder + ->expects($this->at(0)) + ->method('addFilter') + ->with('increment_id', trim($incrementId)) + ->willReturnSelf(); + + $this->searchCriteriaBuilder + ->expects($this->at(1)) + ->method('addFilter') + ->with('store_id', $this->storeModelMock->getId()) + ->willReturnSelf(); + $this->salesOrderMock->expects($this->any())->method('getId')->willReturn($incrementId); $billingAddressMock = $this->createPartialMock( Address::class, - ['getLastname', 'getEmail'] + ['getLastname', 'getEmail', 'getPostcode'] ); - $billingAddressMock->expects($this->once())->method('getLastname')->willReturn(($post['oar_billing_lastname'])); - $billingAddressMock->expects($this->once())->method('getEmail')->willReturn(($post['oar_email'])); + $billingAddressMock->expects($this->once())->method('getLastname') + ->willReturn(trim($post['oar_billing_lastname'])); + $billingAddressMock->expects($this->any())->method('getEmail')->willReturn(trim($post['oar_email'])); + $billingAddressMock->expects($this->any())->method('getPostcode')->willReturn(trim($post['oar_zip'])); $this->salesOrderMock->expects($this->once())->method('getBillingAddress')->willReturn($billingAddressMock); $this->salesOrderMock->expects($this->once())->method('getProtectCode')->willReturn($protectedCode); $metaDataMock = $this->createMock(PublicCookieMetadata::class); @@ -190,10 +205,49 @@ public function testLoadValidOrderNotEmptyPost() $this->assertTrue($this->guest->loadValidOrder($requestMock)); } + /** + * Load valid order with non empty post data provider. + * + * @return array + */ + public function loadValidOrderNotEmptyPostDataProvider() + { + return [ + [ + [ + 'oar_order_id' => '1', + 'oar_type' => 'email', + 'oar_billing_lastname' => 'White', + 'oar_email' => 'test@magento-test.com', + 'oar_zip' => '', + + ] + ], + [ + [ + 'oar_order_id' => ' 14 ', + 'oar_type' => 'email', + 'oar_billing_lastname' => 'Black ', + 'oar_email' => ' test1@magento-test.com ', + 'oar_zip' => '', + ] + ], + [ + [ + 'oar_order_id' => ' 14 ', + 'oar_type' => 'zip', + 'oar_billing_lastname' => 'Black ', + 'oar_email' => ' test1@magento-test.com ', + 'oar_zip' => '123456 ', + ] + ] + ]; + } + public function testLoadValidOrderStoredCookie() { $protectedCode = 'protectedCode'; - $incrementId = 1; + $incrementId = '1'; $cookieData = $protectedCode . ':' . $incrementId; $cookieDataHash = base64_encode($cookieData); $this->sessionMock->expects($this->once())->method('isLoggedIn')->willReturn(false); @@ -201,6 +255,19 @@ public function testLoadValidOrderStoredCookie() ->method('getCookie') ->with(Guest::COOKIE_NAME) ->willReturn($cookieDataHash); + + $this->searchCriteriaBuilder + ->expects($this->at(0)) + ->method('addFilter') + ->with('increment_id', trim($incrementId)) + ->willReturnSelf(); + + $this->searchCriteriaBuilder + ->expects($this->at(1)) + ->method('addFilter') + ->with('store_id', $this->storeModelMock->getId()) + ->willReturnSelf(); + $this->salesOrderMock->expects($this->any())->method('getId')->willReturn($incrementId); $this->salesOrderMock->expects($this->once())->method('getProtectCode')->willReturn($protectedCode); $metaDataMock = $this->createMock(PublicCookieMetadata::class); diff --git a/app/code/Magento/Sales/Test/Unit/Model/Order/Creditmemo/Sender/EmailSenderTest.php b/app/code/Magento/Sales/Test/Unit/Model/Order/Creditmemo/Sender/EmailSenderTest.php index 99e00a74f1ba3..8bc739e9c68fd 100644 --- a/app/code/Magento/Sales/Test/Unit/Model/Order/Creditmemo/Sender/EmailSenderTest.php +++ b/app/code/Magento/Sales/Test/Unit/Model/Order/Creditmemo/Sender/EmailSenderTest.php @@ -273,6 +273,11 @@ public function testSend($configValue, $forceSyncMode, $isComment, $emailSending ->method('setSendEmail') ->with($emailSendingResult); + $this->orderMock->method('getCustomerName')->willReturn('Customer name'); + $this->orderMock->method('getIsNotVirtual')->willReturn(true); + $this->orderMock->method('getEmailCustomerNote')->willReturn(null); + $this->orderMock->method('getFrontendStatusLabel')->willReturn('Pending'); + if (!$configValue || $forceSyncMode) { $transport = [ 'order' => $this->orderMock, @@ -283,6 +288,12 @@ public function testSend($configValue, $forceSyncMode, $isComment, $emailSending 'store' => $this->storeMock, 'formattedShippingAddress' => 'Formatted address', 'formattedBillingAddress' => 'Formatted address', + 'order_data' => [ + 'customer_name' => 'Customer name', + 'is_not_virtual' => true, + 'email_customer_note' => null, + 'frontend_status_label' => 'Pending', + ], ]; $transport = new DataObject($transport); diff --git a/app/code/Magento/Sales/Test/Unit/Model/Order/Email/Sender/AbstractSenderTest.php b/app/code/Magento/Sales/Test/Unit/Model/Order/Email/Sender/AbstractSenderTest.php index b826e058b679e..be7788783adc7 100644 --- a/app/code/Magento/Sales/Test/Unit/Model/Order/Email/Sender/AbstractSenderTest.php +++ b/app/code/Magento/Sales/Test/Unit/Model/Order/Email/Sender/AbstractSenderTest.php @@ -192,7 +192,9 @@ public function stepIdentityContainerInit($identityMockClassName) { $this->identityContainerMock = $this->getMockBuilder($identityMockClassName) ->disableOriginalConstructor() - ->onlyMethods(['getStore', 'isEnabled', 'getConfigValue', 'getTemplateId', 'getGuestTemplateId']) + ->onlyMethods( + ['getStore', 'isEnabled', 'getConfigValue', 'getTemplateId', 'getGuestTemplateId', 'getCopyMethod'] + ) ->getMock(); $this->identityContainerMock->expects($this->any()) ->method('getStore') diff --git a/app/code/Magento/Sales/Test/Unit/Model/Order/Email/Sender/CreditmemoCommentSenderTest.php b/app/code/Magento/Sales/Test/Unit/Model/Order/Email/Sender/CreditmemoCommentSenderTest.php index 0ed2a379f5b73..d191ae9356236 100644 --- a/app/code/Magento/Sales/Test/Unit/Model/Order/Email/Sender/CreditmemoCommentSenderTest.php +++ b/app/code/Magento/Sales/Test/Unit/Model/Order/Email/Sender/CreditmemoCommentSenderTest.php @@ -97,7 +97,7 @@ public function testSendVirtualOrder() $this->assertFalse($result); } - public function testSendTrueWithCustomerCopy() + public function testSendTrueWithoutCustomerCopy() { $billingAddress = $this->addressMock; $comment = 'comment_test'; @@ -140,7 +140,7 @@ public function testSendTrueWithCustomerCopy() $this->assertTrue($result); } - public function testSendTrueWithoutCustomerCopy() + public function testSendTrueWithCustomerCopy() { $billingAddress = $this->addressMock; $comment = 'comment_test'; @@ -161,6 +161,9 @@ public function testSendTrueWithoutCustomerCopy() $this->identityContainerMock->expects($this->once()) ->method('isEnabled') ->willReturn(true); + $this->identityContainerMock->expects($this->once()) + ->method('getCopyMethod') + ->willReturn('copy'); $this->templateContainerMock->expects($this->once()) ->method('setTemplateVars') ->with( diff --git a/app/code/Magento/Sales/Test/Unit/Model/Order/Email/Sender/InvoiceCommentSenderTest.php b/app/code/Magento/Sales/Test/Unit/Model/Order/Email/Sender/InvoiceCommentSenderTest.php index 68f2bd7b1a628..56d78789d7dda 100644 --- a/app/code/Magento/Sales/Test/Unit/Model/Order/Email/Sender/InvoiceCommentSenderTest.php +++ b/app/code/Magento/Sales/Test/Unit/Model/Order/Email/Sender/InvoiceCommentSenderTest.php @@ -31,8 +31,6 @@ protected function setUp(): void $this->stepMockSetup(); $this->paymentHelper = $this->createPartialMock(Data::class, ['getInfoBlockHtml']); - $this->invoiceResource = $this->createMock(Invoice::class); - $this->stepIdentityContainerInit(InvoiceCommentIdentity::class); $this->addressRenderer->expects($this->any())->method('format')->willReturn(1); @@ -65,7 +63,7 @@ public function testSendFalse() $this->assertFalse($result); } - public function testSendTrueWithCustomerCopy() + public function testSendTrueWithoutCustomerCopy() { $billingAddress = $this->addressMock; $this->stepAddressFormat($billingAddress); @@ -110,7 +108,7 @@ public function testSendTrueWithCustomerCopy() $this->assertTrue($result); } - public function testSendTrueWithoutCustomerCopy() + public function testSendTrueWithCustomerCopy() { $billingAddress = $this->addressMock; $customerName = 'Test Customer'; @@ -132,6 +130,9 @@ public function testSendTrueWithoutCustomerCopy() $this->identityContainerMock->expects($this->once()) ->method('isEnabled') ->willReturn(true); + $this->identityContainerMock->expects($this->once()) + ->method('getCopyMethod') + ->willReturn('copy'); $this->templateContainerMock->expects($this->once()) ->method('setTemplateVars') ->with( diff --git a/app/code/Magento/Sales/Test/Unit/Model/Order/Email/Sender/ShipmentCommentSenderTest.php b/app/code/Magento/Sales/Test/Unit/Model/Order/Email/Sender/ShipmentCommentSenderTest.php index 5421991fae848..91e7ae18d3550 100644 --- a/app/code/Magento/Sales/Test/Unit/Model/Order/Email/Sender/ShipmentCommentSenderTest.php +++ b/app/code/Magento/Sales/Test/Unit/Model/Order/Email/Sender/ShipmentCommentSenderTest.php @@ -58,7 +58,7 @@ public function testSendFalse() $this->assertFalse($result); } - public function testSendTrueWithCustomerCopy() + public function testSendTrueWithoutCustomerCopy() { $billingAddress = $this->addressMock; $comment = 'comment_test'; @@ -101,7 +101,7 @@ public function testSendTrueWithCustomerCopy() $this->assertTrue($result); } - public function testSendTrueWithoutCustomerCopy() + public function testSendTrueWithCustomerCopy() { $billingAddress = $this->addressMock; $comment = 'comment_test'; @@ -116,6 +116,9 @@ public function testSendTrueWithoutCustomerCopy() $this->identityContainerMock->expects($this->once()) ->method('isEnabled') ->willReturn(true); + $this->identityContainerMock->expects($this->once()) + ->method('getCopyMethod') + ->willReturn('copy'); $this->orderMock->expects($this->any()) ->method('getCustomerName') ->willReturn($customerName); diff --git a/app/code/Magento/Sales/Test/Unit/Model/Order/Invoice/Sender/EmailSenderTest.php b/app/code/Magento/Sales/Test/Unit/Model/Order/Invoice/Sender/EmailSenderTest.php index eaf57ad1bfc56..4a909a21e2558 100644 --- a/app/code/Magento/Sales/Test/Unit/Model/Order/Invoice/Sender/EmailSenderTest.php +++ b/app/code/Magento/Sales/Test/Unit/Model/Order/Invoice/Sender/EmailSenderTest.php @@ -272,6 +272,11 @@ public function testSend($configValue, $forceSyncMode, $isComment, $emailSending ->method('setSendEmail') ->with($emailSendingResult); + $this->orderMock->method('getCustomerName')->willReturn('Customer name'); + $this->orderMock->method('getIsNotVirtual')->willReturn(true); + $this->orderMock->method('getEmailCustomerNote')->willReturn(null); + $this->orderMock->method('getFrontendStatusLabel')->willReturn('Pending'); + if (!$configValue || $forceSyncMode) { $transport = [ 'order' => $this->orderMock, @@ -282,6 +287,12 @@ public function testSend($configValue, $forceSyncMode, $isComment, $emailSending 'store' => $this->storeMock, 'formattedShippingAddress' => 'Formatted address', 'formattedBillingAddress' => 'Formatted address', + 'order_data' => [ + 'customer_name' => 'Customer name', + 'is_not_virtual' => true, + 'email_customer_note' => null, + 'frontend_status_label' => 'Pending', + ], ]; $transport = new DataObject($transport); diff --git a/app/code/Magento/Sales/Test/Unit/Model/Order/Shipment/Sender/EmailSenderTest.php b/app/code/Magento/Sales/Test/Unit/Model/Order/Shipment/Sender/EmailSenderTest.php index 81ed71ae7bb67..b170a72d691ea 100644 --- a/app/code/Magento/Sales/Test/Unit/Model/Order/Shipment/Sender/EmailSenderTest.php +++ b/app/code/Magento/Sales/Test/Unit/Model/Order/Shipment/Sender/EmailSenderTest.php @@ -259,20 +259,37 @@ protected function setUp(): void * @param bool $forceSyncMode * @param bool $isComment * @param bool $emailSendingResult + * @param array $orderData * * @dataProvider sendDataProvider * * @return void * * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + * @throws \Exception */ - public function testSend($configValue, $forceSyncMode, $isComment, $emailSendingResult) + public function testSend($configValue, $forceSyncMode, $isComment, $emailSendingResult, $orderData) { $this->globalConfigMock->expects($this->once()) ->method('getValue') ->with('sales_email/general/async_sending') ->willReturn($configValue); + $this->orderMock->expects($this->any()) + ->method('getId') + ->willReturn($orderData['order_id']); + $this->orderMock->expects($this->any()) + ->method('getCustomerName') + ->willReturn($orderData['customer_name']); + $this->orderMock->expects($this->any()) + ->method('getIsNotVirtual') + ->willReturn($orderData['is_not_virtual']); + $this->orderMock->expects($this->any()) + ->method('getEmailCustomerNote') + ->willReturn($orderData['email_customer_note']); + $this->orderMock->expects($this->any()) + ->method('getFrontendStatusLabel') + ->willReturn($orderData['frontend_status_label']); if (!$isComment) { $this->commentMock = null; } @@ -296,6 +313,12 @@ public function testSend($configValue, $forceSyncMode, $isComment, $emailSending 'store' => $this->storeMock, 'formattedShippingAddress' => 'Formatted address', 'formattedBillingAddress' => 'Formatted address', + 'order_data' => [ + 'customer_name' => $orderData['customer_name'], + 'is_not_virtual' => $orderData['is_not_virtual'], + 'email_customer_note' => $orderData['email_customer_note'], + 'frontend_status_label' => $orderData['frontend_status_label'] + ] ]; $transport = new DataObject($transport); @@ -388,15 +411,67 @@ public function testSend($configValue, $forceSyncMode, $isComment, $emailSending /** * @return array + * + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ public function sendDataProvider() { return [ - 'Successful sync sending with comment' => [0, false, true, true], - 'Successful sync sending without comment' => [0, false, false, true], - 'Failed sync sending with comment' => [0, false, true, false], - 'Successful forced sync sending with comment' => [1, true, true, true], - 'Async sending' => [1, false, false, false], + 'Successful sync sending with comment' => [ + 0, false, true, true, + [ + 'order_id' => 1, + 'shipment_id' => 1, + 'customer_name' => 'test customer', + 'is_not_virtual' => true, + 'email_customer_note' => 1, + 'frontend_status_label' => 'email_sent' + ] + ], + 'Successful sync sending without comment' => [ + 0, false, false, true, + [ + 'order_id' => 2, + 'shipment_id' => 2, + 'customer_name' => 'test customer 1', + 'is_not_virtual' => true, + 'email_customer_note' => 1, + 'frontend_status_label' => 'email_sent' + ] + ], + 'Failed sync sending with comment' => [ + 0, false, true, false, + [ + 'order_id' => 3, + 'shipment_id' => 3, + 'customer_name' => 'test customer 2', + 'is_not_virtual' => true, + 'email_customer_note' => 1, + 'frontend_status_label' => 'send_email' + ] + ], + 'Successful forced sync sending with comment' => [ + 1, true, true, true, + [ + 'order_id' => 4, + 'shipment_id' => 4, + 'customer_name' => 'test customer 3', + 'is_not_virtual' => true, + 'email_customer_note' => 1, + 'frontend_status_label' => 'email_sent' + ] + ], + 'Async sending' => [ + 1, false, false, false, + [ + 'order_id' => 5, + 'shipment_id' => 5, + 'customer_name' => 'test customer 4', + 'is_not_virtual' => true, + 'email_customer_note' => 1, + 'frontend_status_label' => 'send_email' + ] + ], ]; } } diff --git a/app/code/Magento/Sales/Test/Unit/Model/ShipOrderTest.php b/app/code/Magento/Sales/Test/Unit/Model/ShipOrderTest.php index 5909ebd76feb1..a31b79fcb0c5c 100644 --- a/app/code/Magento/Sales/Test/Unit/Model/ShipOrderTest.php +++ b/app/code/Magento/Sales/Test/Unit/Model/ShipOrderTest.php @@ -189,7 +189,7 @@ protected function setUp(): void ->getMockForAbstractClass(); $this->shipOrderValidatorMock = $this->getMockBuilder(ShipOrderInterface::class) ->disableOriginalConstructor() - ->getMock(); + ->getMockForAbstractClass(); $this->validationMessagesMock = $this->getMockBuilder(ValidatorResultInterface::class) ->disableOriginalConstructor() ->setMethods(['hasMessages', 'getMessages', 'addMessage']) @@ -270,12 +270,12 @@ public function testExecute($orderId, $items, $notify, $appendComment) ->method('setState') ->with(Order::STATE_PROCESSING) ->willReturnSelf(); - $this->orderMock->expects($this->once()) + $this->orderMock->expects($this->exactly(2)) ->method('getState') - ->willReturn(Order::STATE_PROCESSING); + ->willReturn(Order::STATE_NEW); $this->configMock->expects($this->once()) ->method('getStateDefaultStatus') - ->with(Order::STATE_PROCESSING) + ->with(Order::STATE_NEW) ->willReturn('Processing'); $this->orderMock->expects($this->once()) ->method('setStatus') @@ -294,7 +294,7 @@ public function testExecute($orderId, $items, $notify, $appendComment) ->method('notify') ->with($this->orderMock, $this->shipmentMock, $this->shipmentCommentCreationMock); } - $this->shipmentMock->expects($this->exactly(2)) + $this->shipmentMock->expects($this->exactly(1)) ->method('getEntityId') ->willReturn(2); $this->assertEquals( diff --git a/app/code/Magento/Sales/Test/Unit/Plugin/Model/Service/Invoice/AddTransactionCommentAfterCaptureTest.php b/app/code/Magento/Sales/Test/Unit/Plugin/Model/Service/Invoice/AddTransactionCommentAfterCaptureTest.php new file mode 100644 index 0000000000000..8d1a2f5256370 --- /dev/null +++ b/app/code/Magento/Sales/Test/Unit/Plugin/Model/Service/Invoice/AddTransactionCommentAfterCaptureTest.php @@ -0,0 +1,81 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Sales\Test\Unit\Plugin\Model\Service\Invoice; + +use Magento\Framework\DB\Transaction; +use Magento\Framework\DB\TransactionFactory; +use Magento\Sales\Api\InvoiceRepositoryInterface; +use Magento\Sales\Model\Order; +use Magento\Sales\Model\Order\Invoice; +use Magento\Sales\Model\Service\InvoiceService; +use Magento\Sales\Plugin\Model\Service\Invoice\AddTransactionCommentAfterCapture; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * Test to add transaction comment to the order after capture invoice + */ +class AddTransactionCommentAfterCaptureTest extends TestCase +{ + /** + * @var InvoiceRepositoryInterface|MockObject + */ + private $invoiceRepository; + + /** + * @var TransactionFactory|MockObject + */ + private $transactionFactory; + + /** + * @var AddTransactionCommentAfterCapture + */ + private $plugin; + + /** + * @inheritdoc + */ + protected function setUp(): void + { + $this->invoiceRepository = $this->createMock(InvoiceRepositoryInterface::class); + $this->transactionFactory = $this->createMock(TransactionFactory::class); + + $this->plugin = new AddTransactionCommentAfterCapture( + $this->invoiceRepository, + $this->transactionFactory + ); + } + + /** + * Test to add transaction comment after capture invoice + */ + public function testPlugin(): void + { + $result = true; + $invoiceId = 3; + + $orderMock = $this->createMock(Order::class); + $invoiceMock = $this->createMock(Invoice::class); + $invoiceMock->method('getOrder')->willReturn($orderMock); + $this->invoiceRepository->method('get')->with($invoiceId)->willReturn($invoiceMock); + + $transactionMock = $this->createMock(Transaction::class); + $transactionMock->expects($this->at(0))->method('addObject')->with($invoiceMock)->willReturnSelf(); + $transactionMock->expects($this->at(1))->method('addObject')->with($orderMock)->willReturnSelf(); + $transactionMock->expects($this->once())->method('save'); + $this->transactionFactory->method('create')->willReturn($transactionMock); + + /** @var InvoiceService $invoiceService */ + $invoiceService = $this->createMock(InvoiceService::class); + + $this->assertEquals( + $result, + $this->plugin->afterSetCapture($invoiceService, $result, $invoiceId) + ); + } +} diff --git a/app/code/Magento/Sales/Test/Unit/Ui/Component/Listing/Column/PriceTest.php b/app/code/Magento/Sales/Test/Unit/Ui/Component/Listing/Column/PriceTest.php index e3c1c0cc32a3f..4a9061c3f3c5c 100644 --- a/app/code/Magento/Sales/Test/Unit/Ui/Component/Listing/Column/PriceTest.php +++ b/app/code/Magento/Sales/Test/Unit/Ui/Component/Listing/Column/PriceTest.php @@ -12,6 +12,8 @@ use Magento\Framework\View\Element\UiComponent\ContextInterface; use Magento\Framework\View\Element\UiComponent\Processor; use Magento\Sales\Ui\Component\Listing\Column\Price; +use Magento\Store\Model\Store; +use Magento\Store\Model\StoreManagerInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -27,6 +29,11 @@ class PriceTest extends TestCase */ protected $currencyMock; + /** + * @var StoreManagerInterface|MockObject + */ + private $storeManagerMock; + protected function setUp(): void { $objectManager = new ObjectManager($this); @@ -40,31 +47,45 @@ protected function setUp(): void ->setMethods(['load', 'format']) ->disableOriginalConstructor() ->getMock(); + $this->storeManagerMock = $this->getMockBuilder(StoreManagerInterface::class) + ->disableOriginalConstructor() + ->getMock(); $this->model = $objectManager->getObject( Price::class, - ['currency' => $this->currencyMock, 'context' => $contextMock] + ['currency' => $this->currencyMock, 'context' => $contextMock, 'storeManager' => $this->storeManagerMock] ); } - public function testPrepareDataSource() + /** + * @param $hasCurrency + * @param $dataSource + * @param $currencyCode + * @dataProvider testPrepareDataSourceDataProvider + */ + public function testPrepareDataSource($hasCurrency, $dataSource, $currencyCode) { $itemName = 'itemName'; $oldItemValue = 'oldItemValue'; $newItemValue = 'newItemValue'; - $dataSource = [ - 'data' => [ - 'items' => [ - [ - $itemName => $oldItemValue, - 'base_currency_code' => 'US' - ] - ] - ] - ]; + + $store = $this->getMockBuilder(Store::class) + ->disableOriginalConstructor() + ->getMock(); + $currencyMock = $this->getMockBuilder(Currency::class) + ->disableOriginalConstructor() + ->getMock(); + $currencyMock->expects($hasCurrency ? $this->never() : $this->once()) + ->method('getCurrencyCode') + ->willReturn($currencyCode); + $this->storeManagerMock->expects($hasCurrency ? $this->never() : $this->once()) + ->method('getStore') + ->willReturn($store); + $store->expects($hasCurrency ? $this->never() : $this->once()) + ->method('getBaseCurrency') + ->willReturn($currencyMock); $this->currencyMock->expects($this->once()) ->method('load') - ->with($dataSource['data']['items'][0]['base_currency_code']) ->willReturnSelf(); $this->currencyMock->expects($this->once()) @@ -76,4 +97,31 @@ public function testPrepareDataSource() $dataSource = $this->model->prepareDataSource($dataSource); $this->assertEquals($newItemValue, $dataSource['data']['items'][0][$itemName]); } + + public function testPrepareDataSourceDataProvider() + { + $dataSource1 = [ + 'data' => [ + 'items' => [ + [ + 'itemName' => 'oldItemValue', + 'base_currency_code' => 'US' + ] + ] + ] + ]; + $dataSource2 = [ + 'data' => [ + 'items' => [ + [ + 'itemName' => 'oldItemValue' + ] + ] + ] + ]; + return [ + [true, $dataSource1, 'US'], + [false, $dataSource2, 'SAR'], + ]; + } } diff --git a/app/code/Magento/Sales/Ui/Component/Listing/Column/Price.php b/app/code/Magento/Sales/Ui/Component/Listing/Column/Price.php index 8780ce10375ec..4ffb6f98447c7 100644 --- a/app/code/Magento/Sales/Ui/Component/Listing/Column/Price.php +++ b/app/code/Magento/Sales/Ui/Component/Listing/Column/Price.php @@ -7,14 +7,18 @@ namespace Magento\Sales\Ui\Component\Listing\Column; +use Magento\Framework\App\ObjectManager; use Magento\Framework\View\Element\UiComponent\ContextInterface; use Magento\Framework\View\Element\UiComponentFactory; +use Magento\Store\Model\StoreManagerInterface; use Magento\Ui\Component\Listing\Columns\Column; use Magento\Framework\Pricing\PriceCurrencyInterface; use Magento\Directory\Model\Currency; /** * Class Price + * + * UiComponent class for Price format column */ class Price extends Column { @@ -28,6 +32,11 @@ class Price extends Column */ private $currency; + /** + * @var StoreManagerInterface|null + */ + private $storeManager; + /** * Constructor * @@ -37,6 +46,7 @@ class Price extends Column * @param array $components * @param array $data * @param Currency $currency + * @param StoreManagerInterface $storeManager */ public function __construct( ContextInterface $context, @@ -44,11 +54,14 @@ public function __construct( PriceCurrencyInterface $priceFormatter, array $components = [], array $data = [], - Currency $currency = null + Currency $currency = null, + StoreManagerInterface $storeManager = null ) { $this->priceFormatter = $priceFormatter; - $this->currency = $currency ?: \Magento\Framework\App\ObjectManager::getInstance() - ->create(Currency::class); + $this->currency = $currency ?: ObjectManager::getInstance() + ->get(Currency::class); + $this->storeManager = $storeManager ?: ObjectManager::getInstance() + ->get(StoreManagerInterface::class); parent::__construct($context, $uiComponentFactory, $components, $data); } @@ -63,6 +76,12 @@ public function prepareDataSource(array $dataSource) if (isset($dataSource['data']['items'])) { foreach ($dataSource['data']['items'] as & $item) { $currencyCode = isset($item['base_currency_code']) ? $item['base_currency_code'] : null; + if (!$currencyCode) { + $store = $this->storeManager->getStore( + $this->context->getFilterParam('store_id', \Magento\Store\Model\Store::DEFAULT_STORE_ID) + ); + $currencyCode = $store->getBaseCurrency()->getCurrencyCode(); + } $basePurchaseCurrency = $this->currency->load($currencyCode); $item[$this->getData('name')] = $basePurchaseCurrency ->format($item[$this->getData('name')], [], false); diff --git a/app/code/Magento/Sales/ViewModel/Customer/Address/Billing/AddressDataProvider.php b/app/code/Magento/Sales/ViewModel/Customer/Address/Billing/AddressDataProvider.php new file mode 100644 index 0000000000000..c539e965b9df9 --- /dev/null +++ b/app/code/Magento/Sales/ViewModel/Customer/Address/Billing/AddressDataProvider.php @@ -0,0 +1,43 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Sales\ViewModel\Customer\Address\Billing; + +use Magento\Framework\View\Element\Block\ArgumentInterface; +use Magento\Sales\Model\AdminOrder\Create; + +/** + * Customer billing address data provider + */ +class AddressDataProvider implements ArgumentInterface +{ + /** + * @var Create + */ + private $orderCreate; + + /** + * Customer billing address + * + * @param Create $orderCreate + */ + public function __construct( + Create $orderCreate + ) { + $this->orderCreate = $orderCreate; + } + + /** + * Get save billing address in the address book + * + * @return int + */ + public function getSaveInAddressBook(): int + { + return (int)$this->orderCreate->getBillingAddress()->getSaveInAddressBook(); + } +} diff --git a/app/code/Magento/Sales/etc/db_schema.xml b/app/code/Magento/Sales/etc/db_schema.xml index de062029fb53b..491772e7e65a0 100644 --- a/app/code/Magento/Sales/etc/db_schema.xml +++ b/app/code/Magento/Sales/etc/db_schema.xml @@ -769,7 +769,7 @@ <column xsi:type="varchar" name="order_increment_id" nullable="false" length="32" comment="Order Increment ID"/> <column xsi:type="int" name="order_id" unsigned="true" nullable="false" identity="false" comment="Order ID"/> - <column xsi:type="timestamp" name="order_created_at" on_update="true" nullable="false" + <column xsi:type="timestamp" name="order_created_at" on_update="false" nullable="false" default="CURRENT_TIMESTAMP" comment="Order Increment ID"/> <column xsi:type="varchar" name="customer_name" nullable="false" length="128" comment="Customer Name"/> <column xsi:type="decimal" name="total_qty" scale="4" precision="12" unsigned="false" nullable="true" @@ -825,6 +825,9 @@ <index referenceId="SALES_SHIPMENT_GRID_BILLING_NAME" indexType="btree"> <column name="billing_name"/> </index> + <index referenceId="SALES_SHIPMENT_GRID_ORDER_ID" indexType="btree"> + <column name="order_id"/> + </index> <index referenceId="FTI_086B40C8955F167B8EA76653437879B4" indexType="fulltext"> <column name="increment_id"/> <column name="order_increment_id"/> diff --git a/app/code/Magento/Sales/etc/db_schema_whitelist.json b/app/code/Magento/Sales/etc/db_schema_whitelist.json index 087fe6c9eb5ac..02efd7d5a0050 100644 --- a/app/code/Magento/Sales/etc/db_schema_whitelist.json +++ b/app/code/Magento/Sales/etc/db_schema_whitelist.json @@ -479,6 +479,7 @@ "SALES_SHIPMENT_GRID_ORDER_CREATED_AT": true, "SALES_SHIPMENT_GRID_SHIPPING_NAME": true, "SALES_SHIPMENT_GRID_BILLING_NAME": true, + "SALES_SHIPMENT_GRID_ORDER_ID": true, "FTI_086B40C8955F167B8EA76653437879B4": true }, "constraint": { diff --git a/app/code/Magento/Sales/etc/webapi_rest/di.xml b/app/code/Magento/Sales/etc/webapi_rest/di.xml index 1a8478438b04a..12ad410279a08 100644 --- a/app/code/Magento/Sales/etc/webapi_rest/di.xml +++ b/app/code/Magento/Sales/etc/webapi_rest/di.xml @@ -19,7 +19,7 @@ </argument> </arguments> </type> - <type name="Magento\Sales\Model\Order\ShipmentRepository"> - <plugin name="process_order_and_shipment_via_api" type="Magento\Sales\Plugin\ProcessOrderAndShipmentViaAPI" /> + <type name="Magento\Sales\Model\Service\InvoiceService"> + <plugin name="addTransactionCommentAfterCapture" type="Magento\Sales\Plugin\Model\Service\Invoice\AddTransactionCommentAfterCapture"/> </type> </config> diff --git a/app/code/Magento/Sales/etc/webapi_soap/di.xml b/app/code/Magento/Sales/etc/webapi_soap/di.xml index 1a8478438b04a..12ad410279a08 100644 --- a/app/code/Magento/Sales/etc/webapi_soap/di.xml +++ b/app/code/Magento/Sales/etc/webapi_soap/di.xml @@ -19,7 +19,7 @@ </argument> </arguments> </type> - <type name="Magento\Sales\Model\Order\ShipmentRepository"> - <plugin name="process_order_and_shipment_via_api" type="Magento\Sales\Plugin\ProcessOrderAndShipmentViaAPI" /> + <type name="Magento\Sales\Model\Service\InvoiceService"> + <plugin name="addTransactionCommentAfterCapture" type="Magento\Sales\Plugin\Model\Service\Invoice\AddTransactionCommentAfterCapture"/> </type> </config> diff --git a/app/code/Magento/Sales/i18n/en_US.csv b/app/code/Magento/Sales/i18n/en_US.csv index 97c1706f975da..0e65131b7c4b0 100644 --- a/app/code/Magento/Sales/i18n/en_US.csv +++ b/app/code/Magento/Sales/i18n/en_US.csv @@ -803,3 +803,4 @@ If set YES Email field will be required during Admin order creation for new Cust "Could not save the shipment tracking","Could not save the shipment tracking" "Please enter a coupon code!","Please enter a coupon code!" "Reorder is not available.","Reorder is not available." +"The coupon code has been removed.","The coupon code has been removed." diff --git a/app/code/Magento/Sales/view/adminhtml/layout/sales_order_create_load_block_billing_address.xml b/app/code/Magento/Sales/view/adminhtml/layout/sales_order_create_load_block_billing_address.xml index c52f81d5cb56d..91148d86055fc 100644 --- a/app/code/Magento/Sales/view/adminhtml/layout/sales_order_create_load_block_billing_address.xml +++ b/app/code/Magento/Sales/view/adminhtml/layout/sales_order_create_load_block_billing_address.xml @@ -12,6 +12,7 @@ <arguments> <argument name="customerAddressFormatter" xsi:type="object">Magento\Sales\ViewModel\Customer\AddressFormatter</argument> <argument name="customerAddressCollection" xsi:type="object">Magento\Customer\Model\ResourceModel\Address\Collection</argument> + <argument name="billingAddressDataProvider" xsi:type="object">Magento\Sales\ViewModel\Customer\Address\Billing\AddressDataProvider</argument> </arguments> </block> </referenceContainer> diff --git a/app/code/Magento/Sales/view/adminhtml/templates/order/create/form/address.phtml b/app/code/Magento/Sales/view/adminhtml/templates/order/create/form/address.phtml index dc007e4801b41..69b26d70e684a 100644 --- a/app/code/Magento/Sales/view/adminhtml/templates/order/create/form/address.phtml +++ b/app/code/Magento/Sales/view/adminhtml/templates/order/create/form/address.phtml @@ -24,6 +24,11 @@ endif; */ $customerAddressFormatter = $block->getData('customerAddressFormatter'); +/** + * @var \Magento\Sales\ViewModel\Customer\Address\Billing\AddressDataProvider $billingAddressDataProvider + */ +$billingAddressDataProvider = $block->getData('billingAddressDataProvider'); + /** * @var \Magento\Sales\Block\Adminhtml\Order\Create\Billing\Address| * \Magento\Sales\Block\Adminhtml\Order\Create\Shipping\Address $block @@ -114,7 +119,10 @@ endif; ?> type="checkbox" id="<?= $block->escapeHtmlAttr($block->getForm()->getHtmlIdPrefix()) ?>save_in_address_book" value="1" - <?php if (!$block->getDontSaveInAddressBook()): ?> checked="checked"<?php endif; ?> + <?php if ($billingAddressDataProvider && $billingAddressDataProvider->getSaveInAddressBook() || + $block->getIsShipping() && !$block->getDontSaveInAddressBook() && !$block->getAddressId()): ?> + checked="checked" + <?php endif; ?> class="admin__control-checkbox"/> <label for="<?= $block->escapeHtmlAttr($block->getForm()->getHtmlIdPrefix()) ?>save_in_address_book" class="admin__field-label"><?= $block->escapeHtml(__('Save in address book')) ?></label> diff --git a/app/code/Magento/Sales/view/adminhtml/ui_component/sales_order_creditmemo_grid.xml b/app/code/Magento/Sales/view/adminhtml/ui_component/sales_order_creditmemo_grid.xml index e0b7dae8fdb1a..1fc8d41ce0900 100644 --- a/app/code/Magento/Sales/view/adminhtml/ui_component/sales_order_creditmemo_grid.xml +++ b/app/code/Magento/Sales/view/adminhtml/ui_component/sales_order_creditmemo_grid.xml @@ -42,7 +42,7 @@ <label translate="true">Purchased From</label> <dataScope>store_id</dataScope> <imports> - <link name="visible">componentType = column, index = ${ $.index }:visible</link> + <link name="visible">ns = ${ $.ns }, index = ${ $.index }:visible</link> </imports> </settings> </filterSelect> diff --git a/app/code/Magento/Sales/view/adminhtml/ui_component/sales_order_grid.xml b/app/code/Magento/Sales/view/adminhtml/ui_component/sales_order_grid.xml index f6b1240402477..9105b4be8cda2 100644 --- a/app/code/Magento/Sales/view/adminhtml/ui_component/sales_order_grid.xml +++ b/app/code/Magento/Sales/view/adminhtml/ui_component/sales_order_grid.xml @@ -53,7 +53,7 @@ <label translate="true">Purchase Point</label> <dataScope>store_id</dataScope> <imports> - <link name="visible">ns = ${ $.ns }, componentType = column, index = ${ $.index }:visible</link> + <link name="visible">ns = ${ $.ns }, index = ${ $.index }:visible</link> </imports> </settings> </filterSelect> diff --git a/app/code/Magento/Sales/view/adminhtml/ui_component/sales_order_invoice_grid.xml b/app/code/Magento/Sales/view/adminhtml/ui_component/sales_order_invoice_grid.xml index 1e60e4a806fce..c88bc91a16641 100644 --- a/app/code/Magento/Sales/view/adminhtml/ui_component/sales_order_invoice_grid.xml +++ b/app/code/Magento/Sales/view/adminhtml/ui_component/sales_order_invoice_grid.xml @@ -42,7 +42,7 @@ <label translate="true">Purchased From</label> <dataScope>store_id</dataScope> <imports> - <link name="visible">componentType = column, index = ${ $.index }:visible</link> + <link name="visible">ns = ${ $.ns }, index = ${ $.index }:visible</link> </imports> </settings> </filterSelect> diff --git a/app/code/Magento/Sales/view/adminhtml/ui_component/sales_order_shipment_grid.xml b/app/code/Magento/Sales/view/adminhtml/ui_component/sales_order_shipment_grid.xml index 9e02c31a20635..f6474b5db2fd8 100644 --- a/app/code/Magento/Sales/view/adminhtml/ui_component/sales_order_shipment_grid.xml +++ b/app/code/Magento/Sales/view/adminhtml/ui_component/sales_order_shipment_grid.xml @@ -42,7 +42,7 @@ <label translate="true">Purchased From</label> <dataScope>store_id</dataScope> <imports> - <link name="visible">ns = ${ $.ns }, componentType = column, index = ${ $.index }:visible</link> + <link name="visible">ns = ${ $.ns }, index = ${ $.index }:visible</link> </imports> </settings> </filterSelect> diff --git a/app/code/Magento/Sales/view/adminhtml/ui_component/sales_order_view_creditmemo_grid.xml b/app/code/Magento/Sales/view/adminhtml/ui_component/sales_order_view_creditmemo_grid.xml index cf536c27a0ac3..09be15c5a3cf9 100644 --- a/app/code/Magento/Sales/view/adminhtml/ui_component/sales_order_view_creditmemo_grid.xml +++ b/app/code/Magento/Sales/view/adminhtml/ui_component/sales_order_view_creditmemo_grid.xml @@ -51,7 +51,7 @@ <label translate="true">Purchased From</label> <dataScope>store_id</dataScope> <imports> - <link name="visible">ns = ${ $.ns }, componentType = column, index = ${ $.index }:visible</link> + <link name="visible">ns = ${ $.ns }, index = ${ $.index }:visible</link> </imports> </settings> </filterSelect> diff --git a/app/code/Magento/Sales/view/adminhtml/ui_component/sales_order_view_invoice_grid.xml b/app/code/Magento/Sales/view/adminhtml/ui_component/sales_order_view_invoice_grid.xml index ac1233c5e4961..4b6c8b3518e06 100644 --- a/app/code/Magento/Sales/view/adminhtml/ui_component/sales_order_view_invoice_grid.xml +++ b/app/code/Magento/Sales/view/adminhtml/ui_component/sales_order_view_invoice_grid.xml @@ -51,7 +51,7 @@ <label translate="true">Purchased From</label> <dataScope>store_id</dataScope> <imports> - <link name="visible">ns = ${ $.ns }, componentType = column, index = ${ $.index }:visible</link> + <link name="visible">ns = ${ $.ns }, index = ${ $.index }:visible</link> </imports> </settings> </filterSelect> diff --git a/app/code/Magento/Sales/view/adminhtml/ui_component/sales_order_view_shipment_grid.xml b/app/code/Magento/Sales/view/adminhtml/ui_component/sales_order_view_shipment_grid.xml index 5f8ebde290664..8a11bc63a4318 100644 --- a/app/code/Magento/Sales/view/adminhtml/ui_component/sales_order_view_shipment_grid.xml +++ b/app/code/Magento/Sales/view/adminhtml/ui_component/sales_order_view_shipment_grid.xml @@ -51,7 +51,7 @@ <label translate="true">Purchased From</label> <dataScope>store_id</dataScope> <imports> - <link name="visible">ns = ${ $.ns }, componentType = column, index = ${ $.index }:visible</link> + <link name="visible">ns = ${ $.ns }, index = ${ $.index }:visible</link> </imports> </settings> </filterSelect> diff --git a/app/code/Magento/Sales/view/adminhtml/web/order/create/scripts.js b/app/code/Magento/Sales/view/adminhtml/web/order/create/scripts.js index a329524c58d41..ba93f5f88c387 100644 --- a/app/code/Magento/Sales/view/adminhtml/web/order/create/scripts.js +++ b/app/code/Magento/Sales/view/adminhtml/web/order/create/scripts.js @@ -1202,7 +1202,7 @@ define([ for (var i = 0; i < this.loadingAreas.length; i++) { var id = this.loadingAreas[i]; if ($(this.getAreaId(id))) { - if ('message' != id || response[id]) { + if ((id in response) && id !== 'message' || response[id]) { $(this.getAreaId(id)).update(response[id]); } if ($(this.getAreaId(id)).callback) { @@ -1507,12 +1507,17 @@ define([ if (action === 'change') { var confirmText = message.replace(/%s/, customerGroupOption.text); confirmText = confirmText.replace(/%s/, currentCustomerGroupTitle); - if (confirm(confirmText)) { - $$('#' + groupIdHtmlId + ' option').each(function (o) { - o.selected = o.readAttribute('value') == groupId; - }); - this.accountGroupChange(); - } + confirm({ + content: confirmText, + actions: { + confirm: function() { + $$('#' + groupIdHtmlId + ' option').each(function (o) { + o.selected = o.readAttribute('value') == groupId; + }); + this.accountGroupChange(); + }.bind(this) + } + }) } else if (action === 'inform') { alert({ content: message + '\n' + groupMessage diff --git a/app/code/Magento/Sales/view/frontend/layout/sales_order_printinvoice.xml b/app/code/Magento/Sales/view/frontend/layout/sales_order_printinvoice.xml index 3e33f050cb0e7..0272286696e24 100644 --- a/app/code/Magento/Sales/view/frontend/layout/sales_order_printinvoice.xml +++ b/app/code/Magento/Sales/view/frontend/layout/sales_order_printinvoice.xml @@ -11,6 +11,13 @@ <update handle="print" /> <body> <attribute name="class" value="account"/> + <referenceContainer name="header-wrapper"> + <referenceBlock name="logo"> + <arguments> + <argument name="logo_src" xsi:type="helper" helper="Magento\Sales\Model\Order\Invoice\GetLogoFile::execute"/> + </arguments> + </referenceBlock> + </referenceContainer> <referenceContainer name="page.main.title"> <block class="Magento\Sales\Block\Order\PrintOrder\Invoice" name="order.status" template="Magento_Sales::order/order_status.phtml" /> <block class="Magento\Sales\Block\Order\PrintOrder\Invoice" name="order.date" template="Magento_Sales::order/order_date.phtml" /> diff --git a/app/code/Magento/SalesGraphQl/Model/OrderItem/OptionsProcessor.php b/app/code/Magento/SalesGraphQl/Model/OrderItem/OptionsProcessor.php index 83b7e0cc46d96..2f386a3f05ec7 100644 --- a/app/code/Magento/SalesGraphQl/Model/OrderItem/OptionsProcessor.php +++ b/app/code/Magento/SalesGraphQl/Model/OrderItem/OptionsProcessor.php @@ -49,12 +49,12 @@ private function processOptions(array $options): array if (isset($option['option_type'])) { if (in_array($option['option_type'], ['field', 'area', 'file', 'date', 'date_time', 'time'])) { $selectedOptions[] = [ - 'id' => $option['label'], + 'label' => $option['label'], 'value' => $option['print_value'] ?? $option['value'], ]; } elseif (in_array($option['option_type'], ['drop_down', 'radio', 'checkbox', 'multiple'])) { $enteredOptions[] = [ - 'id' => $option['label'], + 'label' => $option['label'], 'value' => $option['print_value'] ?? $option['value'], ]; } @@ -74,7 +74,7 @@ private function processAttributesInfo(array $attributesInfo): array $selectedOptions = []; foreach ($attributesInfo ?? [] as $option) { $selectedOptions[] = [ - 'id' => $option['label'], + 'label' => $option['label'], 'value' => $option['print_value'] ?? $option['value'], ]; } diff --git a/app/code/Magento/SalesGraphQl/etc/schema.graphqls b/app/code/Magento/SalesGraphQl/etc/schema.graphqls index 8b9d58e48d4b1..3544acd1564d0 100644 --- a/app/code/Magento/SalesGraphQl/etc/schema.graphqls +++ b/app/code/Magento/SalesGraphQl/etc/schema.graphqls @@ -48,12 +48,12 @@ type CustomerOrder @doc(description: "Contains details about each of the custome invoices: [Invoice]! @doc(description: "A list of invoices for the order") @resolver(class: "Magento\\SalesGraphQl\\Model\\Resolver\\Invoices") shipments: [OrderShipment] @doc(description: "A list of shipments for the order") @resolver(class: "Magento\\SalesGraphQl\\Model\\Resolver\\Shipments") credit_memos: [CreditMemo] @doc(description: "A list of credit memos") @resolver(class: "Magento\\SalesGraphQl\\Model\\Resolver\\CreditMemos") - payment_methods: [PaymentMethod] @doc(description: "Payment details for the order") + payment_methods: [OrderPaymentMethod] @doc(description: "Payment details for the order") shipping_address: OrderAddress @doc(description: "The shipping address for the order") billing_address: OrderAddress @doc(description: "The billing address for the order") carrier: String @doc(description: "The shipping carrier for the order delivery") @resolver(class: "Magento\\SalesGraphQl\\Model\\Resolver\\CustomerOrders\\Carrier") shipping_method: String @doc(description: "The delivery method for the order") - comments: [CommentItem] @doc(description: "Comments about the order") + comments: [SalesCommentItem] @doc(description: "Comments about the order") increment_id: String @deprecated(reason: "Use the id attribute instead") order_number: String! @deprecated(reason: "Use the number attribute instead") created_at: String @deprecated(reason: "Use the order_date attribute instead") @@ -101,7 +101,7 @@ type OrderItem implements OrderItemInterface { } type OrderItemOption @doc(description: "Represents order item options like selected or entered") { - id: String! @doc(description: "The name of the option") + label: String! @doc(description: "The name of the option") value: String! @doc(description: "The value of the option") } @@ -127,7 +127,7 @@ type Invoice @doc(description: "Invoice details") { number: String! @doc(description: "Sequential invoice number") total: InvoiceTotal @doc(description: "Invoice total amount details") @resolver(class: "Magento\\SalesGraphQl\\Model\\Resolver\\Invoice\\InvoiceTotal") items: [InvoiceItemInterface] @doc(description: "Invoiced product details") @resolver(class: "Magento\\SalesGraphQl\\Model\\Resolver\\Invoice\\InvoiceItems") - comments: [CommentItem] @doc(description: "Comments on the invoice") + comments: [SalesCommentItem] @doc(description: "Comments on the invoice") } interface InvoiceItemInterface @doc(description: "Invoice item details") @typeResolver(class: "Magento\\SalesGraphQl\\Model\\TypeResolver\\InvoiceItem") { @@ -171,10 +171,10 @@ type OrderShipment @doc(description: "Order shipment details") { number: String! @doc(description: "The sequential credit shipment number") tracking: [ShipmentTracking] @doc(description: "Contains shipment tracking details") @resolver(class: "Magento\\SalesGraphQl\\Model\\Resolver\\Shipment\\ShipmentTracking") items: [ShipmentItemInterface] @doc(description: "Contains items included in the shipment") @resolver(class: "Magento\\SalesGraphQl\\Model\\Resolver\\Shipment\\ShipmentItems") - comments: [CommentItem] @doc(description: "Comments added to the shipment") + comments: [SalesCommentItem] @doc(description: "Comments added to the shipment") } -type CommentItem @doc(description: "Comment item details") { +type SalesCommentItem @doc(description: "Comment item details") { timestamp: String! @doc(description: "The timestamp of the comment") message: String! @doc(description: "The text of the message") } @@ -197,7 +197,7 @@ type ShipmentTracking @doc(description: "Order shipment tracking details") { number: String @doc(description: "The tracking number of the order shipment") } -type PaymentMethod @doc(description: "Contains details about the payment method used to pay for the order") { +type OrderPaymentMethod @doc(description: "Contains details about the payment method used to pay for the order") { name: String! @doc(description: "The label that describes the payment method") type: String! @doc(description: "The payment method code that indicates how the order was paid for") additional_data: [KeyValue] @doc(description: "Additional data per payment method type") @@ -208,7 +208,7 @@ type CreditMemo @doc(description: "Credit memo details") { number: String! @doc(description: "The sequential credit memo number") items: [CreditMemoItemInterface] @doc(description: "An array containing details about refunded items") @resolver(class: "Magento\\SalesGraphQl\\Model\\Resolver\\CreditMemo\\CreditMemoItems") total: CreditMemoTotal @doc(description: "Contains details about the total refunded amount") @resolver(class: "Magento\\SalesGraphQl\\Model\\Resolver\\CreditMemo\\CreditMemoTotal") - comments: [CommentItem] @doc(description: "Comments on the credit memo") @resolver(class: "Magento\\SalesGraphQl\\Model\\Resolver\\CreditMemo\\CreditMemoComments") + comments: [SalesCommentItem] @doc(description: "Comments on the credit memo") @resolver(class: "Magento\\SalesGraphQl\\Model\\Resolver\\CreditMemo\\CreditMemoComments") } interface CreditMemoItemInterface @doc(description: "Credit memo item details") @typeResolver(class: "Magento\\SalesGraphQl\\Model\\TypeResolver\\CreditMemoItem") { diff --git a/app/code/Magento/SalesRule/Test/Mftf/ActionGroup/AdminOpenCartPriceRulesPageActionGroup.xml b/app/code/Magento/SalesRule/Test/Mftf/ActionGroup/AdminOpenCartPriceRulesPageActionGroup.xml new file mode 100644 index 0000000000000..b12bdf56e0ed8 --- /dev/null +++ b/app/code/Magento/SalesRule/Test/Mftf/ActionGroup/AdminOpenCartPriceRulesPageActionGroup.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminOpenCartPriceRulesPageActionGroup"> + <annotations> + <description>Open cart price rules page.</description> + </annotations> + + <amOnPage url="{{AdminCartPriceRulesPage.url}}" stepKey="openCartPriceRulesPage"/> + <waitForPageLoad stepKey="waitForPageLoad"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateBuyXGetYFreeTest.xml b/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateBuyXGetYFreeTest.xml index ed2dd16b7df9d..5f2b40dc63e2a 100644 --- a/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateBuyXGetYFreeTest.xml +++ b/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateBuyXGetYFreeTest.xml @@ -34,8 +34,7 @@ </after> <!-- Create a cart price rule of type Buy X get Y free --> - <amOnPage url="{{AdminCartPriceRulesPage.url}}" stepKey="amOnCartPriceList"/> - <waitForPageLoad stepKey="waitForRulesPage"/> + <actionGroup ref="AdminOpenCartPriceRulesPageActionGroup" stepKey="amOnCartPriceList"/> <click selector="{{AdminCartPriceRulesSection.addNewRuleButton}}" stepKey="clickAddNewRule"/> <fillField selector="{{AdminCartPriceRulesFormSection.ruleName}}" userInput="{{_defaultCoupon.code}}" stepKey="fillRuleName"/> <selectOption selector="{{AdminCartPriceRulesFormSection.websites}}" userInput="Main Website" stepKey="selectWebsites"/> diff --git a/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateCartPriceRuleAndVerifyRuleConditionAndFreeShippingIsAppliedTest.xml b/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateCartPriceRuleAndVerifyRuleConditionAndFreeShippingIsAppliedTest.xml index 34152ea06745c..88853b2c40d9a 100644 --- a/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateCartPriceRuleAndVerifyRuleConditionAndFreeShippingIsAppliedTest.xml +++ b/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateCartPriceRuleAndVerifyRuleConditionAndFreeShippingIsAppliedTest.xml @@ -31,8 +31,7 @@ </after> <!--Create cart price rule as per data and verify AssertCartPriceRuleSuccessSaveMessage--> - <amOnPage url="{{AdminCartPriceRulesPage.url}}" stepKey="amOnCartPriceList"/> - <waitForPageLoad stepKey="waitForPriceList"/> + <actionGroup ref="AdminOpenCartPriceRulesPageActionGroup" stepKey="amOnCartPriceList"/> <click selector="{{AdminCartPriceRulesSection.addNewRuleButton}}" stepKey="clickAddNewRule"/> <fillField selector="{{AdminCartPriceRulesFormSection.ruleName}}" userInput="{{CartPriceRuleConditionAndFreeShippingApplied.name}}" stepKey="fillRuleName"/> <fillField selector="{{AdminCartPriceRulesFormSection.description}}" userInput="{{CartPriceRuleConditionAndFreeShippingApplied.description}}" stepKey="fillDescription"/> diff --git a/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateCartPriceRuleAndVerifyRuleConditionIsNotAppliedTest.xml b/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateCartPriceRuleAndVerifyRuleConditionIsNotAppliedTest.xml index 9ac73ceae586e..25d9d431d1c51 100644 --- a/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateCartPriceRuleAndVerifyRuleConditionIsNotAppliedTest.xml +++ b/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateCartPriceRuleAndVerifyRuleConditionIsNotAppliedTest.xml @@ -33,8 +33,7 @@ </after> <!--Create cart price rule as per data and verify AssertCartPriceRuleSuccessSaveMessage--> - <amOnPage url="{{AdminCartPriceRulesPage.url}}" stepKey="amOnCartPriceList"/> - <waitForPageLoad stepKey="waitForPriceList"/> + <actionGroup ref="AdminOpenCartPriceRulesPageActionGroup" stepKey="amOnCartPriceList"/> <click selector="{{AdminCartPriceRulesSection.addNewRuleButton}}" stepKey="clickAddNewRule"/> <fillField selector="{{AdminCartPriceRulesFormSection.ruleName}}" userInput="{{CartPriceRuleConditionNotApplied.name}}" stepKey="fillRuleName"/> <fillField selector="{{AdminCartPriceRulesFormSection.description}}" userInput="{{CartPriceRuleConditionNotApplied.description}}" stepKey="fillDescription"/> diff --git a/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateCartPriceRuleEmptyFromDateTest.xml b/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateCartPriceRuleEmptyFromDateTest.xml index f956d036d7080..e206633808057 100644 --- a/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateCartPriceRuleEmptyFromDateTest.xml +++ b/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateCartPriceRuleEmptyFromDateTest.xml @@ -47,8 +47,7 @@ <click selector="{{AdminMainActionsSection.save}}" stepKey="saveConfig"/> <!-- Create a cart price rule based on a coupon code --> - <amOnPage url="{{AdminCartPriceRulesPage.url}}" stepKey="amOnCartPriceList"/> - <waitForPageLoad stepKey="waitForPriceList"/> + <actionGroup ref="AdminOpenCartPriceRulesPageActionGroup" stepKey="amOnCartPriceList"/> <click selector="{{AdminCartPriceRulesSection.addNewRuleButton}}" stepKey="clickAddNewRule"/> <fillField selector="{{AdminCartPriceRulesFormSection.ruleName}}" userInput="{{_defaultCoupon.code}}" stepKey="fillRuleName"/> <selectOption selector="{{AdminCartPriceRulesFormSection.websites}}" userInput="Main Website" stepKey="selectWebsites"/> diff --git a/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateCartPriceRuleForCouponCodeTest.xml b/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateCartPriceRuleForCouponCodeTest.xml index 557a585858868..16af210066997 100644 --- a/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateCartPriceRuleForCouponCodeTest.xml +++ b/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateCartPriceRuleForCouponCodeTest.xml @@ -34,8 +34,7 @@ </after> <!-- Create a cart price rule based on a coupon code --> - <amOnPage url="{{AdminCartPriceRulesPage.url}}" stepKey="amOnCartPriceList"/> - <waitForPageLoad stepKey="waitForPriceList"/> + <actionGroup ref="AdminOpenCartPriceRulesPageActionGroup" stepKey="amOnCartPriceList"/> <click selector="{{AdminCartPriceRulesSection.addNewRuleButton}}" stepKey="clickAddNewRule"/> <fillField selector="{{AdminCartPriceRulesFormSection.ruleName}}" userInput="{{_defaultCoupon.code}}" stepKey="fillRuleName"/> <selectOption selector="{{AdminCartPriceRulesFormSection.websites}}" userInput="Main Website" stepKey="selectWebsites"/> diff --git a/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateCartPriceRuleForGeneratedCouponTest.xml b/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateCartPriceRuleForGeneratedCouponTest.xml index e18a9eaadcd23..b3d81cea7f97f 100644 --- a/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateCartPriceRuleForGeneratedCouponTest.xml +++ b/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateCartPriceRuleForGeneratedCouponTest.xml @@ -34,8 +34,7 @@ </after> <!-- Create a cart price rule --> - <amOnPage url="{{AdminCartPriceRulesPage.url}}" stepKey="amOnCartPriceList"/> - <waitForPageLoad stepKey="waitForPriceList"/> + <actionGroup ref="AdminOpenCartPriceRulesPageActionGroup" stepKey="amOnCartPriceList"/> <click selector="{{AdminCartPriceRulesSection.addNewRuleButton}}" stepKey="clickAddNewRule"/> <fillField selector="{{AdminCartPriceRulesFormSection.ruleName}}" userInput="{{_defaultCoupon.code}}" stepKey="fillRuleName"/> <selectOption selector="{{AdminCartPriceRulesFormSection.websites}}" userInput="Main Website" stepKey="selectWebsites"/> @@ -60,8 +59,8 @@ <argument name="consumerName" value="{{AdminCodeGeneratorMessageConsumerData.consumerName}}"/> <argument name="maxMessages" value="{{AdminCodeGeneratorMessageConsumerData.messageLimit}}"/> </actionGroup> - <reloadPage stepKey="refreshPage"/> - <waitForPageLoad stepKey="waitFormToReload1"/> + <actionGroup ref="ReloadPageActionGroup" stepKey="refreshPage"/> + <comment userInput="Replacing reload action and preserve Backward Compatibility" stepKey="waitFormToReload1"/> <click selector="{{AdminCartPriceRulesFormSection.manageCouponCodesHeader}}" stepKey="expandCouponSection2"/> <!-- Assert coupon codes grid header is correct --> diff --git a/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateCartPriceRuleForMatchingSubtotalAndVerifyRuleConditionIsAppliedTest.xml b/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateCartPriceRuleForMatchingSubtotalAndVerifyRuleConditionIsAppliedTest.xml index 34714e9637d46..da8c8e4bc1f9d 100644 --- a/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateCartPriceRuleForMatchingSubtotalAndVerifyRuleConditionIsAppliedTest.xml +++ b/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateCartPriceRuleForMatchingSubtotalAndVerifyRuleConditionIsAppliedTest.xml @@ -31,8 +31,7 @@ </after> <!--Create cart price rule as per data and verify AssertCartPriceRuleSuccessSaveMessage--> - <amOnPage url="{{AdminCartPriceRulesPage.url}}" stepKey="amOnCartPriceList"/> - <waitForPageLoad stepKey="waitForPriceList"/> + <actionGroup ref="AdminOpenCartPriceRulesPageActionGroup" stepKey="amOnCartPriceList"/> <click selector="{{AdminCartPriceRulesSection.addNewRuleButton}}" stepKey="clickAddNewRule"/> <fillField selector="{{AdminCartPriceRulesFormSection.ruleName}}" userInput="{{CartPriceRuleConditionAppliedForSubtotal.name}}" stepKey="fillRuleName"/> <fillField selector="{{AdminCartPriceRulesFormSection.description}}" userInput="{{CartPriceRuleConditionAppliedForSubtotal.description}}" stepKey="fillDescription"/> diff --git a/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateCartPriceRuleWithMatchingCategoryAndVerifyRuleConditionIsAppliedTest.xml b/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateCartPriceRuleWithMatchingCategoryAndVerifyRuleConditionIsAppliedTest.xml index a3e6331e31cf6..f6e736c73db74 100644 --- a/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateCartPriceRuleWithMatchingCategoryAndVerifyRuleConditionIsAppliedTest.xml +++ b/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateCartPriceRuleWithMatchingCategoryAndVerifyRuleConditionIsAppliedTest.xml @@ -38,8 +38,7 @@ </after> <!--Create cart price rule as per data and verify AssertCartPriceRuleSuccessSaveMessage--> - <amOnPage url="{{AdminCartPriceRulesPage.url}}" stepKey="amOnCartPriceList"/> - <waitForPageLoad stepKey="waitForPriceList"/> + <actionGroup ref="AdminOpenCartPriceRulesPageActionGroup" stepKey="amOnCartPriceList"/> <click selector="{{AdminCartPriceRulesSection.addNewRuleButton}}" stepKey="clickAddNewRule"/> <fillField selector="{{AdminCartPriceRulesFormSection.ruleName}}" userInput="{{CartPriceRuleConditionAppliedForCategory.name}}" stepKey="fillRuleName"/> <fillField selector="{{AdminCartPriceRulesFormSection.description}}" userInput="{{CartPriceRuleConditionAppliedForCategory.description}}" stepKey="fillDescription"/> diff --git a/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateCartPriceRuleWithMatchingTotalWeightAndVerifyRuleConditionIsAppliedTest.xml b/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateCartPriceRuleWithMatchingTotalWeightAndVerifyRuleConditionIsAppliedTest.xml index e9f7f3ec6c70a..5f110f7074f6f 100644 --- a/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateCartPriceRuleWithMatchingTotalWeightAndVerifyRuleConditionIsAppliedTest.xml +++ b/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateCartPriceRuleWithMatchingTotalWeightAndVerifyRuleConditionIsAppliedTest.xml @@ -31,8 +31,7 @@ </after> <!--Create cart price rule as per data and verify AssertCartPriceRuleSuccessSaveMessage--> - <amOnPage url="{{AdminCartPriceRulesPage.url}}" stepKey="amOnCartPriceList"/> - <waitForPageLoad stepKey="waitForPriceList"/> + <actionGroup ref="AdminOpenCartPriceRulesPageActionGroup" stepKey="amOnCartPriceList"/> <click selector="{{AdminCartPriceRulesSection.addNewRuleButton}}" stepKey="clickAddNewRule"/> <fillField selector="{{AdminCartPriceRulesFormSection.ruleName}}" userInput="{{CartPriceRuleConditionAppliedForWeight.name}}" stepKey="fillRuleName"/> <fillField selector="{{AdminCartPriceRulesFormSection.description}}" userInput="{{CartPriceRuleConditionAppliedForWeight.description}}" stepKey="fillDescription"/> diff --git a/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateFixedAmountDiscountTest.xml b/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateFixedAmountDiscountTest.xml index 0d98abfba3f62..2c3574906848c 100644 --- a/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateFixedAmountDiscountTest.xml +++ b/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateFixedAmountDiscountTest.xml @@ -34,8 +34,7 @@ </after> <!-- Create a cart price rule for $10 Fixed amount discount --> - <amOnPage url="{{AdminCartPriceRulesPage.url}}" stepKey="amOnCartPriceList"/> - <waitForPageLoad stepKey="waitForRulesPage"/> + <actionGroup ref="AdminOpenCartPriceRulesPageActionGroup" stepKey="amOnCartPriceList"/> <click selector="{{AdminCartPriceRulesSection.addNewRuleButton}}" stepKey="clickAddNewRule"/> <fillField selector="{{AdminCartPriceRulesFormSection.ruleName}}" userInput="{{_defaultCoupon.code}}" stepKey="fillRuleName"/> <selectOption selector="{{AdminCartPriceRulesFormSection.websites}}" userInput="Main Website" stepKey="selectWebsites"/> diff --git a/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateFixedAmountWholeCartDiscountTest.xml b/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateFixedAmountWholeCartDiscountTest.xml index bc4139435ab55..1b24480b5808b 100644 --- a/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateFixedAmountWholeCartDiscountTest.xml +++ b/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateFixedAmountWholeCartDiscountTest.xml @@ -34,8 +34,7 @@ </after> <!-- Create a cart price rule for Fixed amount discount for whole cart --> - <amOnPage url="{{AdminCartPriceRulesPage.url}}" stepKey="amOnCartPriceList"/> - <waitForPageLoad stepKey="waitForRulesPage"/> + <actionGroup ref="AdminOpenCartPriceRulesPageActionGroup" stepKey="amOnCartPriceList"/> <click selector="{{AdminCartPriceRulesSection.addNewRuleButton}}" stepKey="clickAddNewRule"/> <fillField selector="{{AdminCartPriceRulesFormSection.ruleName}}" userInput="{{SimpleSalesRule.name}}" stepKey="fillRuleName"/> <selectOption selector="{{AdminCartPriceRulesFormSection.websites}}" userInput="Main Website" stepKey="selectWebsites"/> diff --git a/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateInvalidRuleTest.xml b/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateInvalidRuleTest.xml index 56c4506196d24..83648cec149d0 100644 --- a/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateInvalidRuleTest.xml +++ b/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreateInvalidRuleTest.xml @@ -26,8 +26,7 @@ <actionGroup ref="AdminLogoutActionGroup" stepKey="adminLogout"/> </after> - <amOnPage url="{{AdminCartPriceRulesPage.url}}" stepKey="amOnCartPriceList"/> - <waitForPageLoad stepKey="waitForRulesPage"/> + <actionGroup ref="AdminOpenCartPriceRulesPageActionGroup" stepKey="amOnCartPriceList"/> <click selector="{{AdminCartPriceRulesSection.addNewRuleButton}}" stepKey="clickAddNewRule"/> <click selector="{{AdminCartPriceRulesFormSection.actionsHeader}}" stepKey="clickToExpandActions"/> diff --git a/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreatePercentOfProductPriceTest.xml b/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreatePercentOfProductPriceTest.xml index 23e472518ba84..724860b12603c 100644 --- a/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreatePercentOfProductPriceTest.xml +++ b/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCreatePercentOfProductPriceTest.xml @@ -36,8 +36,7 @@ </after> <!-- Create a cart price rule for 50 percent of product price --> - <amOnPage url="{{AdminCartPriceRulesPage.url}}" stepKey="amOnCartPriceList"/> - <waitForPageLoad stepKey="waitForRulesPage"/> + <actionGroup ref="AdminOpenCartPriceRulesPageActionGroup" stepKey="amOnCartPriceList"/> <click selector="{{AdminCartPriceRulesSection.addNewRuleButton}}" stepKey="clickAddNewRule"/> <fillField selector="{{AdminCartPriceRulesFormSection.ruleName}}" userInput="{{_defaultCoupon.code}}" stepKey="fillRuleName"/> <selectOption selector="{{AdminCartPriceRulesFormSection.websites}}" userInput="Main Website" stepKey="selectWebsites"/> diff --git a/app/code/Magento/SalesRule/Test/Mftf/Test/CartPriceRuleForConfigurableProductTest.xml b/app/code/Magento/SalesRule/Test/Mftf/Test/CartPriceRuleForConfigurableProductTest.xml index ad1ff69a60901..d60a81dcdcef9 100644 --- a/app/code/Magento/SalesRule/Test/Mftf/Test/CartPriceRuleForConfigurableProductTest.xml +++ b/app/code/Magento/SalesRule/Test/Mftf/Test/CartPriceRuleForConfigurableProductTest.xml @@ -96,8 +96,7 @@ </after> <!-- Create the rule --> - <amOnPage url="{{AdminCartPriceRulesPage.url}}" stepKey="amOnCartPriceList"/> - <waitForPageLoad stepKey="waitForRulesPage"/> + <actionGroup ref="AdminOpenCartPriceRulesPageActionGroup" stepKey="amOnCartPriceList"/> <click selector="{{AdminCartPriceRulesSection.addNewRuleButton}}" stepKey="clickAddNewRule"/> <fillField selector="{{AdminCartPriceRulesFormSection.ruleName}}" userInput="{{SimpleSalesRule.name}}" stepKey="fillRuleName"/> <selectOption selector="{{AdminCartPriceRulesFormSection.websites}}" userInput="Main Website" stepKey="selectWebsites"/> diff --git a/app/code/Magento/SalesRule/Test/Mftf/Test/StorefrontAutoGeneratedCouponCodeTest.xml b/app/code/Magento/SalesRule/Test/Mftf/Test/StorefrontAutoGeneratedCouponCodeTest.xml index c2aeca657db3b..74542be376c45 100644 --- a/app/code/Magento/SalesRule/Test/Mftf/Test/StorefrontAutoGeneratedCouponCodeTest.xml +++ b/app/code/Magento/SalesRule/Test/Mftf/Test/StorefrontAutoGeneratedCouponCodeTest.xml @@ -40,8 +40,7 @@ <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> <!-- Search Cart Price Rule and go to edit Cart Price Rule --> - <amOnPage url="{{AdminCartPriceRulesPage.url}}" stepKey="amOnCartPriceList"/> - <waitForPageLoad stepKey="waitForPageLoad"/> + <actionGroup ref="AdminOpenCartPriceRulesPageActionGroup" stepKey="amOnCartPriceList"/> <fillField selector="{{AdminCartPriceRulesSection.filterByNameInput}}" userInput="$$createSalesRule.name$$" stepKey="fillFieldFilterByName"/> <click selector="{{AdminCartPriceRulesSection.searchButton}}" stepKey="clickSearchButton"/> @@ -64,8 +63,8 @@ <argument name="consumerName" value="{{AdminCodeGeneratorMessageConsumerData.consumerName}}"/> <argument name="maxMessages" value="{{AdminCodeGeneratorMessageConsumerData.messageLimit}}"/> </actionGroup> - <reloadPage stepKey="refreshPage"/> - <waitForPageLoad stepKey="waitFormToReload1"/> + <actionGroup ref="ReloadPageActionGroup" stepKey="refreshPage"/> + <comment userInput="Replacing reload action and preserve Backward Compatibility" stepKey="waitFormToReload1"/> <conditionalClick selector="{{AdminCartPriceRulesFormSection.manageCouponCodesHeader}}" dependentSelector="{{AdminCartPriceRulesFormSection.manageCouponCodesHeader}}" visible="true" stepKey="clickManageCouponCodes2"/> diff --git a/app/code/Magento/SalesRule/Test/Mftf/Test/StorefrontCartPriceRuleCountryTest.xml b/app/code/Magento/SalesRule/Test/Mftf/Test/StorefrontCartPriceRuleCountryTest.xml index eef5dadfbe5d8..ea96fa41e5cad 100644 --- a/app/code/Magento/SalesRule/Test/Mftf/Test/StorefrontCartPriceRuleCountryTest.xml +++ b/app/code/Magento/SalesRule/Test/Mftf/Test/StorefrontCartPriceRuleCountryTest.xml @@ -37,8 +37,7 @@ </after> <!-- Create the rule... --> - <amOnPage url="{{AdminCartPriceRulesPage.url}}" stepKey="amOnCartPriceList"/> - <waitForPageLoad stepKey="waitForRulesPage"/> + <actionGroup ref="AdminOpenCartPriceRulesPageActionGroup" stepKey="amOnCartPriceList"/> <click selector="{{AdminCartPriceRulesSection.addNewRuleButton}}" stepKey="clickAddNewRule"/> <fillField selector="{{AdminCartPriceRulesFormSection.ruleName}}" userInput="{{SimpleSalesRule.name}}" stepKey="fillRuleName"/> <selectOption selector="{{AdminCartPriceRulesFormSection.websites}}" userInput="Main Website" stepKey="selectWebsites"/> diff --git a/app/code/Magento/SalesRule/Test/Mftf/Test/CartPriceRuleForBundleProductTest.xml b/app/code/Magento/SalesRule/Test/Mftf/Test/StorefrontCartPriceRuleForBundleProductTest.xml similarity index 97% rename from app/code/Magento/SalesRule/Test/Mftf/Test/CartPriceRuleForBundleProductTest.xml rename to app/code/Magento/SalesRule/Test/Mftf/Test/StorefrontCartPriceRuleForBundleProductTest.xml index 101c72b78078a..56486d2331bd6 100644 --- a/app/code/Magento/SalesRule/Test/Mftf/Test/CartPriceRuleForBundleProductTest.xml +++ b/app/code/Magento/SalesRule/Test/Mftf/Test/StorefrontCartPriceRuleForBundleProductTest.xml @@ -7,7 +7,7 @@ --> <tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> - <test name="CartPriceRuleForBundleProductTest"> + <test name="StorefrontCartPriceRuleForBundleProductTest"> <annotations> <features value="SalesRule"/> <stories value="MAGETWO-28921 - Cart Price Rule for bundle products"/> @@ -16,6 +16,9 @@ <severity value="BLOCKER"/> <testCaseId value="MAGETWO-28921"/> <group value="SalesRule"/> + <skip> + <issueId value="MQE-2288" /> + </skip> </annotations> <before> @@ -104,8 +107,7 @@ </after> <!-- Create the rule --> - <amOnPage url="{{AdminCartPriceRulesPage.url}}" stepKey="amOnCartPriceList"/> - <waitForPageLoad stepKey="waitForRulesPage"/> + <actionGroup ref="AdminOpenCartPriceRulesPageActionGroup" stepKey="amOnCartPriceList"/> <click selector="{{AdminCartPriceRulesSection.addNewRuleButton}}" stepKey="clickAddNewRule"/> <fillField selector="{{AdminCartPriceRulesFormSection.ruleName}}" userInput="{{SimpleSalesRule.name}}" stepKey="fillRuleName"/> <selectOption selector="{{AdminCartPriceRulesFormSection.websites}}" userInput="Main Website" stepKey="selectWebsites"/> diff --git a/app/code/Magento/SalesRule/Test/Mftf/Test/StorefrontCartPriceRulePostcodeTest.xml b/app/code/Magento/SalesRule/Test/Mftf/Test/StorefrontCartPriceRulePostcodeTest.xml index 69097e3269fcb..62c494b988bbd 100644 --- a/app/code/Magento/SalesRule/Test/Mftf/Test/StorefrontCartPriceRulePostcodeTest.xml +++ b/app/code/Magento/SalesRule/Test/Mftf/Test/StorefrontCartPriceRulePostcodeTest.xml @@ -37,8 +37,7 @@ </after> <!-- Create the rule... --> - <amOnPage url="{{AdminCartPriceRulesPage.url}}" stepKey="amOnCartPriceList"/> - <waitForPageLoad stepKey="waitForRulesPage"/> + <actionGroup ref="AdminOpenCartPriceRulesPageActionGroup" stepKey="amOnCartPriceList"/> <click selector="{{AdminCartPriceRulesSection.addNewRuleButton}}" stepKey="clickAddNewRule"/> <fillField selector="{{AdminCartPriceRulesFormSection.ruleName}}" userInput="{{SimpleSalesRule.name}}" stepKey="fillRuleName"/> <selectOption selector="{{AdminCartPriceRulesFormSection.websites}}" userInput="Main Website" stepKey="selectWebsites"/> diff --git a/app/code/Magento/SalesRule/Test/Mftf/Test/StorefrontCartPriceRuleQuantityTest.xml b/app/code/Magento/SalesRule/Test/Mftf/Test/StorefrontCartPriceRuleQuantityTest.xml index 18057965c28e1..70ed09df7a2cc 100644 --- a/app/code/Magento/SalesRule/Test/Mftf/Test/StorefrontCartPriceRuleQuantityTest.xml +++ b/app/code/Magento/SalesRule/Test/Mftf/Test/StorefrontCartPriceRuleQuantityTest.xml @@ -38,8 +38,7 @@ </after> <!-- Create the rule... --> - <amOnPage url="{{AdminCartPriceRulesPage.url}}" stepKey="amOnCartPriceList"/> - <waitForPageLoad stepKey="waitForRulesPage"/> + <actionGroup ref="AdminOpenCartPriceRulesPageActionGroup" stepKey="amOnCartPriceList"/> <click selector="{{AdminCartPriceRulesSection.addNewRuleButton}}" stepKey="clickAddNewRule"/> <fillField selector="{{AdminCartPriceRulesFormSection.ruleName}}" userInput="{{SimpleSalesRule.name}}" stepKey="fillRuleName"/> <selectOption selector="{{AdminCartPriceRulesFormSection.websites}}" userInput="Main Website" stepKey="selectWebsites"/> diff --git a/app/code/Magento/SalesRule/Test/Mftf/Test/StorefrontCartPriceRuleStateTest.xml b/app/code/Magento/SalesRule/Test/Mftf/Test/StorefrontCartPriceRuleStateTest.xml index c13b74b6990d0..da9ca9055d31b 100644 --- a/app/code/Magento/SalesRule/Test/Mftf/Test/StorefrontCartPriceRuleStateTest.xml +++ b/app/code/Magento/SalesRule/Test/Mftf/Test/StorefrontCartPriceRuleStateTest.xml @@ -37,8 +37,7 @@ </after> <!-- Create the rule... --> - <amOnPage url="{{AdminCartPriceRulesPage.url}}" stepKey="amOnCartPriceList"/> - <waitForPageLoad stepKey="waitForRulesPage"/> + <actionGroup ref="AdminOpenCartPriceRulesPageActionGroup" stepKey="amOnCartPriceList"/> <click selector="{{AdminCartPriceRulesSection.addNewRuleButton}}" stepKey="clickAddNewRule"/> <fillField selector="{{AdminCartPriceRulesFormSection.ruleName}}" userInput="{{SimpleSalesRule.name}}" stepKey="fillRuleName"/> <selectOption selector="{{AdminCartPriceRulesFormSection.websites}}" userInput="Main Website" stepKey="selectWebsites"/> diff --git a/app/code/Magento/SalesRule/Test/Mftf/Test/StorefrontCartPriceRuleSubtotalTest.xml b/app/code/Magento/SalesRule/Test/Mftf/Test/StorefrontCartPriceRuleSubtotalTest.xml index 97b75ae772f08..ce0d814e50308 100644 --- a/app/code/Magento/SalesRule/Test/Mftf/Test/StorefrontCartPriceRuleSubtotalTest.xml +++ b/app/code/Magento/SalesRule/Test/Mftf/Test/StorefrontCartPriceRuleSubtotalTest.xml @@ -38,8 +38,7 @@ </after> <!-- Create the rule... --> - <amOnPage url="{{AdminCartPriceRulesPage.url}}" stepKey="amOnCartPriceList"/> - <waitForPageLoad stepKey="waitForRulesPage"/> + <actionGroup ref="AdminOpenCartPriceRulesPageActionGroup" stepKey="amOnCartPriceList"/> <click selector="{{AdminCartPriceRulesSection.addNewRuleButton}}" stepKey="clickAddNewRule"/> <fillField selector="{{AdminCartPriceRulesFormSection.ruleName}}" userInput="{{SimpleSalesRule.name}}" stepKey="fillRuleName"/> <selectOption selector="{{AdminCartPriceRulesFormSection.websites}}" userInput="Main Website" stepKey="selectWebsites"/> diff --git a/app/code/Magento/SalesRule/view/adminhtml/ui_component/sales_rule_form.xml b/app/code/Magento/SalesRule/view/adminhtml/ui_component/sales_rule_form.xml index e1c12f45012ee..689ea0a1fa53d 100644 --- a/app/code/Magento/SalesRule/view/adminhtml/ui_component/sales_rule_form.xml +++ b/app/code/Magento/SalesRule/view/adminhtml/ui_component/sales_rule_form.xml @@ -47,14 +47,6 @@ </settings> </dataProvider> </dataSource> - <fieldset name="general"> - <settings> - <additionalClasses> - <class name="fieldset-schedule">true</class> - </additionalClasses> - <label translate="true">Currently Active</label> - </settings> - </fieldset> <fieldset name="rule_information" sortOrder="10"> <settings> <collapsible>true</collapsible> diff --git a/app/code/Magento/Search/Model/Autocomplete.php b/app/code/Magento/Search/Model/Autocomplete.php index 45957e8795744..57364e4c36bde 100644 --- a/app/code/Magento/Search/Model/Autocomplete.php +++ b/app/code/Magento/Search/Model/Autocomplete.php @@ -30,11 +30,11 @@ public function __construct( */ public function getItems() { - $data = [[]]; + $data = []; foreach ($this->dataProviders as $dataProvider) { $data[] = $dataProvider->getItems(); } - return array_merge(...$data); + return array_merge([], ...$data); } } diff --git a/app/code/Magento/Search/Model/SynonymAnalyzer.php b/app/code/Magento/Search/Model/SynonymAnalyzer.php index eea6a950d7ce5..16d0b0b4ddcd9 100644 --- a/app/code/Magento/Search/Model/SynonymAnalyzer.php +++ b/app/code/Magento/Search/Model/SynonymAnalyzer.php @@ -42,6 +42,7 @@ public function __construct(SynonymReader $synReader) * 3 => [ 0 => "british", 1 => "english" ], * 4 => [ 0 => "queen", 1 => "monarch" ] * ] + * * @param string $phrase * @return array * @throws \Magento\Framework\Exception\LocalizedException @@ -136,6 +137,9 @@ private function getSearchPattern(array $words): string { $patterns = []; for ($lastItem = count($words); $lastItem > 0; $lastItem--) { + $words = array_map(function ($word) { + return preg_quote($word, '/'); + }, $words); $phrase = implode("\s+", \array_slice($words, 0, $lastItem)); $patterns[] = '^' . $phrase . ','; $patterns[] = ',' . $phrase . ','; diff --git a/app/code/Magento/Search/Model/SynonymGroupRepository.php b/app/code/Magento/Search/Model/SynonymGroupRepository.php index dbc2b66b1f047..c670235d67adb 100644 --- a/app/code/Magento/Search/Model/SynonymGroupRepository.php +++ b/app/code/Magento/Search/Model/SynonymGroupRepository.php @@ -150,7 +150,7 @@ private function create(SynonymGroupInterface $synonymGroup, $errorOnMergeConfli */ private function merge(SynonymGroupInterface $synonymGroupToMerge, array $matchingGroupIds) { - $mergedSynonyms = [[]]; + $mergedSynonyms = []; foreach ($matchingGroupIds as $groupId) { /** @var SynonymGroup $synonymGroupModel */ $synonymGroupModel = $this->synonymGroupFactory->create(); @@ -160,7 +160,7 @@ private function merge(SynonymGroupInterface $synonymGroupToMerge, array $matchi } $mergedSynonyms[] = explode(',', $synonymGroupToMerge->getSynonymGroup()); - return array_unique(array_merge(...$mergedSynonyms)); + return array_unique(array_merge([], ...$mergedSynonyms)); } /** diff --git a/app/code/Magento/Search/Test/Mftf/ActionGroup/AdminSetGlobalSearchValueActionGroup.xml b/app/code/Magento/Search/Test/Mftf/ActionGroup/AdminSetGlobalSearchValueActionGroup.xml new file mode 100644 index 0000000000000..5bc63bf730de0 --- /dev/null +++ b/app/code/Magento/Search/Test/Mftf/ActionGroup/AdminSetGlobalSearchValueActionGroup.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminSetGlobalSearchValueActionGroup"> + <arguments> + <argument name="textSearch" type="string" defaultValue=""/> + </arguments> + + <click selector="{{AdminGlobalSearchSection.globalSearch}}" stepKey="clickSearchBtn"/> + <waitForElementVisible selector="{{AdminGlobalSearchSection.globalSearchActive}}" stepKey="waitForSearchInputVisible"/> + <fillField selector="{{AdminGlobalSearchSection.globalSearchInput}}" userInput="{{textSearch}}" stepKey="fillSearch"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Search/Test/Mftf/Section/AdminGlobalSearchSection.xml b/app/code/Magento/Search/Test/Mftf/Section/AdminGlobalSearchSection.xml index 0ba61283548cf..a529000e20923 100644 --- a/app/code/Magento/Search/Test/Mftf/Section/AdminGlobalSearchSection.xml +++ b/app/code/Magento/Search/Test/Mftf/Section/AdminGlobalSearchSection.xml @@ -11,5 +11,8 @@ <section name="AdminGlobalSearchSection"> <element name="globalSearch" type="button" selector=".search-global-label"/> <element name="globalSearchActive" type="block" selector=".search-global-field._active"/> + <element name="globalSearchInput" type="input" selector=".search-global-input"/> + <element name="globalSearchSuggestedCategoryText" type="text" selector="//span[contains(text(), 'Category')]"/> + <element name="globalSearchSuggestedCategoryLink" type="text" selector="//span[contains(text(), 'Category')]/preceding-sibling::a"/> </section> </sections> diff --git a/app/code/Magento/Search/Test/Unit/Model/SynonymAnalyzerTest.php b/app/code/Magento/Search/Test/Unit/Model/SynonymAnalyzerTest.php index 8751c8a4f3ec0..9e6d087f72f99 100644 --- a/app/code/Magento/Search/Test/Unit/Model/SynonymAnalyzerTest.php +++ b/app/code/Magento/Search/Test/Unit/Model/SynonymAnalyzerTest.php @@ -49,9 +49,9 @@ protected function setUp(): void */ public function testGetSynonymsForPhrase() { - $phrase = 'Elizabeth is the british queen'; + $phrase = 'Elizabeth/Angela is the british queen'; $expected = [ - 0 => [ 0 => "Elizabeth" ], + 0 => [ 0 => "Elizabeth/Angela" ], 1 => [ 0 => "is" ], 2 => [ 0 => "the" ], 3 => [ 0 => "british", 1 => "english" ], diff --git a/app/code/Magento/Search/view/adminhtml/ui_component/search_synonyms_grid.xml b/app/code/Magento/Search/view/adminhtml/ui_component/search_synonyms_grid.xml index 42ebf1454fb7e..c95604f0afa49 100644 --- a/app/code/Magento/Search/view/adminhtml/ui_component/search_synonyms_grid.xml +++ b/app/code/Magento/Search/view/adminhtml/ui_component/search_synonyms_grid.xml @@ -65,7 +65,7 @@ <label translate="true">Store View</label> <dataScope>store_id</dataScope> <imports> - <link name="visible">ns = ${ $.ns }, componentType = column, index = ${ $.index }:visible</link> + <link name="visible">ns = ${ $.ns }, index = ${ $.index }:visible</link> </imports> </settings> </filterSelect> @@ -76,7 +76,7 @@ <label translate="true">Website</label> <dataScope>website_id</dataScope> <imports> - <link name="visible">ns = ${ $.ns }, componentType = column, index = ${ $.index }:visible</link> + <link name="visible">ns = ${ $.ns }, index = ${ $.index }:visible</link> </imports> </settings> </filterSelect> diff --git a/app/code/Magento/Security/Model/UserExpirationManager.php b/app/code/Magento/Security/Model/UserExpirationManager.php index fe6b87de5a8ec..667ff4841165c 100644 --- a/app/code/Magento/Security/Model/UserExpirationManager.php +++ b/app/code/Magento/Security/Model/UserExpirationManager.php @@ -122,12 +122,12 @@ private function processExpiredUsers(ExpiredUsersCollection $expiredRecords): vo // delete expired records $expiredRecordIds = $expiredRecords->getAllIds(); + $expiredRecords->walk('delete'); // set user is_active to 0 $users = $this->userCollectionFactory->create() ->addFieldToFilter('main_table.user_id', ['in' => $expiredRecordIds]); $users->setDataToAll('is_active', 0)->save(); - $expiredRecords->walk('delete'); } /** diff --git a/app/code/Magento/Security/Observer/AfterAdminUserSave.php b/app/code/Magento/Security/Observer/AfterAdminUserSave.php index d11c1bfdcdf17..096b0f85f5056 100644 --- a/app/code/Magento/Security/Observer/AfterAdminUserSave.php +++ b/app/code/Magento/Security/Observer/AfterAdminUserSave.php @@ -53,7 +53,7 @@ public function execute(Observer $observer) { /* @var $user \Magento\User\Model\User */ $user = $observer->getEvent()->getObject(); - if ($user->getId()) { + if ($user->getId() && $user->hasData('expires_at')) { $expiresAt = $user->getExpiresAt(); /** @var \Magento\Security\Model\UserExpiration $userExpiration */ $userExpiration = $this->userExpirationFactory->create(); diff --git a/app/code/Magento/Security/Test/Mftf/Test/AdminNavigateWhileUserExpiredTest.xml b/app/code/Magento/Security/Test/Mftf/Test/AdminNavigateWhileUserExpiredTest.xml index c1a951afd87ec..dc88ad9d2cbf1 100644 --- a/app/code/Magento/Security/Test/Mftf/Test/AdminNavigateWhileUserExpiredTest.xml +++ b/app/code/Magento/Security/Test/Mftf/Test/AdminNavigateWhileUserExpiredTest.xml @@ -46,7 +46,7 @@ </actionGroup> <actionGroup ref="AssertAdminDashboardPageIsVisibleActionGroup" stepKey="seeDashboardPage"/> <wait time="120" stepKey="waitForUserToExpire"/> - <amOnPage url="{{AdminCustomerPage.url}}" stepKey="navigateToCustomers"/> + <actionGroup ref="AdminOpenCustomersGridActionGroup" stepKey="navigateToCustomers"/> <!-- Confirm that user is logged out --> <seeInCurrentUrl url="{{AdminLoginPage.url}}" stepKey="seeAdminLoginUrl"/> diff --git a/app/code/Magento/Security/Test/Unit/Observer/AfterAdminUserSaveTest.php b/app/code/Magento/Security/Test/Unit/Observer/AfterAdminUserSaveTest.php index 6a2a6107e3330..f142b2addfd87 100644 --- a/app/code/Magento/Security/Test/Unit/Observer/AfterAdminUserSaveTest.php +++ b/app/code/Magento/Security/Test/Unit/Observer/AfterAdminUserSaveTest.php @@ -86,7 +86,7 @@ protected function setUp(): void ->getMock(); $this->userMock = $this->getMockBuilder(User::class) ->addMethods(['getExpiresAt']) - ->onlyMethods(['getId']) + ->onlyMethods(['getId', 'hasData']) ->disableOriginalConstructor() ->getMock(); $this->userExpirationMock = $this->createPartialMock( @@ -95,13 +95,20 @@ protected function setUp(): void ); } - public function testSaveNewUserExpiration() + /** + * @return void + */ + public function testSaveNewUserExpiration(): void { $userId = '123'; $this->eventObserverMock->expects(static::once())->method('getEvent')->willReturn($this->eventMock); $this->eventMock->expects(static::once())->method('getObject')->willReturn($this->userMock); $this->userMock->expects(static::exactly(3))->method('getId')->willReturn($userId); $this->userMock->expects(static::once())->method('getExpiresAt')->willReturn($this->getExpiresDateTime()); + $this->userMock->expects(static::once()) + ->method('hasData') + ->with('expires_at') + ->willReturn(true); $this->userExpirationFactoryMock->expects(static::once())->method('create') ->willReturn($this->userExpirationMock); $this->userExpirationResourceMock->expects(static::once())->method('load') @@ -119,7 +126,7 @@ public function testSaveNewUserExpiration() /** * @throws \Exception */ - public function testClearUserExpiration() + public function testClearUserExpiration(): void { $userId = '123'; $this->userExpirationMock->setId($userId); @@ -128,6 +135,10 @@ public function testClearUserExpiration() $this->eventMock->expects(static::once())->method('getObject')->willReturn($this->userMock); $this->userMock->expects(static::exactly(2))->method('getId')->willReturn($userId); $this->userMock->expects(static::once())->method('getExpiresAt')->willReturn(null); + $this->userMock->expects(static::once()) + ->method('hasData') + ->with('expires_at') + ->willReturn(true); $this->userExpirationFactoryMock->expects(static::once())->method('create') ->willReturn($this->userExpirationMock); $this->userExpirationResourceMock->expects(static::once())->method('load') @@ -139,7 +150,10 @@ public function testClearUserExpiration() $this->observer->execute($this->eventObserverMock); } - public function testChangeUserExpiration() + /** + * @return void + */ + public function testChangeUserExpiration(): void { $userId = '123'; $this->userExpirationMock->setId($userId); @@ -148,6 +162,10 @@ public function testChangeUserExpiration() $this->eventMock->expects(static::once())->method('getObject')->willReturn($this->userMock); $this->userMock->expects(static::exactly(2))->method('getId')->willReturn($userId); $this->userMock->expects(static::once())->method('getExpiresAt')->willReturn($this->getExpiresDateTime()); + $this->userMock->expects(static::once()) + ->method('hasData') + ->with('expires_at') + ->willReturn(true); $this->userExpirationFactoryMock->expects(static::once())->method('create') ->willReturn($this->userExpirationMock); $this->userExpirationResourceMock->expects(static::once())->method('load') @@ -161,11 +179,35 @@ public function testChangeUserExpiration() $this->observer->execute($this->eventObserverMock); } + /** + * @return void + */ + public function testExecuteWithoutUserExpiration(): void + { + $userId = '123'; + $this->userExpirationMock->setId($userId); + + $this->eventObserverMock->expects(static::once())->method('getEvent')->willReturn($this->eventMock); + $this->eventMock->expects(static::once())->method('getObject')->willReturn($this->userMock); + $this->userMock->expects(static::once())->method('getId')->willReturn($userId); + $this->userMock->expects(static::once()) + ->method('hasData') + ->with('expires_at') + ->willReturn(false); + $this->userExpirationFactoryMock->expects(static::never())->method('create'); + $this->userExpirationResourceMock->expects(static::never())->method('load'); + + $this->userExpirationMock->expects(static::never())->method('getId'); + $this->userExpirationMock->expects(static::never())->method('setExpiresAt'); + $this->userExpirationResourceMock->expects(static::never())->method('save'); + $this->observer->execute($this->eventObserverMock); + } + /** * @return string * @throws \Exception */ - private function getExpiresDateTime() + private function getExpiresDateTime(): string { $testDate = new \DateTime(); $testDate->modify('+10 days'); diff --git a/app/code/Magento/SendFriend/Model/ResourceModel/SendFriend.php b/app/code/Magento/SendFriend/Model/ResourceModel/SendFriend.php index 618d941f7047e..edb572dfdd4d1 100644 --- a/app/code/Magento/SendFriend/Model/ResourceModel/SendFriend.php +++ b/app/code/Magento/SendFriend/Model/ResourceModel/SendFriend.php @@ -6,10 +6,6 @@ namespace Magento\SendFriend\Model\ResourceModel; /** - * SendFriend Log Resource Model - * - * @author Magento Core Team <core@magentocommerce.com> - * * @api * @since 100.0.2 */ @@ -32,6 +28,7 @@ protected function _construct() * @param int $ip * @param int $startTime * @param int $websiteId + * * @return int * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ @@ -46,7 +43,7 @@ public function getSendCount($object, $ip, $startTime, $websiteId = null) AND time>=:time AND website_id=:website_id' ); - $bind = ['ip' => ip2long($ip), 'time' => $startTime, 'website_id' => (int)$websiteId]; + $bind = ['ip' => $ip, 'time' => $startTime, 'website_id' => (int)$websiteId]; $row = $connection->fetchRow($select, $bind); return $row['count']; @@ -58,14 +55,16 @@ public function getSendCount($object, $ip, $startTime, $websiteId = null) * @param int $ip * @param int $startTime * @param int $websiteId + * * @return $this */ public function addSendItem($ip, $startTime, $websiteId) { $this->getConnection()->insert( $this->getMainTable(), - ['ip' => ip2long($ip), 'time' => $startTime, 'website_id' => $websiteId] + ['ip' => $ip, 'time' => $startTime, 'website_id' => $websiteId] ); + return $this; } @@ -73,6 +72,7 @@ public function addSendItem($ip, $startTime, $websiteId) * Delete Old logs * * @param int $time + * * @return $this */ public function deleteLogsBefore($time) diff --git a/app/code/Magento/Shipping/Test/Mftf/Data/TableRatesShippingMethodData.xml b/app/code/Magento/Shipping/Test/Mftf/Data/TableRatesShippingMethodData.xml index 47ef68cc9d765..ceae9c546bd3b 100644 --- a/app/code/Magento/Shipping/Test/Mftf/Data/TableRatesShippingMethodData.xml +++ b/app/code/Magento/Shipping/Test/Mftf/Data/TableRatesShippingMethodData.xml @@ -16,4 +16,10 @@ <data key="title">Best Way</data> <data key="methodName">Table Rate</data> </entity> + <!-- Set Table Rate Shipping method Condition --> + <entity name="TableRateShippingMethodConfig" type="shipping_method"> + <data key="package_weight">Weight vs. Destination</data> + <data key="package_value_with_discount">Price vs. Destination</data> + <data key="package_qty"># of Items vs. Destination</data> + </entity> </entities> diff --git a/app/code/Magento/Shipping/Test/Mftf/Test/AdminCheckInputFieldsDisabledAfterAppConfigDumpTest.xml b/app/code/Magento/Shipping/Test/Mftf/Test/AdminCheckInputFieldsDisabledAfterAppConfigDumpTest.xml index 188b12c6a91c3..0c0372850a3c4 100644 --- a/app/code/Magento/Shipping/Test/Mftf/Test/AdminCheckInputFieldsDisabledAfterAppConfigDumpTest.xml +++ b/app/code/Magento/Shipping/Test/Mftf/Test/AdminCheckInputFieldsDisabledAfterAppConfigDumpTest.xml @@ -13,13 +13,14 @@ <features value="Configuration"/> <stories value="Disable configuration inputs"/> <title value="Check that all input fields disabled after executing CLI app:config:dump"/> - <description value="Check that all input fields disabled after executing CLI app:config:dump"/> + <description value="Check that all input fields disabled after executing CLI app:config:dump. Command app:config:dump is not reversible and magento instance stays configuration read only after this test. You need to restore etc/env.php manually to make magento configuration writable again."/> <severity value="MAJOR"/> <testCaseId value="MC-11158"/> <useCaseId value="MAGETWO-96428"/> <group value="configuration"/> </annotations> <before> + <!-- Command app:config:dump is not reversible and magento instance stays configuration read only after this test. You need to restore etc/env.php manually to make magento configuration writable again.--> <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> </before> <after> diff --git a/app/code/Magento/Shipping/view/adminhtml/templates/order/packaging/popup.phtml b/app/code/Magento/Shipping/view/adminhtml/templates/order/packaging/popup.phtml index 206deb0f5c795..6b188c21056e5 100644 --- a/app/code/Magento/Shipping/view/adminhtml/templates/order/packaging/popup.phtml +++ b/app/code/Magento/Shipping/view/adminhtml/templates/order/packaging/popup.phtml @@ -50,7 +50,11 @@ $girthEnabled = $block->isDisplayGirthValue() && $block->isGirthAllowed() ? 1 : } }); packaging.setItemQtyCallback(function(itemId){ - var item = $$('[name="shipment[items]['+itemId+']"]')[0]; + var item = $$('[name="shipment[items]['+itemId+']"]')[0], + itemTitle = $('order_item_' + itemId + '_title'); + if (!itemTitle && !item) { + return 0; + } if (item && !isNaN(item.value)) { return item.value; } diff --git a/app/code/Magento/Shipping/view/adminhtml/templates/order/packaging/popup_content.phtml b/app/code/Magento/Shipping/view/adminhtml/templates/order/packaging/popup_content.phtml index c3418049a38a0..71299b33ff159 100644 --- a/app/code/Magento/Shipping/view/adminhtml/templates/order/packaging/popup_content.phtml +++ b/app/code/Magento/Shipping/view/adminhtml/templates/order/packaging/popup_content.phtml @@ -227,8 +227,8 @@ <div class="grid_prepare admin__page-subsection"></div> </div> </section> - <?= /* @noEscape */ $secureRenderer->renderStyleAsTag('display:none', '#package_template') ?> <div id="packages_content"></div> + <?= /* @noEscape */ $secureRenderer->renderStyleAsTag('display:none', '#package_template') ?> <?php $scriptString = <<<script require(['jquery'], function($){ $("div#packages_content").on('click', "button[data-action='package-save-items']", diff --git a/app/code/Magento/Sitemap/Block/Robots.php b/app/code/Magento/Sitemap/Block/Robots.php index a074e95ce2f80..2fe7f8807d6a0 100644 --- a/app/code/Magento/Sitemap/Block/Robots.php +++ b/app/code/Magento/Sitemap/Block/Robots.php @@ -11,6 +11,7 @@ use Magento\Robots\Model\Config\Value; use Magento\Sitemap\Helper\Data as SitemapHelper; use Magento\Sitemap\Model\ResourceModel\Sitemap\CollectionFactory; +use Magento\Sitemap\Model\Sitemap; use Magento\Sitemap\Model\SitemapConfigReader; use Magento\Framework\App\ObjectManager; use Magento\Store\Model\StoreManagerInterface; @@ -115,6 +116,9 @@ protected function getSitemapLinks(array $storeIds) $collection->addStoreFilter($storeIds); $sitemapLinks = []; + /** + * @var Sitemap $sitemap + */ foreach ($collection as $sitemap) { $sitemapUrl = $sitemap->getSitemapUrl($sitemap->getSitemapPath(), $sitemap->getSitemapFilename()); $sitemapLinks[$sitemapUrl] = 'Sitemap: ' . $sitemapUrl; diff --git a/app/code/Magento/Sitemap/Model/Observer.php b/app/code/Magento/Sitemap/Model/Observer.php index ce74d738c4bc3..4333c71c7497f 100644 --- a/app/code/Magento/Sitemap/Model/Observer.php +++ b/app/code/Magento/Sitemap/Model/Observer.php @@ -3,11 +3,15 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); + namespace Magento\Sitemap\Model; -use Magento\Sitemap\Model\EmailNotification as SitemapEmail; +use Magento\Framework\App\Area; use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Sitemap\Model\EmailNotification as SitemapEmail; use Magento\Sitemap\Model\ResourceModel\Sitemap\CollectionFactory; +use Magento\Store\Model\App\Emulation; use Magento\Store\Model\ScopeInterface; /** @@ -61,20 +65,28 @@ class Observer */ private $emailNotification; + /** + * @var Emulation + */ + private $appEmulation; + /** * Observer constructor. * @param ScopeConfigInterface $scopeConfig * @param CollectionFactory $collectionFactory * @param EmailNotification $emailNotification + * @param Emulation $appEmulation */ public function __construct( ScopeConfigInterface $scopeConfig, CollectionFactory $collectionFactory, - SitemapEmail $emailNotification + SitemapEmail $emailNotification, + Emulation $appEmulation ) { $this->scopeConfig = $scopeConfig; $this->collectionFactory = $collectionFactory; $this->emailNotification = $emailNotification; + $this->appEmulation = $appEmulation; } /** @@ -105,9 +117,16 @@ public function scheduledGenerateSitemaps() foreach ($collection as $sitemap) { /* @var $sitemap \Magento\Sitemap\Model\Sitemap */ try { + $this->appEmulation->startEnvironmentEmulation( + $sitemap->getStoreId(), + Area::AREA_FRONTEND, + true + ); $sitemap->generateXml(); } catch (\Exception $e) { $errors[] = $e->getMessage(); + } finally { + $this->appEmulation->stopEnvironmentEmulation(); } } if ($errors && $recipient) { diff --git a/app/code/Magento/Sitemap/Model/Sitemap.php b/app/code/Magento/Sitemap/Model/Sitemap.php index 9a8d2c57a280c..ddb04f28d58d1 100644 --- a/app/code/Magento/Sitemap/Model/Sitemap.php +++ b/app/code/Magento/Sitemap/Model/Sitemap.php @@ -475,12 +475,9 @@ public function generateXml() if ($this->_sitemapIncrement == 1) { // In case when only one increment file was created use it as default sitemap - $path = rtrim( - $this->getSitemapPath(), - '/' - ) . '/' . $this->_getCurrentSitemapFilename( - $this->_sitemapIncrement - ); + $path = rtrim($this->getSitemapPath(), '/') + . '/' + . $this->_getCurrentSitemapFilename($this->_sitemapIncrement); $destination = rtrim($this->getSitemapPath(), '/') . '/' . $this->getSitemapFilename(); $this->_directory->renameFile($path, $destination); diff --git a/app/code/Magento/Sitemap/Test/Unit/Model/ItemProvider/ProductTest.php b/app/code/Magento/Sitemap/Test/Unit/Model/ItemProvider/ProductTest.php index 116a574b7c670..26f1f9cd6f56f 100644 --- a/app/code/Magento/Sitemap/Test/Unit/Model/ItemProvider/ProductTest.php +++ b/app/code/Magento/Sitemap/Test/Unit/Model/ItemProvider/ProductTest.php @@ -61,7 +61,7 @@ public function testGetItems(array $products) */ public function productProvider() { - $storeBaseMediaUrl = 'http://store.com/pub/media/catalog/product/cache/c9e0b0ef589f3508e5ba515cde53c5ff/'; + $storeBaseMediaUrl = 'http://store.com/media/catalog/product/cache/c9e0b0ef589f3508e5ba515cde53c5ff/'; return [ [ [ diff --git a/app/code/Magento/Sitemap/Test/Unit/Model/ObserverTest.php b/app/code/Magento/Sitemap/Test/Unit/Model/ObserverTest.php index 23ebe4f85f79e..70520862faee1 100644 --- a/app/code/Magento/Sitemap/Test/Unit/Model/ObserverTest.php +++ b/app/code/Magento/Sitemap/Test/Unit/Model/ObserverTest.php @@ -142,4 +142,39 @@ public function testScheduledGenerateSitemapsSendsExceptionEmail() $this->observer->scheduledGenerateSitemaps(); } + + /** + * Test if cron scheduled XML sitemap generation will start and stop the store environment emulation + * + * @throws \Exception + */ + public function testCronGenerateSitemapEnvironmentEmulation() + { + $storeId = 1; + + $this->scopeConfigMock->expects($this->once())->method('isSetFlag')->willReturn(true); + + $this->collectionFactoryMock->expects($this->once()) + ->method('create') + ->willReturn($this->sitemapCollectionMock); + + $this->sitemapCollectionMock->expects($this->any()) + ->method('getIterator') + ->willReturn(new \ArrayIterator([$this->sitemapMock])); + + $this->sitemapMock->expects($this->at(0)) + ->method('getStoreId') + ->willReturn($storeId); + + $this->sitemapMock->expects($this->once()) + ->method('generateXml'); + + $this->appEmulationMock->expects($this->once()) + ->method('startEnvironmentEmulation'); + + $this->appEmulationMock->expects($this->once()) + ->method('stopEnvironmentEmulation'); + + $this->observer->scheduledGenerateSitemaps(); + } } diff --git a/app/code/Magento/Sitemap/Test/Unit/Model/SitemapTest.php b/app/code/Magento/Sitemap/Test/Unit/Model/SitemapTest.php index bfd2c47164cf6..866b3afd322a0 100644 --- a/app/code/Magento/Sitemap/Test/Unit/Model/SitemapTest.php +++ b/app/code/Magento/Sitemap/Test/Unit/Model/SitemapTest.php @@ -533,7 +533,7 @@ protected function getModelMock($mockBeforeSave = false) $methods[] = 'beforeSave'; } - $storeBaseMediaUrl = 'http://store.com/pub/media/catalog/product/cache/c9e0b0ef589f3508e5ba515cde53c5ff/'; + $storeBaseMediaUrl = 'http://store.com/media/catalog/product/cache/c9e0b0ef589f3508e5ba515cde53c5ff/'; $this->itemProviderMock->expects($this->any()) ->method('getItems') diff --git a/app/code/Magento/Sitemap/Test/Unit/Model/_files/sitemap-1-4.xml b/app/code/Magento/Sitemap/Test/Unit/Model/_files/sitemap-1-4.xml index ff8087a52e42f..03cfdaaead18a 100644 --- a/app/code/Magento/Sitemap/Test/Unit/Model/_files/sitemap-1-4.xml +++ b/app/code/Magento/Sitemap/Test/Unit/Model/_files/sitemap-1-4.xml @@ -13,18 +13,18 @@ <changefreq>monthly</changefreq> <priority>0.5</priority> <image:image> - <image:loc>http://store.com/pub/media/catalog/product/cache/c9e0b0ef589f3508e5ba515cde53c5ff/i/m/image1.png</image:loc> + <image:loc>http://store.com/media/catalog/product/cache/c9e0b0ef589f3508e5ba515cde53c5ff/i/m/image1.png</image:loc> <image:title>Product & > title < "</image:title> <image:caption>Copyright © caption &trade; & > title < "</image:caption> </image:image> <image:image> - <image:loc>http://store.com/pub/media/catalog/product/cache/c9e0b0ef589f3508e5ba515cde53c5ff/i/m/image_no_caption.png</image:loc> + <image:loc>http://store.com/media/catalog/product/cache/c9e0b0ef589f3508e5ba515cde53c5ff/i/m/image_no_caption.png</image:loc> <image:title>Product & > title < "</image:title> </image:image> <PageMap xmlns="http://www.google.com/schemas/sitemap-pagemap/1.0"> <DataObject type="thumbnail"> <Attribute name="name" value="Product & > title < ""/> - <Attribute name="src" value="http://store.com/pub/media/catalog/product/cache/c9e0b0ef589f3508e5ba515cde53c5ff/t/h/thumbnail.jpg"/> + <Attribute name="src" value="http://store.com/media/catalog/product/cache/c9e0b0ef589f3508e5ba515cde53c5ff/t/h/thumbnail.jpg"/> </DataObject> </PageMap> </url> diff --git a/app/code/Magento/Sitemap/Test/Unit/Model/_files/sitemap-single.xml b/app/code/Magento/Sitemap/Test/Unit/Model/_files/sitemap-single.xml index 93b9e159d4b04..f9913d5070fbd 100644 --- a/app/code/Magento/Sitemap/Test/Unit/Model/_files/sitemap-single.xml +++ b/app/code/Magento/Sitemap/Test/Unit/Model/_files/sitemap-single.xml @@ -31,18 +31,18 @@ <changefreq>monthly</changefreq> <priority>0.5</priority> <image:image> - <image:loc>http://store.com/pub/media/catalog/product/cache/c9e0b0ef589f3508e5ba515cde53c5ff/i/m/image1.png</image:loc> + <image:loc>http://store.com/media/catalog/product/cache/c9e0b0ef589f3508e5ba515cde53c5ff/i/m/image1.png</image:loc> <image:title>Product & > title < "</image:title> <image:caption>Copyright © caption &trade; & > title < "</image:caption> </image:image> <image:image> - <image:loc>http://store.com/pub/media/catalog/product/cache/c9e0b0ef589f3508e5ba515cde53c5ff/i/m/image_no_caption.png</image:loc> + <image:loc>http://store.com/media/catalog/product/cache/c9e0b0ef589f3508e5ba515cde53c5ff/i/m/image_no_caption.png</image:loc> <image:title>Product & > title < "</image:title> </image:image> <PageMap xmlns="http://www.google.com/schemas/sitemap-pagemap/1.0"> <DataObject type="thumbnail"> <Attribute name="name" value="Product & > title < ""/> - <Attribute name="src" value="http://store.com/pub/media/catalog/product/cache/c9e0b0ef589f3508e5ba515cde53c5ff/t/h/thumbnail.jpg"/> + <Attribute name="src" value="http://store.com/media/catalog/product/cache/c9e0b0ef589f3508e5ba515cde53c5ff/t/h/thumbnail.jpg"/> </DataObject> </PageMap> </url> diff --git a/app/code/Magento/Sitemap/etc/config.xml b/app/code/Magento/Sitemap/etc/config.xml index 36b2cc2207422..614421b9dd752 100644 --- a/app/code/Magento/Sitemap/etc/config.xml +++ b/app/code/Magento/Sitemap/etc/config.xml @@ -57,5 +57,12 @@ </jobs> </default> </crontab> + <system> + <media_storage_configuration> + <allowed_resources> + <sitemap_folder>sitemap</sitemap_folder> + </allowed_resources> + </media_storage_configuration> + </system> </default> </config> diff --git a/app/code/Magento/Sitemap/etc/di.xml b/app/code/Magento/Sitemap/etc/di.xml index 4c4a5f98f737a..4771da2f11144 100644 --- a/app/code/Magento/Sitemap/etc/di.xml +++ b/app/code/Magento/Sitemap/etc/di.xml @@ -52,4 +52,16 @@ <argument name="configReader" xsi:type="object">Magento\Sitemap\Model\ItemProvider\CmsPageConfigReader</argument> </arguments> </type> + <type name="Magento\Cms\Model\Wysiwyg\Images\Storage"> + <arguments> + <argument name="dirs" xsi:type="array"> + <item name="exclude" xsi:type="array"> + <item name="sitemap" xsi:type="array"> + <item name="regexp" xsi:type="boolean">true</item> + <item name="name" xsi:type="string">media[/\\]+sitemap[/\\]*$</item> + </item> + </item> + </argument> + </arguments> + </type> </config> diff --git a/app/code/Magento/Store/Controller/Store/Redirect.php b/app/code/Magento/Store/Controller/Store/Redirect.php index 45924b5b0d28a..c20e3b31e09b1 100644 --- a/app/code/Magento/Store/Controller/Store/Redirect.php +++ b/app/code/Magento/Store/Controller/Store/Redirect.php @@ -21,10 +21,14 @@ use Magento\Store\Model\Store; use Magento\Store\Model\StoreManagerInterface; use Magento\Store\Model\StoreResolver; +use Magento\Store\Model\StoreSwitcher\ContextInterfaceFactory; use Magento\Store\Model\StoreSwitcher\HashGenerator; +use Magento\Store\Model\StoreSwitcher\RedirectDataGenerator; /** * Builds correct url to target store (group) and performs redirect. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class Redirect extends Action implements HttpGetActionInterface, HttpPostActionInterface { @@ -47,6 +51,14 @@ class Redirect extends Action implements HttpGetActionInterface, HttpPostActionI * @var StoreManagerInterface */ private $storeManager; + /** + * @var RedirectDataGenerator|null + */ + private $redirectDataGenerator; + /** + * @var ContextInterfaceFactory|null + */ + private $contextFactory; /** * @param Context $context @@ -55,8 +67,11 @@ class Redirect extends Action implements HttpGetActionInterface, HttpPostActionI * @param Generic $session * @param SidResolverInterface $sidResolver * @param HashGenerator $hashGenerator - * @param StoreManagerInterface $storeManager + * @param StoreManagerInterface|null $storeManager + * @param RedirectDataGenerator|null $redirectDataGenerator + * @param ContextInterfaceFactory|null $contextFactory * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( Context $context, @@ -65,13 +80,19 @@ public function __construct( Generic $session, SidResolverInterface $sidResolver, HashGenerator $hashGenerator, - StoreManagerInterface $storeManager = null + StoreManagerInterface $storeManager = null, + ?RedirectDataGenerator $redirectDataGenerator = null, + ?ContextInterfaceFactory $contextFactory = null ) { parent::__construct($context); $this->storeRepository = $storeRepository; $this->storeResolver = $storeResolver; $this->hashGenerator = $hashGenerator; $this->storeManager = $storeManager ?: ObjectManager::getInstance()->get(StoreManagerInterface::class); + $this->redirectDataGenerator = $redirectDataGenerator + ?: ObjectManager::getInstance()->get(RedirectDataGenerator::class); + $this->contextFactory = $contextFactory + ?: ObjectManager::getInstance()->get(ContextInterfaceFactory::class); } /** @@ -85,7 +106,6 @@ public function execute() $currentStore = $this->storeRepository->getById($this->storeResolver->getCurrentStoreId()); $targetStoreCode = $this->_request->getParam(StoreResolver::PARAM_NAME); $fromStoreCode = $this->_request->getParam('___from_store'); - $error = null; if ($targetStoreCode === null) { return $this->_redirect($currentStore->getBaseUrl()); @@ -97,30 +117,33 @@ public function execute() /** @var Store $targetStore */ $targetStore = $this->storeRepository->get($targetStoreCode); $this->storeManager->setCurrentStore($targetStore); - } catch (NoSuchEntityException $e) { - $error = __("Requested store is not found ({$fromStoreCode})"); - } - - if ($error !== null) { - $this->messageManager->addErrorMessage($error); - $this->_redirect->redirect($this->_response, $currentStore->getBaseUrl()); - } else { $encodedUrl = $this->_request->getParam(ActionInterface::PARAM_NAME_URL_ENCODED); + $redirectData = $this->redirectDataGenerator->generate( + $this->contextFactory->create( + [ + 'fromStore' => $fromStore, + 'targetStore' => $targetStore, + 'redirectUrl' => $this->_redirect->getRedirectUrl() + ] + ) + ); $query = [ '___from_store' => $fromStore->getCode(), StoreResolverInterface::PARAM_NAME => $targetStoreCode, ActionInterface::PARAM_NAME_URL_ENCODED => $encodedUrl, + 'data' => $redirectData->getData(), + 'time_stamp' => $redirectData->getTimestamp(), + 'signature' => $redirectData->getSignature(), ]; - - $customerHash = $this->hashGenerator->generateHash($fromStore); - $query = array_merge($query, $customerHash); - $arguments = [ '_nosid' => true, '_query' => $query ]; $this->_redirect->redirect($this->_response, 'stores/store/switch', $arguments); + } catch (NoSuchEntityException $e) { + $this->messageManager->addErrorMessage(__("Requested store is not found ({$fromStoreCode})")); + $this->_redirect->redirect($this->_response, $currentStore->getBaseUrl()); } return null; diff --git a/app/code/Magento/Store/Model/ScopeResolver.php b/app/code/Magento/Store/Model/ScopeResolver.php new file mode 100644 index 0000000000000..330ef29c8ac10 --- /dev/null +++ b/app/code/Magento/Store/Model/ScopeResolver.php @@ -0,0 +1,147 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Store\Model; + +use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Framework\App\ScopeTreeProviderInterface; + +/** + * Class used to check if some scope belongs to other scope + */ +class ScopeResolver +{ + /** + * @var ScopeTreeProviderInterface + */ + private $scopeTree; + + /** + * @param ScopeTreeProviderInterface $scopeTree + */ + public function __construct(ScopeTreeProviderInterface $scopeTree) + { + $this->scopeTree = $scopeTree; + } + + /** + * Check is some scope belongs to other scope + * + * @param string $baseScope + * @param int $baseScopeId + * @param string $requestedScope + * @param int $requestedScopeId + * @return bool + */ + public function isBelongsToScope( + string $baseScope, + int $baseScopeId, + string $requestedScope, + int $requestedScopeId + ) : bool { + /* All scopes belongs to All Store Views */ + if ($baseScope === ScopeConfigInterface::SCOPE_TYPE_DEFAULT) { + return true; + } + + $scopeNode = $this->getScopeNode($baseScope, $baseScopeId, [$this->scopeTree->get()]); + if (empty($scopeNode)) { + return false; + } + + return $this->isBelongsToScopeRecurse($requestedScope, $requestedScopeId, [$scopeNode]); + } + + /** + * Check is Belongs some scope to other scope (internal recurse) + * + * @param string $requestedScope + * @param int $requestedScopeId + * @param array $tree + * @return bool + */ + private function isBelongsToScopeRecurse( + string $requestedScope, + int $requestedScopeId, + array $tree + ) : bool { + foreach ($tree as $node) { + if ($this->isScopeEquals($node['scope'], $requestedScope) && (int)$node['scope_id'] === $requestedScopeId) { + return true; + } + if (!empty($node['scopes'])) { + $isBelongsToChild = $this->isBelongsToScopeRecurse( + $requestedScope, + $requestedScopeId, + $node['scopes'] + ); + if ($isBelongsToChild) { + return $isBelongsToChild; + } + } + } + + return false; + } + + /** + * Get tree by scope + * + * @param string $scope + * @param int $scopeId + * @param array $tree + * @return array + */ + private function getScopeNode(string $scope, int $scopeId, array $tree): array + { + foreach ($tree as $node) { + if ($this->isScopeEquals($node['scope'], $scope) && (int)$node['scope_id'] === $scopeId) { + return $node; + } + if (!empty($node['scopes'])) { + $found = $this->getScopeNode($scope, $scopeId, $node['scopes']); + if (!empty($found)) { + return $found; + } + } + } + + return []; + } + + /** + * Is scope equals with normalize names + * + * @param string $firstScope + * @param string $secondScope + * @return bool + */ + private function isScopeEquals(string $firstScope, string $secondScope): bool + { + return $this->normalizeScopeName($firstScope) === $this->normalizeScopeName($secondScope); + } + + /** + * Normalize scope name + * + * @param string $scope + * @return string + */ + private function normalizeScopeName(string $scope): string + { + switch ($scope) { + case ScopeInterface::SCOPE_STORES: + return ScopeInterface::SCOPE_STORE; + case ScopeInterface::SCOPE_WEBSITES: + return ScopeInterface::SCOPE_WEBSITE; + case ScopeInterface::SCOPE_GROUPS: + return ScopeInterface::SCOPE_GROUP; + default: + return $scope; + } + } +} diff --git a/app/code/Magento/Store/Model/StoreSwitcher/Context.php b/app/code/Magento/Store/Model/StoreSwitcher/Context.php new file mode 100644 index 0000000000000..c67dc3d67b01a --- /dev/null +++ b/app/code/Magento/Store/Model/StoreSwitcher/Context.php @@ -0,0 +1,68 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Store\Model\StoreSwitcher; + +use Magento\Store\Api\Data\StoreInterface; + +/** + * Store switcher context + */ +class Context implements ContextInterface +{ + /** + * @var StoreInterface + */ + private $fromStore; + /** + * @var StoreInterface + */ + private $targetStore; + /** + * @var string + */ + private $redirectUrl; + + /** + * @param StoreInterface $fromStore + * @param StoreInterface $targetStore + * @param string $redirectUrl + */ + public function __construct( + StoreInterface $fromStore, + StoreInterface $targetStore, + string $redirectUrl + ) { + $this->fromStore = $fromStore; + $this->targetStore = $targetStore; + $this->redirectUrl = $redirectUrl; + } + + /** + * @inheritDoc + */ + public function getFromStore(): StoreInterface + { + return $this->fromStore; + } + + /** + * @inheritDoc + */ + public function getTargetStore(): StoreInterface + { + return $this->targetStore; + } + + /** + * @inheritDoc + */ + public function getRedirectUrl(): string + { + return $this->redirectUrl; + } +} diff --git a/app/code/Magento/Store/Model/StoreSwitcher/ContextInterface.php b/app/code/Magento/Store/Model/StoreSwitcher/ContextInterface.php new file mode 100644 index 0000000000000..a18c7cc9ccc27 --- /dev/null +++ b/app/code/Magento/Store/Model/StoreSwitcher/ContextInterface.php @@ -0,0 +1,37 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Store\Model\StoreSwitcher; + +use Magento\Store\Api\Data\StoreInterface; + +/** + * Store switcher context interface + */ +interface ContextInterface +{ + /** + * Store to switch from + * + * @return StoreInterface + */ + public function getFromStore(): StoreInterface; + + /** + * Store to switch to + * + * @return StoreInterface + */ + public function getTargetStore(): StoreInterface; + + /** + * The URL to redirect after switching store + * + * @return string + */ + public function getRedirectUrl(): string; +} diff --git a/app/code/Magento/Store/Model/StoreSwitcher/HashGenerator.php b/app/code/Magento/Store/Model/StoreSwitcher/HashGenerator.php index d1858939434b7..3c2320df2ed1a 100644 --- a/app/code/Magento/Store/Model/StoreSwitcher/HashGenerator.php +++ b/app/code/Magento/Store/Model/StoreSwitcher/HashGenerator.php @@ -8,7 +8,6 @@ namespace Magento\Store\Model\StoreSwitcher; use Magento\Authorization\Model\UserContextInterface; -use Magento\Framework\App\ActionInterface; use Magento\Framework\App\DeploymentConfig as DeploymentConfig; use Magento\Framework\Config\ConfigOptionsListConstants; use Magento\Framework\Url\Helper\Data as UrlHelper; @@ -17,6 +16,10 @@ /** * Generate one time token and build redirect url + * + * @deplacated No longer used + * @see RedirectDataGenerator + * @see RedirectDataValidator */ class HashGenerator { diff --git a/app/code/Magento/Store/Model/StoreSwitcher/HashProcessor.php b/app/code/Magento/Store/Model/StoreSwitcher/HashProcessor.php index 909fe9f6683f8..45e93a5af06de 100644 --- a/app/code/Magento/Store/Model/StoreSwitcher/HashProcessor.php +++ b/app/code/Magento/Store/Model/StoreSwitcher/HashProcessor.php @@ -7,71 +7,82 @@ namespace Magento\Store\Model\StoreSwitcher; -use Magento\Authorization\Model\UserContextInterface; -use Magento\Customer\Api\CustomerRepositoryInterface; -use Magento\Customer\Model\ResourceModel\CustomerRepository; -use Magento\Customer\Model\Session as CustomerSession; -use Magento\Framework\App\DeploymentConfig as DeploymentConfig; use Magento\Framework\App\RequestInterface; use Magento\Framework\Exception\LocalizedException; -use Magento\Framework\Exception\NoSuchEntityException; use Magento\Framework\Message\ManagerInterface; -use Magento\Framework\Url\Helper\Data as UrlHelper; use Magento\Store\Api\Data\StoreInterface; -use Magento\Store\Model\StoreSwitcher\HashGenerator\HashData; use Magento\Store\Model\StoreSwitcherInterface; +use Psr\Log\LoggerInterface; /** * Process one time token and build redirect url * * @SuppressWarnings(PHPMD.CookieAndSessionMisuse) + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class HashProcessor implements StoreSwitcherInterface { - /** - * @var HashGenerator - */ - private $hashGenerator; - /** * @var RequestInterface */ private $request; - + /** + * @var RedirectDataPostprocessorInterface + */ + private $postprocessor; + /** + * @var RedirectDataSerializerInterface + */ + private $dataSerializer; /** * @var ManagerInterface */ private $messageManager; - /** - * @var customerSession + * @var RedirectDataInterfaceFactory */ - private $customerSession; - + private $dataFactory; /** - * @var CustomerRepositoryInterface + * @var ContextInterfaceFactory */ - private $customerRepository; + private $contextFactory; + /** + * @var RedirectDataValidator + */ + private $dataValidator; + /** + * @var LoggerInterface + */ + private $logger; /** - * @param HashGenerator $hashGenerator * @param RequestInterface $request + * @param RedirectDataPostprocessorInterface $postprocessor + * @param RedirectDataSerializerInterface $dataSerializer * @param ManagerInterface $messageManager - * @param CustomerRepository $customerRepository - * @param CustomerSession $customerSession + * @param ContextInterfaceFactory $contextFactory + * @param RedirectDataInterfaceFactory $dataFactory + * @param RedirectDataValidator $dataValidator + * @param LoggerInterface $logger */ public function __construct( - HashGenerator $hashGenerator, RequestInterface $request, + RedirectDataPostprocessorInterface $postprocessor, + RedirectDataSerializerInterface $dataSerializer, ManagerInterface $messageManager, - CustomerRepository $customerRepository, - CustomerSession $customerSession + ContextInterfaceFactory $contextFactory, + RedirectDataInterfaceFactory $dataFactory, + RedirectDataValidator $dataValidator, + LoggerInterface $logger ) { - $this->hashGenerator = $hashGenerator; $this->request = $request; + $this->postprocessor = $postprocessor; + $this->dataSerializer = $dataSerializer; $this->messageManager = $messageManager; - $this->customerSession = $customerSession; - $this->customerRepository = $customerRepository; + $this->contextFactory = $contextFactory; + $this->dataFactory = $dataFactory; + $this->dataValidator = $dataValidator; + $this->logger = $logger; } /** @@ -85,41 +96,39 @@ public function __construct( */ public function switch(StoreInterface $fromStore, StoreInterface $targetStore, string $redirectUrl): string { - $customerId = $this->request->getParam('customer_id'); - - if ($customerId) { - $fromStoreCode = (string)$this->request->getParam('___from_store'); - $timeStamp = (string)$this->request->getParam('time_stamp'); - $signature = (string)$this->request->getParam('signature'); - - $error = null; + $timestamp = (int) $this->request->getParam('time_stamp'); + $signature = (string) $this->request->getParam('signature'); + $data = (string) $this->request->getParam('data'); + $context = $this->contextFactory->create( + [ + 'fromStore' => $fromStore, + 'targetStore' => $targetStore, + 'redirectUrl' => $redirectUrl + ] + ); + $redirectDataObject = $this->dataFactory->create( + [ + 'signature' => $signature, + 'timestamp' => $timestamp, + 'data' => $data + ] + ); - $data = new HashData( - [ - "customer_id" => $customerId, - "time_stamp" => $timeStamp, - "___from_store" => $fromStoreCode - ] - ); - - if ($redirectUrl && $this->hashGenerator->validateHash($signature, $data)) { - try { - $customer = $this->customerRepository->getById($customerId); - if (!$this->customerSession->isLoggedIn()) { - $this->customerSession->setCustomerDataAsLoggedIn($customer); - } - } catch (NoSuchEntityException $e) { - $error = __('The requested customer does not exist.'); - } catch (LocalizedException $e) { - $error = __('There was an error retrieving the customer record.'); - } + try { + if ($redirectUrl && $this->dataValidator->validate($context, $redirectDataObject)) { + $this->postprocessor->process($context, $this->dataSerializer->unserialize($data)); } else { - $error = __('The requested store cannot be found. Please check the request and try again.'); - } - - if ($error !== null) { - $this->messageManager->addErrorMessage($error); + throw new LocalizedException( + __('The requested store cannot be found. Please check the request and try again.') + ); } + } catch (LocalizedException $exception) { + $this->messageManager->addErrorMessage($exception->getMessage()); + } catch (\Throwable $exception) { + $this->logger->error($exception); + $this->messageManager->addErrorMessage( + __('Something went wrong.') + ); } return $redirectUrl; diff --git a/app/code/Magento/Store/Model/StoreSwitcher/RedirectData.php b/app/code/Magento/Store/Model/StoreSwitcher/RedirectData.php new file mode 100644 index 0000000000000..58185ea3d712a --- /dev/null +++ b/app/code/Magento/Store/Model/StoreSwitcher/RedirectData.php @@ -0,0 +1,66 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Store\Model\StoreSwitcher; + +/** + * Store switcher redirect data + */ +class RedirectData implements RedirectDataInterface +{ + /** + * @var string + */ + private $signature; + /** + * @var string + */ + private $data; + /** + * @var int + */ + private $timestamp; + + /** + * @param string $signature + * @param string $data + * @param int $timestamp + */ + public function __construct( + string $signature, + string $data, + int $timestamp + ) { + $this->signature = $signature; + $this->data = $data; + $this->timestamp = $timestamp; + } + + /** + * @inheritDoc + */ + public function getSignature(): string + { + return $this->signature; + } + + /** + * @inheritDoc + */ + public function getData(): string + { + return $this->data; + } + + /** + * @inheritDoc + */ + public function getTimestamp(): int + { + return $this->timestamp; + } +} diff --git a/app/code/Magento/Store/Model/StoreSwitcher/RedirectDataCacheSerializer.php b/app/code/Magento/Store/Model/StoreSwitcher/RedirectDataCacheSerializer.php new file mode 100644 index 0000000000000..5360d403d1388 --- /dev/null +++ b/app/code/Magento/Store/Model/StoreSwitcher/RedirectDataCacheSerializer.php @@ -0,0 +1,96 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Store\Model\StoreSwitcher; + +use InvalidArgumentException; +use Magento\Framework\App\CacheInterface; +use Magento\Framework\Math\Random; +use Magento\Framework\Serialize\Serializer\Json; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Store switcher redirect data cache serializer + */ +class RedirectDataCacheSerializer implements RedirectDataSerializerInterface +{ + private const CACHE_KEY_PREFIX = 'store_switch_'; + private const CACHE_LIFE_TIME = 10; + private const CACHE_ID_LENGTH = 32; + + /** + * @var CacheInterface + */ + private $cache; + /** + * @var Json + */ + private $json; + /** + * @var Random + */ + private $random; + /** + * @var LoggerInterface + */ + private $logger; + + /** + * @param Json $json + * @param Random $random + * @param CacheInterface $cache + * @param LoggerInterface $logger + */ + public function __construct( + Json $json, + Random $random, + CacheInterface $cache, + LoggerInterface $logger + ) { + $this->cache = $cache; + $this->json = $json; + $this->random = $random; + $this->logger = $logger; + } + + /** + * @inheritDoc + */ + public function serialize(array $data): string + { + $token = $this->random->getRandomString(self::CACHE_ID_LENGTH); + $cacheKey = self::CACHE_KEY_PREFIX . $token; + $this->cache->save($this->json->serialize($data), $cacheKey, [], self::CACHE_LIFE_TIME); + + return $token; + } + + /** + * @inheritDoc + */ + public function unserialize(string $data): array + { + if (strlen($data) !== self::CACHE_ID_LENGTH) { + throw new InvalidArgumentException("Invalid cache key '$data' supplied."); + } + + $cacheKey = self::CACHE_KEY_PREFIX . $data; + $json = $this->cache->load($cacheKey); + if (!$json) { + throw new InvalidArgumentException('Couldn\'t retrieve data from cache.'); + } + $result = $this->json->unserialize($json); + try { + $this->cache->remove($cacheKey); + } catch (Throwable $exception) { + $this->logger->error($exception); + } + + return $result; + } +} diff --git a/app/code/Magento/Store/Model/StoreSwitcher/RedirectDataGenerator.php b/app/code/Magento/Store/Model/StoreSwitcher/RedirectDataGenerator.php new file mode 100644 index 0000000000000..3ff0375a0c348 --- /dev/null +++ b/app/code/Magento/Store/Model/StoreSwitcher/RedirectDataGenerator.php @@ -0,0 +1,95 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Store\Model\StoreSwitcher; + +use Magento\Framework\Encryption\Encryptor; +use Psr\Log\LoggerInterface; + +/** + * Store switcher redirect data collector + */ +class RedirectDataGenerator +{ + /** + * @var RedirectDataPreprocessorInterface + */ + private $preprocessor; + /** + * @var RedirectDataSerializerInterface + */ + private $dataSerializer; + /** + * @var RedirectDataInterfaceFactory + */ + private $dataFactory; + /** + * @var Encryptor + */ + private $encryptor; + /** + * @var LoggerInterface + */ + private $logger; + + /** + * @param Encryptor $encryptor + * @param RedirectDataPreprocessorInterface $preprocessor + * @param RedirectDataSerializerInterface $dataSerializer + * @param RedirectDataInterfaceFactory $dataFactory + * @param LoggerInterface $logger + */ + public function __construct( + Encryptor $encryptor, + RedirectDataPreprocessorInterface $preprocessor, + RedirectDataSerializerInterface $dataSerializer, + RedirectDataInterfaceFactory $dataFactory, + LoggerInterface $logger + ) { + $this->preprocessor = $preprocessor; + $this->dataSerializer = $dataSerializer; + $this->dataFactory = $dataFactory; + $this->encryptor = $encryptor; + $this->logger = $logger; + } + + /** + * Collect data to be redirected to the target store + * + * @param ContextInterface $context + * @return RedirectDataInterface + */ + public function generate(ContextInterface $context): RedirectDataInterface + { + $data = $this->preprocessor->process($context, []); + try { + $dataStr = $this->dataSerializer->serialize($data); + } catch (\Throwable $exception) { + $this->logger->error($exception); + $dataStr = ''; + } + $timestamp = time(); + $token = implode( + ',', + [ + $dataStr, + $timestamp, + $context->getFromStore()->getCode(), + $context->getTargetStore()->getCode(), + ] + ); + $signature = $this->encryptor->hash($token, Encryptor::HASH_VERSION_SHA256); + + return $this->dataFactory->create( + [ + 'data' => $dataStr, + 'timestamp' => $timestamp, + 'signature' => $signature + ] + ); + } +} diff --git a/app/code/Magento/Store/Model/StoreSwitcher/RedirectDataInterface.php b/app/code/Magento/Store/Model/StoreSwitcher/RedirectDataInterface.php new file mode 100644 index 0000000000000..f7fc066634b63 --- /dev/null +++ b/app/code/Magento/Store/Model/StoreSwitcher/RedirectDataInterface.php @@ -0,0 +1,35 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Store\Model\StoreSwitcher; + +/** + * Store switcher redirect data interface + */ +interface RedirectDataInterface +{ + /** + * Redirect data signature + * + * @return string + */ + public function getSignature(): string; + + /** + * Data to redirect from store to store + * + * @return string + */ + public function getData(): string; + + /** + * Expire date of the redirect data + * + * @return int + */ + public function getTimestamp(): int; +} diff --git a/app/code/Magento/Store/Model/StoreSwitcher/RedirectDataPostprocessorComposite.php b/app/code/Magento/Store/Model/StoreSwitcher/RedirectDataPostprocessorComposite.php new file mode 100644 index 0000000000000..579ab80f31897 --- /dev/null +++ b/app/code/Magento/Store/Model/StoreSwitcher/RedirectDataPostprocessorComposite.php @@ -0,0 +1,37 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Store\Model\StoreSwitcher; + +/** + * Store switcher redirect data post-processors collection + */ +class RedirectDataPostprocessorComposite implements RedirectDataPostprocessorInterface +{ + /** + * @var RedirectDataPostprocessorInterface[] + */ + private $processors; + + /** + * @param RedirectDataPostprocessorInterface[] $processors + */ + public function __construct(array $processors = []) + { + $this->processors = $processors; + } + + /** + * @inheritdoc + */ + public function process(ContextInterface $context, array $data): void + { + foreach ($this->processors as $processor) { + $processor->process($context, $data); + } + } +} diff --git a/app/code/Magento/Store/Model/StoreSwitcher/RedirectDataPostprocessorInterface.php b/app/code/Magento/Store/Model/StoreSwitcher/RedirectDataPostprocessorInterface.php new file mode 100644 index 0000000000000..de117915e23da --- /dev/null +++ b/app/code/Magento/Store/Model/StoreSwitcher/RedirectDataPostprocessorInterface.php @@ -0,0 +1,22 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Store\Model\StoreSwitcher; + +/** + * Store switcher redirect data post-processor interface + */ +interface RedirectDataPostprocessorInterface +{ + /** + * Process data redirected from origin source + * + * @param ContextInterface $context + * @param array $data + */ + public function process(ContextInterface $context, array $data): void; +} diff --git a/app/code/Magento/Store/Model/StoreSwitcher/RedirectDataPreprocessorComposite.php b/app/code/Magento/Store/Model/StoreSwitcher/RedirectDataPreprocessorComposite.php new file mode 100644 index 0000000000000..4b93df1cdc677 --- /dev/null +++ b/app/code/Magento/Store/Model/StoreSwitcher/RedirectDataPreprocessorComposite.php @@ -0,0 +1,39 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Store\Model\StoreSwitcher; + +/** + * Store switcher redirect data pre-processors collection + */ +class RedirectDataPreprocessorComposite implements RedirectDataPreprocessorInterface +{ + /** + * @var RedirectDataPreprocessorInterface[] + */ + private $processors; + + /** + * @param RedirectDataPreprocessorInterface[] $processors + */ + public function __construct(array $processors = []) + { + $this->processors = $processors; + } + + /** + * @inheritdoc + */ + public function process(ContextInterface $context, array $data): array + { + foreach ($this->processors as $processor) { + $data = $processor->process($context, $data); + } + + return $data; + } +} diff --git a/app/code/Magento/Store/Model/StoreSwitcher/RedirectDataPreprocessorInterface.php b/app/code/Magento/Store/Model/StoreSwitcher/RedirectDataPreprocessorInterface.php new file mode 100644 index 0000000000000..d28a7dd776ab7 --- /dev/null +++ b/app/code/Magento/Store/Model/StoreSwitcher/RedirectDataPreprocessorInterface.php @@ -0,0 +1,23 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Store\Model\StoreSwitcher; + +/** + * Store switcher redirect data pre-processor interface + */ +interface RedirectDataPreprocessorInterface +{ + /** + * Collect data to be redirected to target store + * + * @param ContextInterface $context + * @param array $data + * @return array + */ + public function process(ContextInterface $context, array $data): array; +} diff --git a/app/code/Magento/Store/Model/StoreSwitcher/RedirectDataSerializerInterface.php b/app/code/Magento/Store/Model/StoreSwitcher/RedirectDataSerializerInterface.php new file mode 100644 index 0000000000000..0f7cde4d94ccd --- /dev/null +++ b/app/code/Magento/Store/Model/StoreSwitcher/RedirectDataSerializerInterface.php @@ -0,0 +1,30 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Store\Model\StoreSwitcher; + +/** + * Store switcher redirect data serializer interface + */ +interface RedirectDataSerializerInterface +{ + /** + * Serialize provided data and return the serialized data + * + * @param array $data + * @return string + */ + public function serialize(array $data): string; + + /** + * Unserialize provided data and return the unserialized data + * + * @param string $data + * @return array + */ + public function unserialize(string $data): array; +} diff --git a/app/code/Magento/Store/Model/StoreSwitcher/RedirectDataValidator.php b/app/code/Magento/Store/Model/StoreSwitcher/RedirectDataValidator.php new file mode 100644 index 0000000000000..9200e80cae05c --- /dev/null +++ b/app/code/Magento/Store/Model/StoreSwitcher/RedirectDataValidator.php @@ -0,0 +1,55 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Store\Model\StoreSwitcher; + +use Magento\Framework\Encryption\Encryptor; + +/** + * Store switcher redirect data validator + */ +class RedirectDataValidator +{ + private const TIMEOUT = 5; + /** + * @var Encryptor + */ + private $encryptor; + + /** + * @param Encryptor $encryptor + */ + public function __construct( + Encryptor $encryptor + ) { + $this->encryptor = $encryptor; + } + + /** + * Validate data redirected from origin store + * + * @param ContextInterface $context + * @param RedirectDataInterface $redirectData + * @return bool + */ + public function validate(ContextInterface $context, RedirectDataInterface $redirectData) + { + $timeStamp = $redirectData->getTimestamp(); + $signature = $redirectData->getSignature(); + $value = implode( + ',', + [ + $redirectData->getData(), + $timeStamp, + $context->getFromStore()->getCode(), + $context->getTargetStore()->getCode() + ] + ); + return time() - $timeStamp <= self::TIMEOUT + && $this->encryptor->validateHash($value, $signature); + } +} diff --git a/app/code/Magento/Store/Test/Mftf/ActionGroup/AdminOpenFirstRowInStoresGridActionGroup.xml b/app/code/Magento/Store/Test/Mftf/ActionGroup/AdminOpenFirstRowInStoresGridActionGroup.xml new file mode 100644 index 0000000000000..a3be7b0d8a8c4 --- /dev/null +++ b/app/code/Magento/Store/Test/Mftf/ActionGroup/AdminOpenFirstRowInStoresGridActionGroup.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminOpenFirstRowInStoresGridActionGroup"> + + <click selector="{{AdminStoresGridSection.firstRow}}" stepKey="clickFirstRow"/> + <waitForPageLoad stepKey="AdminSystemStoreGroupPageToOpen"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Store/Test/Mftf/ActionGroup/AdminOpenStoreInFirstRowInStoresGridActionGroup.xml b/app/code/Magento/Store/Test/Mftf/ActionGroup/AdminOpenStoreInFirstRowInStoresGridActionGroup.xml new file mode 100644 index 0000000000000..6af4a4f159a7e --- /dev/null +++ b/app/code/Magento/Store/Test/Mftf/ActionGroup/AdminOpenStoreInFirstRowInStoresGridActionGroup.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminOpenStoreInFirstRowInStoresGridActionGroup"> + + <click selector="{{AdminStoresGridSection.storeNameInFirstRow}}" stepKey="clickStoreViewFirstRowInGrid"/> + <waitForPageLoad stepKey="waitForAdminSystemStoreViewPageLoad"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Store/Test/Mftf/Test/AdminUpdateStoreGroupAcceptAlertAndVerifyStoreViewFormTest.xml b/app/code/Magento/Store/Test/Mftf/Test/AdminUpdateStoreGroupAcceptAlertAndVerifyStoreViewFormTest.xml index 09a33d5eb86a6..40a912617ee0b 100644 --- a/app/code/Magento/Store/Test/Mftf/Test/AdminUpdateStoreGroupAcceptAlertAndVerifyStoreViewFormTest.xml +++ b/app/code/Magento/Store/Test/Mftf/Test/AdminUpdateStoreGroupAcceptAlertAndVerifyStoreViewFormTest.xml @@ -51,8 +51,8 @@ <actionGroup ref="AssertStoreGroupInGridActionGroup" stepKey="openCreatedStoreGroupInGrid"> <argument name="storeGroupName" value="{{staticStoreGroup.name}}"/> </actionGroup> - <click selector="{{AdminStoresGridSection.firstRow}}" stepKey="clickFirstRow"/> - <waitForPageLoad stepKey="AdminSystemStoreGroupPageToOpen"/> + <actionGroup ref="AdminOpenFirstRowInStoresGridActionGroup" stepKey="clickFirstRow"/> + <!--Update created Store group as per requirement and accept alert message--> <actionGroup ref="EditCustomStoreGroupAcceptWarningMessageActionGroup" stepKey="updateCustomStoreGroup"> <argument name="website" value="{{customWebsite.name}}"/> diff --git a/app/code/Magento/Store/Test/Mftf/Test/AdminUpdateStoreGroupAndVerifyStoreViewFormTest.xml b/app/code/Magento/Store/Test/Mftf/Test/AdminUpdateStoreGroupAndVerifyStoreViewFormTest.xml index 1c5d58c13538e..02125aab26496 100644 --- a/app/code/Magento/Store/Test/Mftf/Test/AdminUpdateStoreGroupAndVerifyStoreViewFormTest.xml +++ b/app/code/Magento/Store/Test/Mftf/Test/AdminUpdateStoreGroupAndVerifyStoreViewFormTest.xml @@ -41,8 +41,8 @@ <actionGroup ref="AssertStoreGroupInGridActionGroup" stepKey="openCreatedStoreGroupInGrid"> <argument name="storeGroupName" value="{{SecondStoreGroupUnique.name}}"/> </actionGroup> - <click selector="{{AdminStoresGridSection.firstRow}}" stepKey="clickFirstRow"/> - <waitForPageLoad stepKey="AdminSystemStoreGroupPageToOpen"/> + <actionGroup ref="AdminOpenFirstRowInStoresGridActionGroup" stepKey="clickFirstRow"/> + <!--Update created Store group as per requirement--> <actionGroup ref="CreateCustomStoreActionGroup" stepKey="createNewCustomStoreGroup"> <argument name="website" value="{{_defaultWebsite.name}}"/> diff --git a/app/code/Magento/Store/Test/Mftf/Test/AdminUpdateStoreViewTest.xml b/app/code/Magento/Store/Test/Mftf/Test/AdminUpdateStoreViewTest.xml index c7c846c51af4d..b4aac676f2bc9 100644 --- a/app/code/Magento/Store/Test/Mftf/Test/AdminUpdateStoreViewTest.xml +++ b/app/code/Magento/Store/Test/Mftf/Test/AdminUpdateStoreViewTest.xml @@ -39,8 +39,8 @@ <actionGroup ref="AssertStoreViewInGridActionGroup" stepKey="searchCreatedStoreViewInGrid"> <argument name="storeViewName" value="{{storeViewData.name}}"/> </actionGroup> - <click selector="{{AdminStoresGridSection.storeNameInFirstRow}}" stepKey="clickStoreViewFirstRowInGrid"/> - <waitForPageLoad stepKey="waitForAdminSystemStoreViewPageLoad"/> + <actionGroup ref="AdminOpenStoreInFirstRowInStoresGridActionGroup" stepKey="clickStoreViewFirstRowInGrid"/> + <!--Update created store view as per requirements--> <actionGroup ref="AdminCreateStoreViewActionGroup" stepKey="updateStoreView"> <argument name="StoreGroup" value="_defaultStoreGroup"/> diff --git a/app/code/Magento/Store/Test/Mftf/Test/StorefrontAddStoreCodeInUrlTest.xml b/app/code/Magento/Store/Test/Mftf/Test/StorefrontAddStoreCodeInUrlTest.xml index eaebc7fdaf74a..fc17bc7f0f10a 100644 --- a/app/code/Magento/Store/Test/Mftf/Test/StorefrontAddStoreCodeInUrlTest.xml +++ b/app/code/Magento/Store/Test/Mftf/Test/StorefrontAddStoreCodeInUrlTest.xml @@ -21,6 +21,9 @@ </annotations> <before> <magentoCLI command="config:set {{StorefrontEnableAddStoreCodeToUrls.path}} {{StorefrontEnableAddStoreCodeToUrls.value}}" stepKey="addStoreCodeToUrlEnable"/> + <actionGroup ref="CliCacheFlushActionGroup" stepKey="flushPageCache"> + <argument name="tags" value="full_page"/> + </actionGroup> </before> <after> <magentoCLI command="config:set {{StorefrontDisableAddStoreCodeToUrls.path}} {{StorefrontDisableAddStoreCodeToUrls.value}}" stepKey="addStoreCodeToUrlDisable"/> diff --git a/app/code/Magento/Store/Test/Unit/Controller/Store/RedirectTest.php b/app/code/Magento/Store/Test/Unit/Controller/Store/RedirectTest.php index 91fff641338db..7d873ee6c1d8e 100755 --- a/app/code/Magento/Store/Test/Unit/Controller/Store/RedirectTest.php +++ b/app/code/Magento/Store/Test/Unit/Controller/Store/RedirectTest.php @@ -22,7 +22,10 @@ use Magento\Store\Model\Store; use Magento\Store\Model\StoreManagerInterface; use Magento\Store\Model\StoreResolver; +use Magento\Store\Model\StoreSwitcher\ContextInterface; +use Magento\Store\Model\StoreSwitcher\ContextInterfaceFactory; use Magento\Store\Model\StoreSwitcher\HashGenerator; +use Magento\Store\Model\StoreSwitcher\RedirectDataGenerator; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -163,6 +166,11 @@ protected function setUp(): void ->method('getCurrentStoreId') ->willReturnSelf(); + $redirectDataGenerator = $this->createMock(RedirectDataGenerator::class); + $contextFactory = $this->createMock(ContextInterfaceFactory::class); + $contextFactory->method('create') + ->willReturn($this->createMock(ContextInterface::class)); + $objectManager = new ObjectManagerHelper($this); $context = $objectManager->getObject( Context::class, @@ -182,6 +190,8 @@ protected function setUp(): void 'sidResolver' => $this->sidResolverMock, 'hashGenerator' => $this->hashGeneratorMock, 'context' => $context, + 'redirectDataGenerator' => $redirectDataGenerator, + 'contextFactory' => $contextFactory, ] ); } @@ -220,11 +230,6 @@ public function testRedirect(string $defaultStoreViewCode, string $storeCode): v ->expects($this->once()) ->method('getCode') ->willReturn($defaultStoreViewCode); - $this->hashGeneratorMock - ->expects($this->once()) - ->method('generateHash') - ->with($this->fromStoreMock) - ->willReturn([]); $this->storeManagerMock ->expects($this->once()) ->method('setCurrentStore') @@ -239,7 +244,10 @@ public function testRedirect(string $defaultStoreViewCode, string $storeCode): v '_query' => [ 'uenc' => $defaultStoreViewCode, '___from_store' => $defaultStoreViewCode, - '___store' => $storeCode + '___store' => $storeCode, + 'data' => '', + 'time_stamp' => 0, + 'signature' => '', ] ] ); diff --git a/app/code/Magento/Store/Test/Unit/Model/ScopeResolverTest.php b/app/code/Magento/Store/Test/Unit/Model/ScopeResolverTest.php new file mode 100644 index 0000000000000..d93c08eb3b27f --- /dev/null +++ b/app/code/Magento/Store/Test/Unit/Model/ScopeResolverTest.php @@ -0,0 +1,178 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Store\Test\Unit\Model; + +use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Framework\App\ScopeTreeProviderInterface; +use Magento\Store\Model\ScopeInterface; +use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\MockObject\MockObject; +use Magento\Store\Model\ScopeResolver; + +/** + * Test for ScopeResolver + */ +class ScopeResolverTest extends TestCase +{ + /** + * @var ScopeTreeProviderInterface|MockObject + */ + private $scopeTreeMock; + + /** + * @var ScopeResolver + */ + private $scopeResolver; + + /** + * @inheritdoc + */ + protected function setUp(): void + { + $this->scopeTreeMock = $this->getMockBuilder(ScopeTreeProviderInterface::class) + ->getMockForAbstractClass(); + $this->scopeResolver = new ScopeResolver($this->scopeTreeMock); + } + + /** + * Check is some scope belongs to other scope + * + * @param string $baseScope + * @param int $baseScopeId + * @param string $requestedScope + * @param int $requestedScopeId + * @param bool $isBelong + * @dataProvider testIsBelongsToScopeDataProvider + */ + public function testIsBelongsToScope( + string $baseScope, + int $baseScopeId, + string $requestedScope, + int $requestedScopeId, + bool $isBelong + ) { + $this->scopeTreeMock->expects($this->any()) + ->method('get') + ->willReturn( + $this->getTree() + ); + $this->assertEquals( + $isBelong, + $this->scopeResolver->isBelongsToScope($baseScope, $baseScopeId, $requestedScope, $requestedScopeId) + ); + } + + /** + * Data provider for testIsBelongsToScope + * + * @return array[] + */ + public function testIsBelongsToScopeDataProvider() + { + return [ + 'All scopes belongs to Default' => [ + 'baseScope' => ScopeConfigInterface::SCOPE_TYPE_DEFAULT, + 'baseScopeId' => 0, + 'requestedScope' => ScopeInterface::SCOPE_WEBSITE, + 'requestedScopeId' => 1, + 'isBelong' => true + ], + 'Store group belongs to website' => [ + 'baseScope' => ScopeInterface::SCOPE_WEBSITE, + 'baseScopeId' => 1, + 'requestedScope' => ScopeInterface::SCOPE_GROUP, + 'requestedScopeId' => 1, + 'isBelong' => true + ], + 'Store belongs to store group' => [ + 'baseScope' => ScopeInterface::SCOPE_GROUP, + 'baseScopeId' => 1, + 'requestedScope' => ScopeInterface::SCOPE_STORE, + 'requestedScopeId' => 1, + 'isBelong' => true + ], + 'Store belongs to website' => [ + 'baseScope' => ScopeInterface::SCOPE_WEBSITE, + 'baseScopeId' => 1, + 'requestedScope' => ScopeInterface::SCOPE_STORE, + 'requestedScopeId' => 1, + 'isBelong' => true + ], + 'Store group not belongs to website' => [ + 'baseScope' => ScopeInterface::SCOPE_WEBSITE, + 'baseScopeId' => 1, + 'requestedScope' => ScopeInterface::SCOPE_GROUP, + 'requestedScopeId' => 2, + 'isBelong' => false + ], + 'Store not belongs to store group' => [ + 'baseScope' => ScopeInterface::SCOPE_GROUP, + 'baseScopeId' => 1, + 'requestedScope' => ScopeInterface::SCOPE_STORE, + 'requestedScopeId' => 2, + 'isBelong' => false + ], + 'Store not belongs to website' => [ + 'baseScope' => ScopeInterface::SCOPE_WEBSITE, + 'baseScopeId' => 1, + 'requestedScope' => ScopeInterface::SCOPE_STORE, + 'requestedScopeId' => 2, + 'isBelong' => false + ], + ]; + } + + /** + * Get scope tree with 2 websites, 2 groups and 2 stores + * + * @return array + */ + private function getTree() + { + return [ + 'scope' => ScopeConfigInterface::SCOPE_TYPE_DEFAULT, + 'scope_id' => null, + 'scopes' => [ + [ + 'scope' => ScopeInterface::SCOPE_WEBSITE, + 'scope_id' => 1, + 'scopes' => [ + [ + 'scope' => ScopeInterface::SCOPE_GROUP, + 'scope_id' => 1, + 'scopes' => [ + [ + 'scope' => ScopeInterface::SCOPE_STORE, + 'scope_id' => 1, + 'scopes' => [], + ], + ], + ], + ], + ], + [ + 'scope' => ScopeInterface::SCOPE_WEBSITE, + 'scope_id' => 2, + 'scopes' => [ + [ + 'scope' => ScopeInterface::SCOPE_GROUP, + 'scope_id' => 2, + 'scopes' => [ + [ + 'scope' => ScopeInterface::SCOPE_STORE, + 'scope_id' => 2, + 'scopes' => [], + ], + ], + ], + ], + ], + ], + ]; + } +} diff --git a/app/code/Magento/Store/Test/Unit/Model/Service/StoreConfigManagerTest.php b/app/code/Magento/Store/Test/Unit/Model/Service/StoreConfigManagerTest.php index c17e2846e22df..0622869c0b963 100644 --- a/app/code/Magento/Store/Test/Unit/Model/Service/StoreConfigManagerTest.php +++ b/app/code/Magento/Store/Test/Unit/Model/Service/StoreConfigManagerTest.php @@ -136,10 +136,10 @@ public function testGetStoreConfigs() $secureBaseUrl = 'https://magento/base_url'; $baseLinkUrl = 'http://magento/base_url/links'; $secureBaseLinkUrl = 'https://magento/base_url/links'; - $baseStaticUrl = 'http://magento/base_url/pub/static'; + $baseStaticUrl = 'http://magento/base_url/static'; $secureBaseStaticUrl = 'https://magento/base_url/static'; - $baseMediaUrl = 'http://magento/base_url/pub/media'; - $secureBaseMediaUrl = 'https://magento/base_url/pub/media'; + $baseMediaUrl = 'http://magento/base_url/media'; + $secureBaseMediaUrl = 'https://magento/base_url/media'; $locale = 'en_US'; $timeZone = 'America/Los_Angeles'; $baseCurrencyCode = 'USD'; diff --git a/app/code/Magento/Store/Test/Unit/Model/StoreSwitcher/HashProcessorTest.php b/app/code/Magento/Store/Test/Unit/Model/StoreSwitcher/HashProcessorTest.php new file mode 100644 index 0000000000000..89dc1d1c99ebd --- /dev/null +++ b/app/code/Magento/Store/Test/Unit/Model/StoreSwitcher/HashProcessorTest.php @@ -0,0 +1,163 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Store\Test\Unit\Model\StoreSwitcher; + +use InvalidArgumentException; +use Magento\Framework\App\RequestInterface; +use Magento\Framework\Message\ManagerInterface; +use Magento\Store\Api\Data\StoreInterface; +use Magento\Store\Model\StoreSwitcher\ContextInterface; +use Magento\Store\Model\StoreSwitcher\ContextInterfaceFactory; +use Magento\Store\Model\StoreSwitcher\HashProcessor; +use Magento\Store\Model\StoreSwitcher\RedirectDataInterface; +use Magento\Store\Model\StoreSwitcher\RedirectDataInterfaceFactory; +use Magento\Store\Model\StoreSwitcher\RedirectDataPostprocessorInterface; +use Magento\Store\Model\StoreSwitcher\RedirectDataSerializerInterface; +use Magento\Store\Model\StoreSwitcher\RedirectDataValidator; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +/** + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ +class HashProcessorTest extends TestCase +{ + /** + * @var RequestInterface|MockObject + */ + private $request; + /** + * @var RedirectDataPostprocessorInterface|MockObject + */ + private $postprocessor; + /** + * @var RedirectDataSerializerInterface|MockObject + */ + private $dataSerializer; + /** + * @var ManagerInterface|MockObject + */ + private $messageManager; + /** + * @var RedirectDataValidator|MockObject + */ + private $dataValidator; + /** + * @var StoreInterface|MockObject + */ + private $store1; + /** + * @var StoreInterface|MockObject + */ + private $store2; + /** + * @var HashProcessor + */ + private $model; + + /** + * @inheritDoc + */ + protected function setUp(): void + { + parent::setUp(); + $this->request = $this->createMock(RequestInterface::class); + $this->postprocessor = $this->createMock(RedirectDataPostprocessorInterface::class); + $this->dataSerializer = $this->createMock(RedirectDataSerializerInterface::class); + $this->messageManager = $this->createMock(ManagerInterface::class); + $contextFactory = $this->createMock(ContextInterfaceFactory::class); + $dataFactory = $this->createMock(RedirectDataInterfaceFactory::class); + $this->dataValidator = $this->createMock(RedirectDataValidator::class); + $logger = $this->createMock(LoggerInterface::class); + $this->store1 = $this->createMock(StoreInterface::class); + $this->store2 = $this->createMock(StoreInterface::class); + $this->model = new HashProcessor( + $this->request, + $this->postprocessor, + $this->dataSerializer, + $this->messageManager, + $contextFactory, + $dataFactory, + $this->dataValidator, + $logger + ); + + $contextFactory->method('create') + ->willReturn($this->createMock(ContextInterface::class)); + $dataFactory->method('create') + ->willReturnCallback( + function (array $data) { + return $this->createConfiguredMock( + RedirectDataInterface::class, + [ + 'getTimestamp' => $data['timestamp'], + 'getData' => $data['data'], + 'getSignature' => $data['signature'], + ] + ); + } + ); + } + + public function testShouldProcessIfDataValidationPassed(): void + { + $redirectUrl = '/category-1/category-1.1.html'; + $this->request->method('getParam') + ->willReturnMap( + [ + ['time_stamp', null, time() - 1], + ['data', null, '{"customer_id":1}'], + ['signature', null, 'randomstring'], + ] + ); + $this->dataValidator->method('validate') + ->willReturn(true); + $this->dataSerializer->method('unserialize') + ->with('{"customer_id":1}') + ->willReturnCallback( + function ($arg) { + return json_decode($arg, true); + } + ); + $this->postprocessor->expects($this->once()) + ->method('process') + ->with($this->isInstanceOf(ContextInterface::class), ['customer_id' => 1]); + $this->assertEquals($redirectUrl, $this->model->switch($this->store1, $this->store2, $redirectUrl)); + } + + public function testShouldNotProcessIfDataValidationFailed(): void + { + $redirectUrl = '/category-1/category-1.1.html'; + $this->dataValidator->method('validate') + ->willReturn(false); + $this->postprocessor->expects($this->never()) + ->method('process'); + $this->messageManager->expects($this->once()) + ->method('addErrorMessage') + ->with('The requested store cannot be found. Please check the request and try again.'); + + $this->assertEquals($redirectUrl, $this->model->switch($this->store1, $this->store2, $redirectUrl)); + } + + public function testShouldNotProcessIfDataUnserializationFailed(): void + { + $redirectUrl = '/category-1/category-1.1.html'; + $this->dataValidator->method('validate') + ->willReturn(true); + $this->dataSerializer->method('unserialize') + ->willThrowException(new InvalidArgumentException('Invalid token supplied')); + $this->postprocessor->expects($this->never()) + ->method('process'); + $this->messageManager->expects($this->once()) + ->method('addErrorMessage') + ->with('Something went wrong.'); + + $this->assertEquals($redirectUrl, $this->model->switch($this->store1, $this->store2, $redirectUrl)); + } +} diff --git a/app/code/Magento/Store/Test/Unit/Model/StoreSwitcher/RedirectDataCacheSerializerTest.php b/app/code/Magento/Store/Test/Unit/Model/StoreSwitcher/RedirectDataCacheSerializerTest.php new file mode 100644 index 0000000000000..c21d785b268a9 --- /dev/null +++ b/app/code/Magento/Store/Test/Unit/Model/StoreSwitcher/RedirectDataCacheSerializerTest.php @@ -0,0 +1,99 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Store\Test\Unit\Model\StoreSwitcher; + +use InvalidArgumentException; +use Magento\Framework\App\CacheInterface; +use Magento\Framework\Math\Random; +use Magento\Framework\Serialize\Serializer\Json; +use Magento\Store\Model\StoreSwitcher\RedirectDataCacheSerializer; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use RuntimeException; + +class RedirectDataCacheSerializerTest extends TestCase +{ + private const RANDOM_STRING = '7ddf32e17a6ac5ce04a8ecbf782ca509'; + /** + * @var CacheInterface|MockObject + */ + private $cache; + /** + * @var RedirectDataCacheSerializer + */ + private $model; + + /** + * @inheritDoc + */ + protected function setUp(): void + { + parent::setUp(); + $this->cache = $this->createMock(CacheInterface::class); + $random = $this->createMock(Random::class); + $logger = $this->createMock(LoggerInterface::class); + $this->model = new RedirectDataCacheSerializer( + new Json(), + $random, + $this->cache, + $logger + ); + $random->method('getRandomString')->willReturn(self::RANDOM_STRING); + } + + public function testSerialize(): void + { + $this->cache->expects($this->once()) + ->method('save') + ->with( + '{"customer_id":1}', + 'store_switch_' . self::RANDOM_STRING, + [], + 10 + ); + $this->assertEquals(self::RANDOM_STRING, $this->model->serialize(['customer_id' => 1])); + } + + public function testSerializeShouldThrowExceptionIfCannotSaveCache(): void + { + $exception = new RuntimeException('Failed to connect to cache server'); + $this->expectExceptionObject($exception); + $this->cache->expects($this->once()) + ->method('save') + ->willThrowException($exception); + $this->assertEquals(self::RANDOM_STRING, $this->model->serialize(['customer_id' => 1])); + } + + public function testUnserialize(): void + { + $this->cache->expects($this->once()) + ->method('load') + ->with('store_switch_' . self::RANDOM_STRING) + ->willReturn('{"customer_id":1}'); + $this->assertEquals(['customer_id' => 1], $this->model->unserialize(self::RANDOM_STRING)); + } + + public function testUnserializeShouldThrowExceptionIfCacheHasExpired(): void + { + $this->expectExceptionObject(new InvalidArgumentException('Couldn\'t retrieve data from cache.')); + $this->cache->expects($this->once()) + ->method('load') + ->with('store_switch_' . self::RANDOM_STRING) + ->willReturn(null); + $this->assertEquals(['customer_id' => 1], $this->model->unserialize(self::RANDOM_STRING)); + } + + public function testUnserializeShouldThrowExceptionIfCacheKeyIsInvalid(): void + { + $this->expectExceptionObject(new InvalidArgumentException('Invalid cache key \'abc\' supplied.')); + $this->cache->expects($this->never()) + ->method('load'); + $this->assertEquals(['customer_id' => 1], $this->model->unserialize('abc')); + } +} diff --git a/app/code/Magento/Store/Test/Unit/Model/StoreSwitcher/RedirectDataGeneratorTest.php b/app/code/Magento/Store/Test/Unit/Model/StoreSwitcher/RedirectDataGeneratorTest.php new file mode 100644 index 0000000000000..67270f5f70dce --- /dev/null +++ b/app/code/Magento/Store/Test/Unit/Model/StoreSwitcher/RedirectDataGeneratorTest.php @@ -0,0 +1,130 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Store\Test\Unit\Model\StoreSwitcher; + +use Magento\Framework\Encryption\Encryptor; +use Magento\Store\Api\Data\StoreInterface; +use Magento\Store\Model\StoreSwitcher\ContextInterface; +use Magento\Store\Model\StoreSwitcher\RedirectDataGenerator; +use Magento\Store\Model\StoreSwitcher\RedirectDataInterface; +use Magento\Store\Model\StoreSwitcher\RedirectDataInterfaceFactory; +use Magento\Store\Model\StoreSwitcher\RedirectDataPreprocessorInterface; +use Magento\Store\Model\StoreSwitcher\RedirectDataSerializerInterface; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +class RedirectDataGeneratorTest extends TestCase +{ + /** + * @var RedirectDataPreprocessorInterface|MockObject + */ + private $preprocessor; + /** + * @var RedirectDataSerializerInterface|MockObject + */ + private $dataSerializer; + /** + * @var ContextInterface|MockObject + */ + private $context; + /** + * @var RedirectDataGenerator + */ + private $model; + + /** + * @inheritDoc + */ + protected function setUp(): void + { + parent::setUp(); + $this->preprocessor = $this->createMock(RedirectDataPreprocessorInterface::class); + $this->dataSerializer = $this->createMock(RedirectDataSerializerInterface::class); + $dataFactory = $this->createMock(RedirectDataInterfaceFactory::class); + $encryptor = $this->createMock(Encryptor::class); + $logger = $this->createMock(LoggerInterface::class); + $this->model = new RedirectDataGenerator( + $encryptor, + $this->preprocessor, + $this->dataSerializer, + $dataFactory, + $logger + ); + $store1 = $this->createConfiguredMock( + StoreInterface::class, + [ + 'getCode' => 'en', + 'getId' => 1, + ] + ); + $store2 = $this->createConfiguredMock( + StoreInterface::class, + [ + 'getCode' => 'fr', + 'getId' => 2, + ] + ); + $this->context = $this->createConfiguredMock( + ContextInterface::class, + [ + 'getFromStore' => $store2, + 'getTargetStore' => $store1, + ] + ); + $encryptor->method('hash') + ->willReturnCallback( + function (string $arg1) { + // phpcs:ignore Magento2.Security.InsecureFunction + return md5($arg1); + } + ); + $dataFactory->method('create') + ->willReturnCallback( + function (array $data) { + return $this->createConfiguredMock( + RedirectDataInterface::class, + [ + 'getTimestamp' => $data['timestamp'], + 'getData' => $data['data'], + 'getSignature' => $data['signature'], + ] + ); + } + ); + } + + public function testGenerate(): void + { + $this->preprocessor->method('process') + ->willReturn(['customer_id' => 1]); + $this->dataSerializer->method('serialize') + ->willReturnCallback('json_encode'); + $redirectData = $this->model->generate($this->context); + $time = time(); + $this->assertEqualsWithDelta($time, $redirectData->getTimestamp(), 1); + $time = $redirectData->getTimestamp(); + $this->assertEquals('{"customer_id":1}', $redirectData->getData()); + // phpcs:ignore Magento2.Security.InsecureFunction + $this->assertEquals(md5("{\"customer_id\":1},{$time},fr,en"), $redirectData->getSignature()); + } + + public function testShouldGenerateEmptyDataIfDataSerializationFailed(): void + { + $this->dataSerializer->method('serialize') + ->willThrowException(new \InvalidArgumentException('Failed to connect to cache server')); + + $redirectData = $this->model->generate($this->context); + $time = time(); + $this->assertEqualsWithDelta($time, $redirectData->getTimestamp(), 1); + $time = $redirectData->getTimestamp(); + $this->assertEquals('', $redirectData->getData()); + // phpcs:ignore Magento2.Security.InsecureFunction + $this->assertEquals(md5(",{$time},fr,en"), $redirectData->getSignature()); + } +} diff --git a/app/code/Magento/Store/Test/Unit/Model/StoreSwitcher/RedirectDataValidatorTest.php b/app/code/Magento/Store/Test/Unit/Model/StoreSwitcher/RedirectDataValidatorTest.php new file mode 100644 index 0000000000000..9960fad2071be --- /dev/null +++ b/app/code/Magento/Store/Test/Unit/Model/StoreSwitcher/RedirectDataValidatorTest.php @@ -0,0 +1,144 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Store\Test\Unit\Model\StoreSwitcher; + +use Magento\Framework\Encryption\Encryptor; +use Magento\Store\Api\Data\StoreInterface; +use Magento\Store\Model\StoreSwitcher\ContextInterface; +use Magento\Store\Model\StoreSwitcher\RedirectDataInterface; +use Magento\Store\Model\StoreSwitcher\RedirectDataValidator; +use PHPUnit\Framework\TestCase; + +class RedirectDataValidatorTest extends TestCase +{ + /** + * @var RedirectDataValidator + */ + private $model; + /** + * @var ContextInterface + */ + private $context; + + /** + * @inheritDoc + */ + protected function setUp(): void + { + parent::setUp(); + + $encryptor = $this->createMock(Encryptor::class); + $this->model = new RedirectDataValidator( + $encryptor + ); + $store1 = $this->createConfiguredMock( + StoreInterface::class, + [ + 'getCode' => 'en', + 'getId' => 1, + ] + ); + $store2 = $this->createConfiguredMock( + StoreInterface::class, + [ + 'getCode' => 'fr', + 'getId' => 2, + ] + ); + $this->context = $this->createConfiguredMock( + ContextInterface::class, + [ + 'getFromStore' => $store2, + 'getTargetStore' => $store1, + ] + ); + $encryptor->method('validateHash') + ->willReturnCallback( + function (string $value, string $hash) { + // phpcs:ignore Magento2.Security.InsecureFunction + return md5($value) === $hash; + } + ); + } + + /** + * @param array $params + * @param bool $result + * @dataProvider validationDataProvider + */ + public function testValidation(array $params, bool $result): void + { + $originalData = '{"customer_id":1}'; + $timestamp = time() - $params['elapsedTime']; + $fromStoreCode = $params['fromStoreCode'] ?? $this->context->getFromStore()->getCode(); + $targetStoreCode = $params['targetStoreCode'] ?? $this->context->getTargetStore()->getCode(); + // phpcs:ignore Magento2.Security.InsecureFunction + $signature = md5("{$originalData},{$timestamp},{$fromStoreCode},{$targetStoreCode}"); + $redirectData = $this->createConfiguredMock( + RedirectDataInterface::class, + [ + 'getTimestamp' => $params['timestamp'] ?? $timestamp, + 'getData' => $params['data'] ?? $originalData, + 'getSignature' => $params['signature'] ?? $signature, + ] + ); + $this->assertEquals($result, $this->model->validate($this->context, $redirectData)); + } + + /** + * @return array + */ + public function validationDataProvider(): array + { + return [ + [ + [ + 'elapsedTime' => 1, + ], + true + ], + [ + [ + 'elapsedTime' => 6, + ], + false + ], + [ + [ + 'elapsedTime' => 1, + 'data' => '{"customer_id":2}' + ], + false + ], + [ + [ + 'elapsedTime' => 1, + 'fromStoreCode' => 'es' + + ], + false + ], + [ + [ + 'elapsedTime' => 1, + 'targetStoreCode' => 'de' + + ], + false + ], + [ + [ + 'elapsedTime' => 1, + 'signature' => 'abcd1efgh2ijkl3mnop4qrst5uvwx6yz' + + ], + false + ] + ]; + } +} diff --git a/app/code/Magento/Store/etc/config.xml b/app/code/Magento/Store/etc/config.xml index 83bb4432ac18f..d4dddbb6a7dfa 100644 --- a/app/code/Magento/Store/etc/config.xml +++ b/app/code/Magento/Store/etc/config.xml @@ -133,6 +133,7 @@ <phpt>phpt</phpt> <pht>pht</pht> <svg>svg</svg> + <xml>xml</xml> </protected_extensions> <public_files_valid_paths> <protected> diff --git a/app/code/Magento/Store/etc/di.xml b/app/code/Magento/Store/etc/di.xml index 2da9e91e1fddd..ccfec562ba103 100644 --- a/app/code/Magento/Store/etc/di.xml +++ b/app/code/Magento/Store/etc/di.xml @@ -26,6 +26,11 @@ <preference for="Magento\Framework\App\ScopeTreeProviderInterface" type="Magento\Store\Model\ScopeTreeProvider"/> <preference for="Magento\Framework\App\ScopeValidatorInterface" type="Magento\Store\Model\ScopeValidator"/> <preference for="Magento\Store\Model\StoreSwitcherInterface" type="Magento\Store\Model\StoreSwitcher" /> + <preference for="Magento\Store\Model\StoreSwitcher\RedirectDataPreprocessorInterface" type="Magento\Store\Model\StoreSwitcher\RedirectDataPreprocessorComposite" /> + <preference for="Magento\Store\Model\StoreSwitcher\RedirectDataPostprocessorInterface" type="Magento\Store\Model\StoreSwitcher\RedirectDataPostprocessorComposite" /> + <preference for="Magento\Store\Model\StoreSwitcher\RedirectDataSerializerInterface" type="Magento\Store\Model\StoreSwitcher\RedirectDataCacheSerializer" /> + <preference for="Magento\Store\Model\StoreSwitcher\ContextInterface" type="Magento\Store\Model\StoreSwitcher\Context" /> + <preference for="Magento\Store\Model\StoreSwitcher\RedirectDataInterface" type="Magento\Store\Model\StoreSwitcher\RedirectData" /> <type name="Magento\Framework\App\Http\Context"> <arguments> <argument name="default" xsi:type="array"> diff --git a/app/code/Magento/Swatches/Helper/Data.php b/app/code/Magento/Swatches/Helper/Data.php index d2cd1baca894b..dd257de331b91 100644 --- a/app/code/Magento/Swatches/Helper/Data.php +++ b/app/code/Magento/Swatches/Helper/Data.php @@ -310,7 +310,7 @@ private function addFilterByParent(ProductCollection $productCollection, $parent * Method getting full media gallery for current Product * * Array structure: [ - * ['image'] => 'http://url/pub/media/catalog/product/2/0/blabla.jpg', + * ['image'] => 'http://url/media/catalog/product/2/0/blabla.jpg', * ['mediaGallery'] => [ * galleryImageId1 => simpleProductImage1.jpg, * galleryImageId2 => simpleProductImage2.jpg, diff --git a/app/code/Magento/Swatches/Helper/Media.php b/app/code/Magento/Swatches/Helper/Media.php index f3694515ecb26..6787fba534893 100644 --- a/app/code/Magento/Swatches/Helper/Media.php +++ b/app/code/Magento/Swatches/Helper/Media.php @@ -6,8 +6,9 @@ namespace Magento\Swatches\Helper; use Magento\Catalog\Helper\Image; -use Magento\Framework\App\Area; +use Magento\Catalog\Model\Config\CatalogMediaConfig; use Magento\Framework\App\Filesystem\DirectoryList; +use Magento\Framework\App\ObjectManager; /** * Helper to move images from tmp to catalog directory @@ -72,6 +73,11 @@ class Media extends \Magento\Framework\App\Helper\AbstractHelper */ private $imageConfig; + /** + * @var string + */ + private $mediaUrlFormat; + /** * @param \Magento\Catalog\Model\Product\Media\Config $mediaConfig * @param \Magento\Framework\Filesystem $filesystem @@ -80,6 +86,8 @@ class Media extends \Magento\Framework\App\Helper\AbstractHelper * @param \Magento\Framework\Image\Factory $imageFactory * @param \Magento\Theme\Model\ResourceModel\Theme\Collection $themeCollection * @param \Magento\Framework\View\ConfigInterface $configInterface + * @param CatalogMediaConfig $catalogMediaConfig + * @throws \Magento\Framework\Exception\FileSystemException */ public function __construct( \Magento\Catalog\Model\Product\Media\Config $mediaConfig, @@ -88,7 +96,8 @@ public function __construct( \Magento\Store\Model\StoreManagerInterface $storeManager, \Magento\Framework\Image\Factory $imageFactory, \Magento\Theme\Model\ResourceModel\Theme\Collection $themeCollection, - \Magento\Framework\View\ConfigInterface $configInterface + \Magento\Framework\View\ConfigInterface $configInterface, + CatalogMediaConfig $catalogMediaConfig = null ) { $this->mediaConfig = $mediaConfig; $this->fileStorageDb = $fileStorageDb; @@ -97,6 +106,9 @@ public function __construct( $this->imageFactory = $imageFactory; $this->themeCollection = $themeCollection; $this->viewConfig = $configInterface; + + $catalogMediaConfig = $catalogMediaConfig ?: ObjectManager::getInstance()->get(CatalogMediaConfig::class); + $this->mediaUrlFormat = $catalogMediaConfig->getMediaUrlFormat(); } /** @@ -106,17 +118,35 @@ public function __construct( */ public function getSwatchAttributeImage($swatchType, $file) { - $generationPath = $swatchType . '/' . $this->getFolderNameSize($swatchType) . $file; - $absoluteImagePath = $this->mediaDirectory - ->getAbsolutePath($this->getSwatchMediaPath() . '/' . $generationPath); - if (!file_exists($absoluteImagePath)) { - try { - $this->generateSwatchVariations($file); - } catch (\Exception $e) { - return ''; + $basePath = $this->getSwatchMediaUrl(); + + if ($this->mediaUrlFormat === CatalogMediaConfig::HASH) { + $generationPath = $swatchType . '/' . $this->getFolderNameSize($swatchType) . $file; + $absoluteImagePath = $this->mediaDirectory + ->getAbsolutePath($this->getSwatchMediaPath() . '/' . $generationPath); + if (!$this->mediaDirectory->isExist(($absoluteImagePath))) { + try { + $this->generateSwatchVariations($file); + } catch (\Exception $e) { + return ''; + } } + + return $basePath . '/' . $generationPath; } - return $this->getSwatchMediaUrl() . '/' . $generationPath; + + return $basePath . '/' . $this->getRelativeTransformationParametersPath($swatchType, $file); + } + + private function getRelativeTransformationParametersPath($swatchType, $file) + { + $imageConfig = $this->getImageConfig(); + return $this->prepareFile($file) . '?' . http_build_query([ + 'width' => $imageConfig[$swatchType]['width'], + 'height' => $imageConfig[$swatchType]['height'], + 'store' => $this->storeManager->getStore()->getCode(), + 'image-type' => $swatchType + ]); } /** @@ -156,7 +186,7 @@ public function moveImageFromTmp($file) /** * Check whether file to move exists. Getting unique name * - * @param <type> $file + * @param string $file * @return string */ protected function getUniqueFileName($file) @@ -167,14 +197,19 @@ protected function getUniqueFileName($file) $file ); } else { - $destFile = dirname($file) . '/' . \Magento\MediaStorage\Model\File\Uploader::getNewFileName( - $this->mediaDirectory->getAbsolutePath($this->getAttributeSwatchPath($file)) + $destFile = rtrim(dirname($file), '/.') . '/' . \Magento\MediaStorage\Model\File\Uploader::getNewFileName( + $this->getOriginalFilePath($file) ); } return $destFile; } + private function getOriginalFilePath($file) + { + return $this->mediaDirectory->getAbsolutePath($this->getAttributeSwatchPath($file)); + } + /** * Generate swatch thumb and small swatch image * @@ -183,16 +218,19 @@ protected function getUniqueFileName($file) */ public function generateSwatchVariations($imageUrl) { - $absoluteImagePath = $this->mediaDirectory->getAbsolutePath($this->getAttributeSwatchPath($imageUrl)); - foreach ($this->swatchImageTypes as $swatchType) { - $imageConfig = $this->getImageConfig(); - $swatchNamePath = $this->generateNamePath($imageConfig, $imageUrl, $swatchType); - $image = $this->imageFactory->create($absoluteImagePath); - $this->setupImageProperties($image); - $image->resize($imageConfig[$swatchType]['width'], $imageConfig[$swatchType]['height']); - $this->setupImageProperties($image, true); - $image->save($swatchNamePath['path_for_save'], $swatchNamePath['name']); + if ($this->mediaUrlFormat === CatalogMediaConfig::HASH) { + $absoluteImagePath = $this->getOriginalFilePath($imageUrl); + foreach ($this->swatchImageTypes as $swatchType) { + $imageConfig = $this->getImageConfig(); + $swatchNamePath = $this->generateNamePath($imageConfig, $imageUrl, $swatchType); + $image = $this->imageFactory->create($absoluteImagePath); + $this->setupImageProperties($image); + $image->resize($imageConfig[$swatchType]['width'], $imageConfig[$swatchType]['height']); + $this->setupImageProperties($image, true); + $image->save($swatchNamePath['path_for_save'], $swatchNamePath['name']); + } } + return $this; } @@ -281,7 +319,7 @@ protected function prepareFileName($imageUrl) } /** - * Url type http://url/pub/media/attribute/swatch/ + * Url type http://url/media/attribute/swatch/ * * @return string */ diff --git a/app/code/Magento/Swatches/Model/Plugin/EavAttribute.php b/app/code/Magento/Swatches/Model/Plugin/EavAttribute.php index 0acd7ef315700..f584cc4738faf 100644 --- a/app/code/Magento/Swatches/Model/Plugin/EavAttribute.php +++ b/app/code/Magento/Swatches/Model/Plugin/EavAttribute.php @@ -352,7 +352,7 @@ protected function processTextualSwatch(Attribute $attribute) } $defaultSwatchValue = reset($storeValues); foreach ($storeValues as $storeId => $value) { - if (!$value) { + if ($value === null || $value === '') { $value = $defaultSwatchValue; } $swatch = $this->loadSwatchIfExists($optionId, $storeId); @@ -378,8 +378,9 @@ protected function processTextualSwatch(Attribute $attribute) */ protected function getAttributeOptionId($optionId) { - if (substr($optionId, 0, 6) == self::BASE_OPTION_TITLE || substr($optionId, 0, 3) == self::API_OPTION_PREFIX) { - $optionId = isset($this->dependencyArray[$optionId]) ? $this->dependencyArray[$optionId] : null; + if (strpos((string)$optionId, self::BASE_OPTION_TITLE) === 0 || + strpos((string)$optionId, self::API_OPTION_PREFIX) === 0) { + $optionId = $this->dependencyArray[$optionId] ?? null; } return $optionId; } diff --git a/app/code/Magento/Swatches/Test/Mftf/ActionGroup/AddVisualSwatchToProductWithOutCreatedActionGroup.xml b/app/code/Magento/Swatches/Test/Mftf/ActionGroup/AddVisualSwatchToProductWithOutCreatedActionGroup.xml new file mode 100644 index 0000000000000..604ef606e94e5 --- /dev/null +++ b/app/code/Magento/Swatches/Test/Mftf/ActionGroup/AddVisualSwatchToProductWithOutCreatedActionGroup.xml @@ -0,0 +1,43 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AddVisualSwatchToProductWithOutCreatedActionGroup"> + <annotations> + <description>Does not create an attribute. Adds the provided Visual Swatch Attribute and Options (2) to a Product on the Admin Product creation/edit page. Clicks on Save. Validates that the Success Message is present. </description> + </annotations> + <arguments> + <argument name="attribute" defaultValue="visualSwatchAttribute"/> + </arguments> + + <seeInCurrentUrl url="{{ProductCatalogPage.url}}" stepKey="seeOnProductEditPage"/> + <conditionalClick selector="{{AdminProductFormConfigurationsSection.sectionHeader}}" dependentSelector="{{AdminProductFormConfigurationsSection.createConfigurations}}" visible="false" stepKey="openConfigurationSection"/> + <click selector="{{AdminProductFormConfigurationsSection.createConfigurations}}" stepKey="openConfigurationPanel"/> + <waitForElementVisible selector="{{AdminCreateProductConfigurationsPanel.createNewAttribute}}" stepKey="waitForSlideOut"/> + + <!--Find attribute in grid and select--> + <conditionalClick selector="{{AdminDataGridHeaderSection.clearFilters}}" dependentSelector="{{AdminDataGridHeaderSection.clearFilters}}" visible="true" stepKey="clearExistingFilters"/> + <click selector="{{AdminDataGridHeaderSection.filters}}" stepKey="clickOnFilters"/> + <fillField selector="{{AdminDataGridHeaderSection.attributeCodeFilterInput}}" userInput="{{attribute.default_label}}" stepKey="fillFilterAttributeCodeField"/> + <click selector="{{AdminDataGridHeaderSection.applyFilters}}" stepKey="clickApplyFiltersButton"/> + <click selector="{{AdminDataGridTableSection.rowCheckbox('1')}}" stepKey="clickOnFirstCheckbox"/> + + <click selector="{{AdminCreateProductConfigurationsPanel.next}}" stepKey="clickNextStep1"/> + + <click selector="{{AdminCreateProductConfigurationsPanel.selectAllByAttribute(attribute.default_label)}}" stepKey="clickSelectAll"/> + <click selector="{{AdminCreateProductConfigurationsPanel.next}}" stepKey="clickNextStep2"/> + + <click selector="{{AdminCreateProductConfigurationsPanel.applySingleQuantityToEachSkus}}" stepKey="clickOnApplySingleQuantityToEachSku"/> + <fillField selector="{{AdminCreateProductConfigurationsPanel.quantity}}" userInput="100" stepKey="enterAttributeQuantity"/> + <click selector="{{AdminCreateProductConfigurationsPanel.next}}" stepKey="clickOnNextStep3"/> + <click selector="{{AdminCreateProductConfigurationsPanel.next}}" stepKey="generateProducts"/> + <click selector="{{AdminProductFormActionSection.saveButton}}" stepKey="saveProduct"/> + <seeElement selector="{{AdminMessagesSection.success}}" stepKey="seeSaveProductMessage"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Swatches/Test/Mftf/ActionGroup/StorefrontSelectVisualSwatchOptionOnCategoryPageActionGroup.xml b/app/code/Magento/Swatches/Test/Mftf/ActionGroup/StorefrontSelectVisualSwatchOptionOnCategoryPageActionGroup.xml new file mode 100644 index 0000000000000..5722210abf211 --- /dev/null +++ b/app/code/Magento/Swatches/Test/Mftf/ActionGroup/StorefrontSelectVisualSwatchOptionOnCategoryPageActionGroup.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <!--Click a swatch option on product page--> + <actionGroup name="StorefrontSelectVisualSwatchOptionOnCategoryPageActionGroup"> + <arguments> + <argument name="productId" type="string"/> + <argument name="visualSwatchOptionLabel" type="string" /> + </arguments> + <click selector="{{StorefrontCategoryPageProductInfoSection.visualSwatchOption(productId,visualSwatchOptionLabel)}}" stepKey="clickSwatchOption"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Swatches/Test/Mftf/Section/StorefrontCategoryPageProductInfoSection.xml b/app/code/Magento/Swatches/Test/Mftf/Section/StorefrontCategoryPageProductInfoSection.xml new file mode 100644 index 0000000000000..5f321c7f17603 --- /dev/null +++ b/app/code/Magento/Swatches/Test/Mftf/Section/StorefrontCategoryPageProductInfoSection.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="StorefrontCategoryPageProductInfoSection"> + <element name="visualSwatchOption" type="button" selector="#product-item-info_{{var1}} .swatch-option[data-option-label='{{var2}}']" parameterized="true"/> + <element name="productAddToWishlist" type="button" selector="#product-item-info_{{var1}} .action.towishlist" parameterized="true"/> + </section> +</sections> diff --git a/app/code/Magento/Swatches/Test/Mftf/Test/AdminDisablingSwatchTooltipsTest.xml b/app/code/Magento/Swatches/Test/Mftf/Test/AdminDisablingSwatchTooltipsTest.xml index b48f181c8d199..85481f6fd4d5f 100644 --- a/app/code/Magento/Swatches/Test/Mftf/Test/AdminDisablingSwatchTooltipsTest.xml +++ b/app/code/Magento/Swatches/Test/Mftf/Test/AdminDisablingSwatchTooltipsTest.xml @@ -153,8 +153,8 @@ </actionGroup> <!-- Verify swatch tooltips are not visible --> - <reloadPage stepKey="refreshPage"/> - <waitForPageLoad stepKey="waitForPageReload"/> + <actionGroup ref="ReloadPageActionGroup" stepKey="refreshPage"/> + <comment userInput="Replacing reload action and preserve Backward Compatibility" stepKey="waitForPageReload"/> <moveMouseOver selector="{{StorefrontProductInfoMainSection.nthSwatchOption('1')}}" stepKey="hoverDisabledSwatch"/> <wait time="1" stepKey="waitForTooltip2"/> <dontSeeElement selector="{{StorefrontProductInfoMainSection.swatchOptionTooltip}}" stepKey="swatchTooltipNotVisible"/> diff --git a/app/code/Magento/Swatches/Test/Unit/Helper/DataTest.php b/app/code/Magento/Swatches/Test/Unit/Helper/DataTest.php index 880fbca71dce3..4c1ceecf153dd 100644 --- a/app/code/Magento/Swatches/Test/Unit/Helper/DataTest.php +++ b/app/code/Magento/Swatches/Test/Unit/Helper/DataTest.php @@ -255,7 +255,7 @@ public function dataForAssembleEavAttribute() */ public function testLoadFirstVariationWithSwatchImage($imageTypes, $expected, $requiredAttributes) { - $this->getSwatchAttributes($this->productMock); + $this->getSwatchAttributes(); $this->getUsedProducts($imageTypes + $requiredAttributes, $imageTypes); $result = $this->swatchHelperObject->loadFirstVariationWithSwatchImage($this->productMock, $requiredAttributes); @@ -295,16 +295,13 @@ public function dataForVariationWithSwatchImage() ]; } - /** - * @dataProvider dataForCreateSwatchProductByFallback - */ - public function testLoadVariationByFallback($product) + public function testLoadVariationByFallback() { $metadataMock = $this->getMockForAbstractClass(EntityMetadataInterface::class); $this->metaDataPoolMock->expects($this->once())->method('getMetadata')->willReturn($metadataMock); $metadataMock->expects($this->once())->method('getLinkField')->willReturn('id'); - $this->getSwatchAttributes($product); + $this->getSwatchAttributes(); $this->prepareVariationCollection(); @@ -321,7 +318,7 @@ public function testLoadVariationByFallback($product) */ public function testLoadFirstVariationWithImage($imageTypes, $expected, $requiredAttributes) { - $this->getSwatchAttributes($this->productMock); + $this->getSwatchAttributes(); $this->getUsedProducts($imageTypes + $requiredAttributes, $imageTypes); $result = $this->swatchHelperObject->loadFirstVariationWithImage($this->productMock, $requiredAttributes); @@ -592,23 +589,6 @@ public function dataForCreateSwatchProduct() ]; } - /** - * @return array - */ - public function dataForCreateSwatchProductByFallback() - { - $productMock = $this->createMock(Product::class); - - return [ - [ - 95, - ], - [ - $productMock, - ], - ]; - } - /** * @dataProvider dataForGettingSwatchAsArray */ diff --git a/app/code/Magento/Swatches/Test/Unit/Helper/MediaTest.php b/app/code/Magento/Swatches/Test/Unit/Helper/MediaTest.php index e4988bdf9308c..9e9978b499150 100644 --- a/app/code/Magento/Swatches/Test/Unit/Helper/MediaTest.php +++ b/app/code/Magento/Swatches/Test/Unit/Helper/MediaTest.php @@ -7,13 +7,16 @@ namespace Magento\Swatches\Test\Unit\Helper; +use Magento\Catalog\Model\Config\CatalogMediaConfig; use Magento\Catalog\Model\Product\Media\Config; use Magento\Framework\Config\View; use Magento\Framework\Filesystem; +use Magento\Framework\Filesystem\Directory\ReadInterface; use Magento\Framework\Filesystem\Directory\Write; use Magento\Framework\Filesystem\Directory\WriteInterface; use Magento\Framework\Image; use Magento\Framework\Image\Factory; +use Magento\Framework\ObjectManagerInterface; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; use Magento\MediaStorage\Helper\File\Storage\Database; use Magento\Store\Model\Store; @@ -59,8 +62,23 @@ class MediaTest extends TestCase /** @var Media|ObjectManager */ protected $mediaHelperObject; + /** @var CatalogMediaConfig|MockObject */ + private $catalogMediaConfigMock; + + private function setupObjectManagerForCheckImageExist($return) + { + $objectManagerMock = $this->getMockForAbstractClass(ObjectManagerInterface::class); + $mockFileSystem = $this->createMock(Filesystem::class); + $mockRead = $this->createMock(ReadInterface::class); + $objectManagerMock->method($this->logicalOr('get', 'create'))->willReturn($mockFileSystem); + $mockFileSystem->method('getDirectoryRead')->willReturn($mockRead); + $mockRead->method('isExist')->willReturn($return); + \Magento\Framework\App\ObjectManager::setInstance($objectManagerMock); + } + protected function setUp(): void { + $this->setupObjectManagerForCheckImageExist(false); $objectManager = new ObjectManager($this); $this->mediaConfigMock = $this->createMock(Config::class); @@ -78,6 +96,9 @@ protected function setUp(): void $this->storeMock = $this->createPartialMock(Store::class, ['getBaseUrl']); + $this->catalogMediaConfigMock = $this->createPartialMock(CatalogMediaConfig::class, ['getMediaUrlFormat']); + $this->catalogMediaConfigMock->method('getMediaUrlFormat')->willReturn(CatalogMediaConfig::HASH); + $this->mediaDirectoryMock = $this->createMock(Write::class); $this->fileSystemMock = $this->createPartialMock(Filesystem::class, ['getDirectoryWrite']); $this->fileSystemMock @@ -94,6 +115,7 @@ protected function setUp(): void 'storeManager' => $this->storeManagerMock, 'imageFactory' => $this->imageFactoryMock, 'configInterface' => $this->viewConfigMock, + 'catalogMediaConfig' => $this->catalogMediaConfigMock, ] ); } @@ -112,7 +134,7 @@ public function testGetSwatchAttributeImage($swatchType, $expectedResult) ->expects($this->once()) ->method('getBaseUrl') ->with('media') - ->willReturn('http://url/pub/media/'); + ->willReturn('http://url/media/'); $this->generateImageConfig(); @@ -120,7 +142,7 @@ public function testGetSwatchAttributeImage($swatchType, $expectedResult) $result = $this->mediaHelperObject->getSwatchAttributeImage($swatchType, '/f/i/file.png'); - $this->assertEquals($result, $expectedResult); + $this->assertEquals($expectedResult, $result); } /** @@ -131,11 +153,11 @@ public function dataForFullPath() return [ [ 'swatch_image', - 'http://url/pub/media/attribute/swatch/swatch_image/30x20/f/i/file.png', + 'http://url/media/attribute/swatch/swatch_image/30x20/f/i/file.png', ], [ 'swatch_thumb', - 'http://url/pub/media/attribute/swatch/swatch_thumb/110x90/f/i/file.png', + 'http://url/media/attribute/swatch/swatch_thumb/110x90/f/i/file.png', ], ]; } @@ -153,6 +175,10 @@ public function testMoveImageFromTmpNoDb() { $this->fileStorageDbMock->method('checkDbUsage')->willReturn(false); $this->fileStorageDbMock->method('renameFile')->willReturnSelf(); + $this->mediaDirectoryMock + ->expects($this->atLeastOnce()) + ->method('getAbsolutePath') + ->willReturn('attribute/swatch/f/i/file.tmp'); $result = $this->mediaHelperObject->moveImageFromTmp('file.tmp'); $this->assertNotNull($result); } @@ -177,7 +203,7 @@ public function testGenerateSwatchVariations() $this->imageFactoryMock->expects($this->any())->method('create')->willReturn($image); $this->generateImageConfig(); - $image->expects($this->any())->method('resize')->willReturnSelf(); + $image->method('resize')->willReturnSelf(); $image->expects($this->atLeastOnce())->method('backgroundColor')->with([255, 255, 255])->willReturnSelf(); $this->mediaHelperObject->generateSwatchVariations('/e/a/earth.png'); } @@ -195,11 +221,11 @@ public function testGetSwatchMediaUrl() ->expects($this->once()) ->method('getBaseUrl') ->with('media') - ->willReturn('http://url/pub/media/'); + ->willReturn('http://url/media/'); $result = $this->mediaHelperObject->getSwatchMediaUrl(); - $this->assertEquals($result, 'http://url/pub/media/attribute/swatch'); + $this->assertEquals($result, 'http://url/media/attribute/swatch'); } /** @@ -282,7 +308,7 @@ protected function generateImageConfig() ], ]; - $configMock->expects($this->any())->method('getMediaEntities')->willReturn($imageConfig); + $configMock->method('getMediaEntities')->willReturn($imageConfig); } public function testGetAttributeSwatchPath() diff --git a/app/code/Magento/Swatches/Test/Unit/Model/Plugin/EavAttributeTest.php b/app/code/Magento/Swatches/Test/Unit/Model/Plugin/EavAttributeTest.php index 2b04f1d12062d..1dd79b2af020b 100644 --- a/app/code/Magento/Swatches/Test/Unit/Model/Plugin/EavAttributeTest.php +++ b/app/code/Magento/Swatches/Test/Unit/Model/Plugin/EavAttributeTest.php @@ -13,6 +13,7 @@ use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; use Magento\Swatches\Helper\Data; use Magento\Swatches\Model\Plugin\EavAttribute; +use Magento\Swatches\Model\ResourceModel\Swatch as SwatchResource; use Magento\Swatches\Model\ResourceModel\Swatch\Collection; use Magento\Swatches\Model\ResourceModel\Swatch\CollectionFactory; use Magento\Swatches\Model\Swatch; @@ -20,6 +21,7 @@ use Magento\Swatches\Model\SwatchFactory; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Magento\Framework\Exception\InputException; /** * Test plugin model for Catalog Resource Attribute @@ -35,6 +37,7 @@ class EavAttributeTest extends TestCase private const OPTION_2_ID = 2; private const ADMIN_STORE_ID = 0; private const DEFAULT_STORE_ID = 1; + private const SECOND_STORE_ID = 2; private const NEW_OPTION_KEY = 'option_2'; private const ATTRIBUTE_DEFAULT_VALUE = [ 0 => self::NEW_OPTION_KEY @@ -84,10 +87,12 @@ class EavAttributeTest extends TestCase self::OPTION_1_ID => [ self::ADMIN_STORE_ID => 'S', self::DEFAULT_STORE_ID => 'S', + self::SECOND_STORE_ID => '0', ], self::NEW_OPTION_KEY => [ self::ADMIN_STORE_ID => 'M', self::DEFAULT_STORE_ID => 'M', + self::SECOND_STORE_ID => '0', ], ] ]; @@ -106,71 +111,66 @@ class EavAttributeTest extends TestCase private $eavAttribute; /** @var Attribute|MockObject */ - private $attribute; + private $attributeMock; /** @var SwatchFactory|MockObject */ - private $swatchFactory; + private $swatchFactoryMock; /** @var CollectionFactory|MockObject */ - private $collectionFactory; + private $collectionFactoryMock; /** @var Data|MockObject */ - private $swatchHelper; + private $swatchHelperMock; /** @var AbstractSource|MockObject */ - private $abstractSource; + private $abstractSourceMock; - /** @var \Magento\Swatches\Model\ResourceModel\Swatch|MockObject */ - private $resource; + /** @var SwatchResource|MockObject */ + private $swatchResourceMock; /** @var Collection|MockObject */ - private $collection; + private $collectionMock; /** - * {@inheritDoc} + * @inheritDoc */ protected function setUp(): void { $objectManager = new ObjectManager($this); - $this->abstractSource = $this->createMock(AbstractSource::class); - $this->attribute = $this->createPartialMock( + $this->abstractSourceMock = $this->createMock(AbstractSource::class); + $this->attributeMock = $this->createPartialMock( Attribute::class, ['getSource'] ); - $this->attribute->setId(self::ATTRIBUTE_ID); - $this->swatchFactory = $this->createPartialMock( + $this->attributeMock->setId(self::ATTRIBUTE_ID); + $this->swatchFactoryMock = $this->createPartialMock( SwatchFactory::class, ['create'] ); - $this->swatchHelper = $objectManager->getObject( + $this->swatchHelperMock = $objectManager->getObject( Data::class, [ 'swatchTypeChecker' => $objectManager->getObject(SwatchAttributeType::class) ] ); - $this->resource = $this->createMock(\Magento\Swatches\Model\ResourceModel\Swatch::class); - $this->collection = $this->createMock(Collection::class); - $this->collectionFactory = $this->createPartialMock(CollectionFactory::class, ['create']); - $serializer = $objectManager->getObject(Json::class); - $this->eavAttribute = $objectManager->getObject( - EavAttribute::class, - [ - 'collectionFactory' => $this->collectionFactory, - 'swatchFactory' => $this->swatchFactory, - 'swatchHelper' => $this->swatchHelper, - 'serializer' => $serializer, - ] + $this->swatchResourceMock = $this->createMock(SwatchResource::class); + $this->collectionMock = $this->createMock(Collection::class); + $this->collectionFactoryMock = $this->createPartialMock(CollectionFactory::class, ['create']); + $this->attributeMock->method('getSource') + ->willReturn($this->abstractSourceMock); + $swatchMock = $this->createMock(Swatch::class); + $swatchMock->method('getResource') + ->willReturn($this->swatchResourceMock); + $this->swatchFactoryMock->method('create') + ->willReturn($swatchMock); + + $this->eavAttribute = new EavAttribute( + $this->collectionFactoryMock, + $this->swatchFactoryMock, + $this->swatchHelperMock, + new Json(), + $this->swatchResourceMock ); - $this->attribute->expects($this->any()) - ->method('getSource') - ->willReturn($this->abstractSource); - $swatch = $this->createMock(Swatch::class); - $swatch->expects($this->any()) - ->method('getResource') - ->willReturn($this->resource); - $this->swatchFactory->expects($this->any()) - ->method('create') - ->willReturn($swatch); } /** @@ -178,7 +178,7 @@ protected function setUp(): void */ public function testBeforeSaveVisualSwatch() { - $this->attribute->setData( + $this->attributeMock->setData( [ 'defaultvisual' => self::ATTRIBUTE_DEFAULT_VALUE, 'optionvisual' => self::VISUAL_ATTRIBUTE_OPTIONS, @@ -186,11 +186,11 @@ public function testBeforeSaveVisualSwatch() ] ); - $this->attribute->setData(Swatch::SWATCH_INPUT_TYPE_KEY, Swatch::SWATCH_INPUT_TYPE_VISUAL); - $this->eavAttribute->beforeBeforeSave($this->attribute); - $this->assertEquals(self::ATTRIBUTE_DEFAULT_VALUE, $this->attribute->getData('default')); - $this->assertEquals(self::VISUAL_ATTRIBUTE_OPTIONS, $this->attribute->getData('option')); - $this->assertEquals(self::VISUAL_SWATCH_OPTIONS, $this->attribute->getData('swatch')); + $this->attributeMock->setData(Swatch::SWATCH_INPUT_TYPE_KEY, Swatch::SWATCH_INPUT_TYPE_VISUAL); + $this->eavAttribute->beforeBeforeSave($this->attributeMock); + $this->assertEquals(self::ATTRIBUTE_DEFAULT_VALUE, $this->attributeMock->getData('default')); + $this->assertEquals(self::VISUAL_ATTRIBUTE_OPTIONS, $this->attributeMock->getData('option')); + $this->assertEquals(self::VISUAL_SWATCH_OPTIONS, $this->attributeMock->getData('swatch')); } /** @@ -198,7 +198,7 @@ public function testBeforeSaveVisualSwatch() */ public function testBeforeSaveTextSwatch() { - $this->attribute->setData( + $this->attributeMock->setData( [ 'defaulttext' => self::ATTRIBUTE_DEFAULT_VALUE, 'optiontext' => self::TEXT_ATTRIBUTE_OPTIONS, @@ -206,11 +206,11 @@ public function testBeforeSaveTextSwatch() ] ); - $this->attribute->setData(Swatch::SWATCH_INPUT_TYPE_KEY, Swatch::SWATCH_INPUT_TYPE_TEXT); - $this->eavAttribute->beforeBeforeSave($this->attribute); - $this->assertEquals(self::ATTRIBUTE_DEFAULT_VALUE, $this->attribute->getData('default')); - $this->assertEquals(self::TEXT_ATTRIBUTE_OPTIONS, $this->attribute->getData('option')); - $this->assertEquals(self::TEXT_SWATCH_OPTIONS, $this->attribute->getData('swatch')); + $this->attributeMock->setData(Swatch::SWATCH_INPUT_TYPE_KEY, Swatch::SWATCH_INPUT_TYPE_TEXT); + $this->eavAttribute->beforeBeforeSave($this->attributeMock); + $this->assertEquals(self::ATTRIBUTE_DEFAULT_VALUE, $this->attributeMock->getData('default')); + $this->assertEquals(self::TEXT_ATTRIBUTE_OPTIONS, $this->attributeMock->getData('option')); + $this->assertEquals(self::TEXT_SWATCH_OPTIONS, $this->attributeMock->getData('swatch')); } /** @@ -218,11 +218,11 @@ public function testBeforeSaveTextSwatch() */ public function testBeforeSaveWithFailedValidation() { - $this->expectException('Magento\Framework\Exception\InputException'); + $this->expectException(InputException::class); $this->expectExceptionMessage('Admin is a required field in each row'); $options = self::VISUAL_ATTRIBUTE_OPTIONS; $options['value'][self::NEW_OPTION_KEY][self::ADMIN_STORE_ID] = ''; - $this->attribute->setData( + $this->attributeMock->setData( [ 'defaultvisual' => self::ATTRIBUTE_DEFAULT_VALUE, 'optionvisual' => $options, @@ -230,8 +230,8 @@ public function testBeforeSaveWithFailedValidation() ] ); - $this->attribute->setData(Swatch::SWATCH_INPUT_TYPE_KEY, Swatch::SWATCH_INPUT_TYPE_VISUAL); - $this->eavAttribute->beforeBeforeSave($this->attribute); + $this->attributeMock->setData(Swatch::SWATCH_INPUT_TYPE_KEY, Swatch::SWATCH_INPUT_TYPE_VISUAL); + $this->eavAttribute->beforeBeforeSave($this->attributeMock); } /** @@ -242,7 +242,7 @@ public function testValidationIsSkippedForDeletedOption() $options = self::VISUAL_ATTRIBUTE_OPTIONS; $options['value'][self::NEW_OPTION_KEY][self::ADMIN_STORE_ID] = ''; $options['delete'][self::NEW_OPTION_KEY] = '1'; - $this->attribute->setData( + $this->attributeMock->setData( [ 'defaultvisual' => self::ATTRIBUTE_DEFAULT_VALUE, 'optionvisual' => $options, @@ -250,11 +250,11 @@ public function testValidationIsSkippedForDeletedOption() ] ); - $this->attribute->setData(Swatch::SWATCH_INPUT_TYPE_KEY, Swatch::SWATCH_INPUT_TYPE_VISUAL); - $this->eavAttribute->beforeBeforeSave($this->attribute); - $this->assertEquals(self::ATTRIBUTE_DEFAULT_VALUE, $this->attribute->getData('default')); - $this->assertEquals($options, $this->attribute->getData('option')); - $this->assertEquals(self::VISUAL_SWATCH_OPTIONS, $this->attribute->getData('swatch')); + $this->attributeMock->setData(Swatch::SWATCH_INPUT_TYPE_KEY, Swatch::SWATCH_INPUT_TYPE_VISUAL); + $this->eavAttribute->beforeBeforeSave($this->attributeMock); + $this->assertEquals(self::ATTRIBUTE_DEFAULT_VALUE, $this->attributeMock->getData('default')); + $this->assertEquals($options, $this->attributeMock->getData('option')); + $this->assertEquals(self::VISUAL_SWATCH_OPTIONS, $this->attributeMock->getData('swatch')); } /** @@ -268,20 +268,20 @@ public function testBeforeSaveNotSwatch() 'use_product_image_for_swatch' => 0 ]; - $this->attribute->setData( + $this->attributeMock->setData( [ Swatch::SWATCH_INPUT_TYPE_KEY => Swatch::SWATCH_INPUT_TYPE_DROPDOWN, 'additional_data' => json_encode($additionalData), ] ); - $this->attribute->setData(Swatch::SWATCH_INPUT_TYPE_KEY, Swatch::SWATCH_INPUT_TYPE_DROPDOWN); + $this->attributeMock->setData(Swatch::SWATCH_INPUT_TYPE_KEY, Swatch::SWATCH_INPUT_TYPE_DROPDOWN); - $this->eavAttribute->beforeBeforeSave($this->attribute); + $this->eavAttribute->beforeBeforeSave($this->attributeMock); unset($additionalData[Swatch::SWATCH_INPUT_TYPE_KEY]); - $this->assertEquals(json_encode($additionalData), $this->attribute->getData('additional_data')); + $this->assertEquals(json_encode($additionalData), $this->attributeMock->getData('additional_data')); } /** @@ -310,7 +310,7 @@ public function testAfterAfterSaveVisualSwatch(int $swatchType, string $swatch1, $options = self::VISUAL_SWATCH_OPTIONS; $options['value'][self::OPTION_1_ID] = $swatch1; $options['value'][self::NEW_OPTION_KEY] = $swatch2; - $this->attribute->addData( + $this->attributeMock->addData( [ 'defaultvisual' => self::ATTRIBUTE_DEFAULT_VALUE, 'optionvisual' => self::VISUAL_ATTRIBUTE_OPTIONS, @@ -318,17 +318,17 @@ public function testAfterAfterSaveVisualSwatch(int $swatchType, string $swatch1, ] ); - $this->attribute->setData(Swatch::SWATCH_INPUT_TYPE_KEY, Swatch::SWATCH_INPUT_TYPE_VISUAL); - $this->eavAttribute->beforeBeforeSave($this->attribute); - $this->abstractSource->expects($this->once()) + $this->attributeMock->setData(Swatch::SWATCH_INPUT_TYPE_KEY, Swatch::SWATCH_INPUT_TYPE_VISUAL); + $this->eavAttribute->beforeBeforeSave($this->attributeMock); + $this->abstractSourceMock->expects($this->once()) ->method('getAllOptions') ->willReturn(self::VISUAL_SAVED_OPTIONS); - $this->resource->expects($this->once()) + $this->swatchResourceMock->expects($this->once()) ->method('saveDefaultSwatchOption') ->with(self::ATTRIBUTE_ID, self::OPTION_2_ID); - $this->collection->expects($this->exactly(4)) + $this->collectionMock->expects($this->exactly(4)) ->method('addFieldToFilter') ->withConsecutive( ['option_id', self::OPTION_1_ID], @@ -338,27 +338,27 @@ public function testAfterAfterSaveVisualSwatch(int $swatchType, string $swatch1, ) ->willReturnSelf(); - $this->collection->expects($this->exactly(2)) + $this->collectionMock->expects($this->exactly(2)) ->method('getFirstItem') ->willReturnOnConsecutiveCalls( $this->createSwatchMock( (string)$swatchType, - (string)$swatch1 ?: null, + $swatch1 ?: null, 1 ), $this->createSwatchMock( (string)$swatchType, - (string)$swatch2 ?: null, + $swatch2 ?: null, null, self::OPTION_2_ID, self::ADMIN_STORE_ID ) ); - $this->collectionFactory->expects($this->exactly(2)) + $this->collectionFactoryMock->expects($this->exactly(2)) ->method('create') - ->willReturn($this->collection); + ->willReturn($this->collectionMock); - $this->eavAttribute->afterAfterSave($this->attribute); + $this->eavAttribute->afterAfterSave($this->attributeMock); } /** @@ -366,7 +366,7 @@ public function testAfterAfterSaveVisualSwatch(int $swatchType, string $swatch1, */ public function testAfterAfterSaveTextualSwatch() { - $this->attribute->addData( + $this->attributeMock->addData( [ 'defaulttext' => self::ATTRIBUTE_DEFAULT_VALUE, 'optiontext' => self::TEXT_ATTRIBUTE_OPTIONS, @@ -374,32 +374,36 @@ public function testAfterAfterSaveTextualSwatch() ] ); - $this->attribute->setData(Swatch::SWATCH_INPUT_TYPE_KEY, Swatch::SWATCH_INPUT_TYPE_TEXT); - $this->eavAttribute->beforeBeforeSave($this->attribute); + $this->attributeMock->setData(Swatch::SWATCH_INPUT_TYPE_KEY, Swatch::SWATCH_INPUT_TYPE_TEXT); + $this->eavAttribute->beforeBeforeSave($this->attributeMock); - $this->abstractSource->expects($this->once()) + $this->abstractSourceMock->expects($this->once()) ->method('getAllOptions') ->willReturn(self::TEXT_SAVED_OPTIONS); - $this->resource->expects($this->once()) + $this->swatchResourceMock->expects($this->once()) ->method('saveDefaultSwatchOption') ->with(self::ATTRIBUTE_ID, self::OPTION_2_ID); - $this->collection->expects($this->exactly(8)) + $this->collectionMock->expects($this->exactly(12)) ->method('addFieldToFilter') ->withConsecutive( ['option_id', self::OPTION_1_ID], ['store_id', self::ADMIN_STORE_ID], ['option_id', self::OPTION_1_ID], ['store_id', self::DEFAULT_STORE_ID], + ['option_id', self::OPTION_1_ID], + ['store_id', self::SECOND_STORE_ID], ['option_id', self::OPTION_2_ID], ['store_id', self::ADMIN_STORE_ID], ['option_id', self::OPTION_2_ID], - ['store_id', self::DEFAULT_STORE_ID] + ['store_id', self::DEFAULT_STORE_ID], + ['option_id', self::OPTION_2_ID], + ['store_id', self::SECOND_STORE_ID] ) ->willReturnSelf(); - $this->collection->expects($this->exactly(4)) + $this->collectionMock->expects($this->exactly(6)) ->method('getFirstItem') ->willReturnOnConsecutiveCalls( $this->createSwatchMock( @@ -412,6 +416,11 @@ public function testAfterAfterSaveTextualSwatch() self::TEXT_SWATCH_OPTIONS['value'][self::OPTION_1_ID][self::DEFAULT_STORE_ID], 1 ), + $this->createSwatchMock( + (string)Swatch::SWATCH_TYPE_TEXTUAL, + self::TEXT_SWATCH_OPTIONS['value'][self::OPTION_1_ID][self::SECOND_STORE_ID], + 1 + ), $this->createSwatchMock( (string)Swatch::SWATCH_TYPE_TEXTUAL, self::TEXT_SWATCH_OPTIONS['value'][self::NEW_OPTION_KEY][self::ADMIN_STORE_ID], @@ -425,13 +434,20 @@ public function testAfterAfterSaveTextualSwatch() null, self::OPTION_2_ID, self::DEFAULT_STORE_ID + ), + $this->createSwatchMock( + (string)Swatch::SWATCH_TYPE_TEXTUAL, + self::TEXT_SWATCH_OPTIONS['value'][self::NEW_OPTION_KEY][self::SECOND_STORE_ID], + null, + self::OPTION_2_ID, + self::SECOND_STORE_ID ) ); - $this->collectionFactory->expects($this->exactly(4)) + $this->collectionFactoryMock->expects($this->exactly(6)) ->method('create') - ->willReturn($this->collection); + ->willReturn($this->collectionMock); - $this->eavAttribute->afterAfterSave($this->attribute); + $this->eavAttribute->afterAfterSave($this->attributeMock); } /** @@ -441,7 +457,7 @@ public function testAfterAfterSaveVisualSwatchIsDelete() { $options = self::VISUAL_ATTRIBUTE_OPTIONS; $options['delete'][self::OPTION_1_ID] = '1'; - $this->attribute->addData( + $this->attributeMock->addData( [ 'defaultvisual' => self::ATTRIBUTE_DEFAULT_VALUE, 'optionvisual' => $options, @@ -449,17 +465,17 @@ public function testAfterAfterSaveVisualSwatchIsDelete() ] ); - $this->attribute->setData(Swatch::SWATCH_INPUT_TYPE_KEY, Swatch::SWATCH_INPUT_TYPE_VISUAL); - $this->eavAttribute->beforeBeforeSave($this->attribute); - $this->abstractSource->expects($this->once()) + $this->attributeMock->setData(Swatch::SWATCH_INPUT_TYPE_KEY, Swatch::SWATCH_INPUT_TYPE_VISUAL); + $this->eavAttribute->beforeBeforeSave($this->attributeMock); + $this->abstractSourceMock->expects($this->once()) ->method('getAllOptions') ->willReturn(self::VISUAL_SAVED_OPTIONS); - $this->resource->expects($this->once()) + $this->swatchResourceMock->expects($this->once()) ->method('saveDefaultSwatchOption') ->with(self::ATTRIBUTE_ID, self::OPTION_2_ID); - $this->collection->expects($this->exactly(2)) + $this->collectionMock->expects($this->exactly(2)) ->method('addFieldToFilter') ->withConsecutive( ['option_id', self::OPTION_2_ID], @@ -467,7 +483,7 @@ public function testAfterAfterSaveVisualSwatchIsDelete() ) ->willReturnSelf(); - $this->collection->expects($this->exactly(1)) + $this->collectionMock->expects($this->exactly(1)) ->method('getFirstItem') ->willReturnOnConsecutiveCalls( $this->createSwatchMock( @@ -478,11 +494,11 @@ public function testAfterAfterSaveVisualSwatchIsDelete() self::ADMIN_STORE_ID ) ); - $this->collectionFactory->expects($this->exactly(1)) + $this->collectionFactoryMock->expects($this->exactly(1)) ->method('create') - ->willReturn($this->collection); + ->willReturn($this->collectionMock); - $this->eavAttribute->afterAfterSave($this->attribute); + $this->eavAttribute->afterAfterSave($this->attributeMock); } /** @@ -492,7 +508,7 @@ public function testAfterAfterSaveTextualSwatchIsDelete() { $options = self::TEXT_ATTRIBUTE_OPTIONS; $options['delete'][self::OPTION_1_ID] = '1'; - $this->attribute->addData( + $this->attributeMock->addData( [ 'defaulttext' => self::ATTRIBUTE_DEFAULT_VALUE, 'optiontext' => $options, @@ -500,28 +516,30 @@ public function testAfterAfterSaveTextualSwatchIsDelete() ] ); - $this->attribute->setData(Swatch::SWATCH_INPUT_TYPE_KEY, Swatch::SWATCH_INPUT_TYPE_TEXT); - $this->eavAttribute->beforeBeforeSave($this->attribute); + $this->attributeMock->setData(Swatch::SWATCH_INPUT_TYPE_KEY, Swatch::SWATCH_INPUT_TYPE_TEXT); + $this->eavAttribute->beforeBeforeSave($this->attributeMock); - $this->abstractSource->expects($this->once()) + $this->abstractSourceMock->expects($this->once()) ->method('getAllOptions') ->willReturn(self::TEXT_SAVED_OPTIONS); - $this->resource->expects($this->once()) + $this->swatchResourceMock->expects($this->once()) ->method('saveDefaultSwatchOption') ->with(self::ATTRIBUTE_ID, self::OPTION_2_ID); - $this->collection->expects($this->exactly(4)) + $this->collectionMock->expects($this->exactly(6)) ->method('addFieldToFilter') ->withConsecutive( ['option_id', self::OPTION_2_ID], ['store_id', self::ADMIN_STORE_ID], ['option_id', self::OPTION_2_ID], - ['store_id', self::DEFAULT_STORE_ID] + ['store_id', self::DEFAULT_STORE_ID], + ['option_id', self::OPTION_2_ID], + ['store_id', self::SECOND_STORE_ID] ) ->willReturnSelf(); - $this->collection->expects($this->exactly(2)) + $this->collectionMock->expects($this->exactly(3)) ->method('getFirstItem') ->willReturnOnConsecutiveCalls( $this->createSwatchMock( @@ -537,13 +555,20 @@ public function testAfterAfterSaveTextualSwatchIsDelete() null, self::OPTION_2_ID, self::DEFAULT_STORE_ID + ), + $this->createSwatchMock( + (string)Swatch::SWATCH_TYPE_TEXTUAL, + self::TEXT_SWATCH_OPTIONS['value'][self::NEW_OPTION_KEY][self::SECOND_STORE_ID], + null, + self::OPTION_2_ID, + self::SECOND_STORE_ID ) ); - $this->collectionFactory->expects($this->exactly(2)) + $this->collectionFactoryMock->expects($this->exactly(3)) ->method('create') - ->willReturn($this->collection); + ->willReturn($this->collectionMock); - $this->eavAttribute->afterAfterSave($this->attribute); + $this->eavAttribute->afterAfterSave($this->attributeMock); } /** @@ -554,9 +579,11 @@ public function testAfterAfterSaveNotSwatchAttribute() $options = self::TEXT_SWATCH_OPTIONS; $options['value'][self::OPTION_1_ID][self::ADMIN_STORE_ID] = null; $options['value'][self::OPTION_1_ID][self::DEFAULT_STORE_ID] = null; + $options['value'][self::OPTION_1_ID][self::SECOND_STORE_ID] = null; $options['value'][self::NEW_OPTION_KEY][self::ADMIN_STORE_ID] = null; $options['value'][self::NEW_OPTION_KEY][self::DEFAULT_STORE_ID] = null; - $this->attribute->addData( + $options['value'][self::NEW_OPTION_KEY][self::SECOND_STORE_ID] = null; + $this->attributeMock->addData( [ 'defaulttext' => self::ATTRIBUTE_DEFAULT_VALUE, 'optiontext' => self::TEXT_ATTRIBUTE_OPTIONS, @@ -564,32 +591,36 @@ public function testAfterAfterSaveNotSwatchAttribute() ] ); - $this->attribute->setData(Swatch::SWATCH_INPUT_TYPE_KEY, Swatch::SWATCH_INPUT_TYPE_TEXT); - $this->eavAttribute->beforeBeforeSave($this->attribute); + $this->attributeMock->setData(Swatch::SWATCH_INPUT_TYPE_KEY, Swatch::SWATCH_INPUT_TYPE_TEXT); + $this->eavAttribute->beforeBeforeSave($this->attributeMock); - $this->abstractSource->expects($this->once()) + $this->abstractSourceMock->expects($this->once()) ->method('getAllOptions') ->willReturn(self::TEXT_SAVED_OPTIONS); - $this->resource->expects($this->once()) + $this->swatchResourceMock->expects($this->once()) ->method('saveDefaultSwatchOption') ->with(self::ATTRIBUTE_ID, self::OPTION_2_ID); - $this->collection->expects($this->exactly(8)) + $this->collectionMock->expects($this->exactly(12)) ->method('addFieldToFilter') ->withConsecutive( ['option_id', self::OPTION_1_ID], ['store_id', self::ADMIN_STORE_ID], ['option_id', self::OPTION_1_ID], ['store_id', self::DEFAULT_STORE_ID], + ['option_id', self::OPTION_1_ID], + ['store_id', self::SECOND_STORE_ID], ['option_id', self::OPTION_2_ID], ['store_id', self::ADMIN_STORE_ID], ['option_id', self::OPTION_2_ID], - ['store_id', self::DEFAULT_STORE_ID] + ['store_id', self::DEFAULT_STORE_ID], + ['option_id', self::OPTION_2_ID], + ['store_id', self::SECOND_STORE_ID] ) ->willReturnSelf(); - $this->collection->expects($this->exactly(4)) + $this->collectionMock->expects($this->exactly(6)) ->method('getFirstItem') ->willReturnOnConsecutiveCalls( $this->createSwatchMock( @@ -602,6 +633,11 @@ public function testAfterAfterSaveNotSwatchAttribute() null, 1 ), + $this->createSwatchMock( + (string)Swatch::SWATCH_TYPE_TEXTUAL, + null, + 1 + ), $this->createSwatchMock( (string)Swatch::SWATCH_TYPE_TEXTUAL, null, @@ -615,13 +651,20 @@ public function testAfterAfterSaveNotSwatchAttribute() null, self::OPTION_2_ID, self::DEFAULT_STORE_ID + ), + $this->createSwatchMock( + (string)Swatch::SWATCH_TYPE_TEXTUAL, + null, + null, + self::OPTION_2_ID, + self::SECOND_STORE_ID ) ); - $this->collectionFactory->expects($this->exactly(4)) + $this->collectionFactoryMock->expects($this->exactly(6)) ->method('create') - ->willReturn($this->collection); + ->willReturn($this->collectionMock); - $this->eavAttribute->afterAfterSave($this->attribute); + $this->eavAttribute->afterAfterSave($this->attributeMock); } /** @@ -642,12 +685,10 @@ private function createSwatchMock( ?int $storeId = null ) { $swatch = $this->createMock(Swatch::class); - $swatch->expects($this->any()) - ->method('getId') + $swatch->method('getId') ->willReturn($id); - $swatch->expects($this->any()) - ->method('getResource') - ->willReturn($this->resource); + $swatch->method('getResource') + ->willReturn($this->swatchResourceMock); $swatch->expects($this->once()) ->method('save'); if ($id) { diff --git a/app/code/Magento/Swatches/Test/Unit/Model/SwatchAttributeCodesTest.php b/app/code/Magento/Swatches/Test/Unit/Model/SwatchAttributeCodesTest.php index 29eb752bb3c57..c952cd3c2e6a2 100644 --- a/app/code/Magento/Swatches/Test/Unit/Model/SwatchAttributeCodesTest.php +++ b/app/code/Magento/Swatches/Test/Unit/Model/SwatchAttributeCodesTest.php @@ -90,17 +90,12 @@ public function testGetCodes($swatchAttributeCodesCache, $expected) ->withConsecutive( [ self::identicalTo( - ['a' => self::ATTRIBUTE_TABLE], - [ - 'attribute_id' => 'a.attribute_id', - 'attribute_code' => 'a.attribute_code', - ] + ['a' => self::ATTRIBUTE_TABLE] ) ], [ self::identicalTo( - ['o' => self::ATTRIBUTE_OPTION_TABLE], - ['attribute_id' => 'o.attribute_id'] + ['o' => self::ATTRIBUTE_OPTION_TABLE] ) ] ) diff --git a/app/code/Magento/Swatches/etc/config.xml b/app/code/Magento/Swatches/etc/config.xml index 9d36d9692b295..236e9237fb29b 100644 --- a/app/code/Magento/Swatches/etc/config.xml +++ b/app/code/Magento/Swatches/etc/config.xml @@ -14,6 +14,13 @@ <show_swatch_tooltip>1</show_swatch_tooltip> </frontend> </catalog> + <system> + <media_storage_configuration> + <allowed_resources> + <swatches_folder>attribute</swatches_folder> + </allowed_resources> + </media_storage_configuration> + </system> <general> <validator_data> <input_types> diff --git a/app/code/Magento/Tax/Model/Calculation/AbstractAggregateCalculator.php b/app/code/Magento/Tax/Model/Calculation/AbstractAggregateCalculator.php index 939facd02c02d..612573ff493ad 100644 --- a/app/code/Magento/Tax/Model/Calculation/AbstractAggregateCalculator.php +++ b/app/code/Magento/Tax/Model/Calculation/AbstractAggregateCalculator.php @@ -21,7 +21,7 @@ protected function calculateWithTaxInPrice(QuoteDetailsItemInterface $item, $qua $this->taxClassManagement->getTaxClassId($item->getTaxClassKey()) ); $rate = $this->calculationTool->getRate($taxRateRequest); - $storeRate = $storeRate = $this->calculationTool->getStoreRate($taxRateRequest, $this->storeId); + $storeRate = $this->calculationTool->getStoreRate($taxRateRequest, $this->storeId); $discountTaxCompensationAmount = 0; $applyTaxAfterDiscount = $this->config->applyTaxAfterDiscount($this->storeId); diff --git a/app/code/Magento/Tax/Model/Calculation/UnitBaseCalculator.php b/app/code/Magento/Tax/Model/Calculation/UnitBaseCalculator.php index ed469e822d937..655fcc9749cb3 100644 --- a/app/code/Magento/Tax/Model/Calculation/UnitBaseCalculator.php +++ b/app/code/Magento/Tax/Model/Calculation/UnitBaseCalculator.php @@ -10,7 +10,15 @@ class UnitBaseCalculator extends AbstractCalculator { /** - * {@inheritdoc} + * Determines the rounding operation type and rounds the amount + * + * @param float $amount + * @param string $rate + * @param bool $direction + * @param string $type + * @param bool $round + * @param QuoteDetailsItemInterface $item + * @return float|string */ protected function roundAmount( $amount, @@ -31,7 +39,12 @@ protected function roundAmount( } /** - * {@inheritdoc} + * Calculate tax details for quote item with tax in price with given quantity + * + * @param QuoteDetailsItemInterface $item + * @param int $quantity + * @param bool $round + * @return \Magento\Tax\Api\Data\TaxDetailsItemInterface */ protected function calculateWithTaxInPrice(QuoteDetailsItemInterface $item, $quantity, $round = true) { @@ -39,7 +52,7 @@ protected function calculateWithTaxInPrice(QuoteDetailsItemInterface $item, $qua $this->taxClassManagement->getTaxClassId($item->getTaxClassKey()) ); $rate = $this->calculationTool->getRate($taxRateRequest); - $storeRate = $storeRate = $this->calculationTool->getStoreRate($taxRateRequest, $this->storeId); + $storeRate = $this->calculationTool->getStoreRate($taxRateRequest, $this->storeId); // Calculate $priceInclTax $applyTaxAfterDiscount = $this->config->applyTaxAfterDiscount($this->storeId); @@ -104,7 +117,12 @@ protected function calculateWithTaxInPrice(QuoteDetailsItemInterface $item, $qua } /** - * {@inheritdoc} + * Calculate tax details for quote item with tax not in price with given quantity + * + * @param QuoteDetailsItemInterface $item + * @param int $quantity + * @param bool $round + * @return \Magento\Tax\Api\Data\TaxDetailsItemInterface */ protected function calculateWithTaxNotInPrice(QuoteDetailsItemInterface $item, $quantity, $round = true) { diff --git a/app/code/Magento/Tax/Model/Plugin/OrderSave.php b/app/code/Magento/Tax/Model/Plugin/OrderSave.php index 38952eec02ca1..b46c5b51a9db2 100644 --- a/app/code/Magento/Tax/Model/Plugin/OrderSave.php +++ b/app/code/Magento/Tax/Model/Plugin/OrderSave.php @@ -50,11 +50,14 @@ public function afterSave( } /** + * Save order tax + * * @param \Magento\Sales\Api\Data\OrderInterface $order * @return $this * @SuppressWarnings(PHPMD.CyclomaticComplexity) * @SuppressWarnings(PHPMD.NPathComplexity) * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + * phpcs:disable Generic.Metrics.NestingLevel.TooHigh */ protected function saveOrderTax(\Magento\Sales\Api\Data\OrderInterface $order) { @@ -176,7 +179,9 @@ protected function saveOrderTax(\Magento\Sales\Api\Data\OrderInterface $order) } elseif (isset($quoteItemId['associated_item_id'])) { //This item is associated with a product item $item = $order->getItemByQuoteItemId($quoteItemId['associated_item_id']); - $associatedItemId = $item->getId(); + if ($item !== null && $item->getId()) { + $associatedItemId = $item->getId(); + } } $data = [ diff --git a/app/code/Magento/Tax/Model/ResourceModel/Calculation/Rate/Collection.php b/app/code/Magento/Tax/Model/ResourceModel/Calculation/Rate/Collection.php index 7863b70f6626a..d34e863d56c54 100644 --- a/app/code/Magento/Tax/Model/ResourceModel/Calculation/Rate/Collection.php +++ b/app/code/Magento/Tax/Model/ResourceModel/Calculation/Rate/Collection.php @@ -206,7 +206,7 @@ public function getOptionRates() { $size = self::TAX_RULES_CHUNK_SIZE; $page = 1; - $rates = [[]]; + $rates = []; do { $offset = $size * ($page - 1); $this->getSelect()->reset(); @@ -222,6 +222,6 @@ public function getOptionRates() $page++; } while ($this->getSize() > $offset); - return array_merge(...$rates); + return array_merge([], ...$rates); } } diff --git a/app/code/Magento/Tax/Model/Sales/Total/Quote/CommonTaxCollector.php b/app/code/Magento/Tax/Model/Sales/Total/Quote/CommonTaxCollector.php index c7cc4ded1bf07..a9acef7c178da 100644 --- a/app/code/Magento/Tax/Model/Sales/Total/Quote/CommonTaxCollector.php +++ b/app/code/Magento/Tax/Model/Sales/Total/Quote/CommonTaxCollector.php @@ -367,7 +367,7 @@ public function mapItems( $priceIncludesTax, $useBaseCurrency ); - $itemDataObjects[] = $parentItemDataObject; + $itemDataObjects[] = [$parentItemDataObject]; foreach ($item->getChildren() as $child) { $childItemDataObject = $this->mapItem( $itemDataObjectFactory, @@ -376,31 +376,29 @@ public function mapItems( $useBaseCurrency, $parentItemDataObject->getCode() ); - $itemDataObjects[] = $childItemDataObject; + $itemDataObjects[] = [$childItemDataObject]; $extraTaxableItems = $this->mapItemExtraTaxables( $itemDataObjectFactory, $item, $priceIncludesTax, $useBaseCurrency ); - //phpcs:ignore Magento2.Performance.ForeachArrayMerge - $itemDataObjects = array_merge($itemDataObjects, $extraTaxableItems); + $itemDataObjects[] = $extraTaxableItems; } } else { $itemDataObject = $this->mapItem($itemDataObjectFactory, $item, $priceIncludesTax, $useBaseCurrency); - $itemDataObjects[] = $itemDataObject; + $itemDataObjects[] = [$itemDataObject]; $extraTaxableItems = $this->mapItemExtraTaxables( $itemDataObjectFactory, $item, $priceIncludesTax, $useBaseCurrency ); - //phpcs:ignore Magento2.Performance.ForeachArrayMerge - $itemDataObjects = array_merge($itemDataObjects, $extraTaxableItems); + $itemDataObjects[] = $extraTaxableItems; } } - return $itemDataObjects; + return array_merge([], ...$itemDataObjects); } /** diff --git a/app/code/Magento/Tax/Test/Mftf/Data/TaxRuleData.xml b/app/code/Magento/Tax/Test/Mftf/Data/TaxRuleData.xml index fde43cd10e3ea..fd0cb31fd8655 100644 --- a/app/code/Magento/Tax/Test/Mftf/Data/TaxRuleData.xml +++ b/app/code/Magento/Tax/Test/Mftf/Data/TaxRuleData.xml @@ -123,4 +123,17 @@ <entity name="TaxRuleZeroRate" type="taxRule"> <data key="name" unique="suffix">TaxNameZeroRate</data> </entity> + <entity name="DefaultTaxRuleWithCustomTaxRate" type="taxRule"> + <data key="code" unique="suffix">TaxRule</data> + <data key="position">0</data> + <data key="priority">0</data> + <array key="customer_tax_class_ids"> + <item>3</item> + </array> + <array key="product_tax_class_ids"> + <item>2</item> + </array> + <var key="tax_rate_ids" entityType="taxRate" entityKey="id"/> + <data key="calculate_subtotal">false</data> + </entity> </entities> diff --git a/app/code/Magento/Tax/Test/Unit/Model/Plugin/OrderSaveTest.php b/app/code/Magento/Tax/Test/Unit/Model/Plugin/OrderSaveTest.php index 2925ebef958b6..d98bd4a0722ee 100644 --- a/app/code/Magento/Tax/Test/Unit/Model/Plugin/OrderSaveTest.php +++ b/app/code/Magento/Tax/Test/Unit/Model/Plugin/OrderSaveTest.php @@ -175,50 +175,51 @@ public function verifyItemTaxes($expectedItemTaxes) } /** + * Test for order afterSave + * * @dataProvider afterSaveDataProvider + * @param array $appliedTaxes + * @param array $itemAppliedTaxes + * @param array $expectedTaxes + * @param array $expectedItemTaxes + * @param int|null $itemId + * @return void */ public function testAfterSave( - $appliedTaxes, - $itemAppliedTaxes, - $expectedTaxes, - $expectedItemTaxes - ) { + array $appliedTaxes, + array $itemAppliedTaxes, + array $expectedTaxes, + array $expectedItemTaxes, + ?int $itemId + ): void { $orderMock = $this->setupOrderMock(); $extensionAttributeMock = $this->setupExtensionAttributeMock(); - $extensionAttributeMock->expects($this->any()) - ->method('getConvertingFromQuote') + $extensionAttributeMock->method('getConvertingFromQuote') ->willReturn(true); - $extensionAttributeMock->expects($this->any()) - ->method('getAppliedTaxes') + $extensionAttributeMock->method('getAppliedTaxes') ->willReturn($appliedTaxes); - $extensionAttributeMock->expects($this->any()) - ->method('getItemAppliedTaxes') + $extensionAttributeMock->method('getItemAppliedTaxes') ->willReturn($itemAppliedTaxes); $orderItemMock = $this->getMockBuilder(\Magento\Sales\Model\Order\Item::class) ->disableOriginalConstructor() ->setMethods(['getId']) ->getMock(); - $orderItemMock->expects($this->atLeastOnce()) - ->method('getId') - ->willReturn(self::ORDER_ITEM_ID); - $orderMock->expects($this->once()) - ->method('getAppliedTaxIsSaved') + $orderItemMock->method('getId') + ->willReturn($itemId); + $orderMock->method('getAppliedTaxIsSaved') ->willReturn(false); - $orderMock->expects($this->once()) - ->method('getExtensionAttributes') + $orderMock->method('getExtensionAttributes') ->willReturn($extensionAttributeMock); - $orderMock->expects($this->atLeastOnce()) - ->method('getItemByQuoteItemId') + $itemByQuoteId = $itemId ? $orderItemMock : $itemId; + $orderMock->method('getItemByQuoteItemId') ->with(self::ITEMID) - ->willReturn($orderItemMock); - $orderMock->expects($this->atLeastOnce()) - ->method('getEntityId') + ->willReturn($itemByQuoteId); + $orderMock->method('getEntityId') ->willReturn(self::ORDERID); - $orderMock->expects($this->once()) - ->method('setAppliedTaxIsSaved') + $orderMock->method('setAppliedTaxIsSaved') ->with(true); $this->verifyOrderTaxes($expectedTaxes); @@ -228,10 +229,12 @@ public function testAfterSave( } /** + * After save data provider + * * @return array * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ - public function afterSaveDataProvider() + public function afterSaveDataProvider(): array { return [ //one item with shipping @@ -485,6 +488,257 @@ public function afterSaveDataProvider() 'taxable_item_type' => 'shipping', ], ], + 'item_id' => self::ORDER_ITEM_ID, + ], + 'associated_item_with_empty_order_quote_item' => [ + 'applied_taxes' => [ + [ + 'amount' => 0.66, + 'base_amount' => 0.66, + 'percent' => 11, + 'id' => 'ILUS', + 'extension_attributes' => [ + 'rates' => [ + [ + 'percent' => 6, + 'code' => 'IL', + 'title' => 'IL', + ], + [ + 'percent' => 5, + 'code' => 'US', + 'title' => 'US', + ], + ] + ], + ], + [ + 'amount' => 0.2, + 'base_amount' => 0.2, + 'percent' => 3.33, + 'id' => 'CityTax', + 'extension_attributes' => [ + 'rates' => [ + [ + 'percent' => 3, + 'code' => 'CityTax', + 'title' => 'CityTax', + ], + ] + ], + ], + ], + 'item_applied_taxes' => [ + //item tax, three tax rates + [ + //first two taxes are combined + 'item_id' => null, + 'type' => 'product', + 'associated_item_id' => self::ITEMID, + 'applied_taxes' => [ + [ + 'amount' => 0.11, + 'base_amount' => 0.11, + 'percent' => 11, + 'id' => 'ILUS', + 'extension_attributes' => [ + 'rates' => [ + [ + 'percent' => 6, + 'code' => 'IL', + 'title' => 'IL', + ], + [ + 'percent' => 5, + 'code' => 'US', + 'title' => 'US', + ], + ] + ], + ], + //city tax + [ + 'amount' => 0.03, + 'base_amount' => 0.03, + 'percent' => 3.33, + 'id' => 'CityTax', + 'extension_attributes' => [ + 'rates' => [ + [ + 'percent' => 3, + 'code' => 'CityTax', + 'title' => 'CityTax', + ], + ] + ], + ], + ], + ], + //shipping tax + [ + //first two taxes are combined + 'item_id' => null, + 'type' => 'shipping', + 'associated_item_id' => null, + 'applied_taxes' => [ + [ + 'amount' => 0.55, + 'base_amount' => 0.55, + 'percent' => 11, + 'id' => 'ILUS', + 'extension_attributes' => [ + 'rates' => [ + [ + 'percent' => 6, + 'code' => 'IL', + 'title' => 'IL', + ], + [ + 'percent' => 5, + 'code' => 'US', + 'title' => 'US', + ], + ] + ], + ], + //city tax + [ + 'amount' => 0.17, + 'base_amount' => 0.17, + 'percent' => 3.33, + 'id' => 'CityTax', + 'extension_attributes' => [ + 'rates' => [ + [ + 'percent' => 3, + 'code' => 'CityTax', + 'title' => 'CityTax', + ], + ] + ], + ], + ], + ], + ], + 'expected_order_taxes' => [ + //state tax + '35' => [ + 'order_id' => self::ORDERID, + 'code' => 'IL', + 'title' => 'IL', + 'hidden' => 0, + 'percent' => 6, + 'priority' => 0, + 'position' => 0, + 'amount' => 0.66, + 'base_amount' => 0.66, + 'process' => 0, + 'base_real_amount' => 0.36, + ], + //federal tax + '36' => [ + 'order_id' => self::ORDERID, + 'code' => 'US', + 'title' => 'US', + 'hidden' => 0, + 'percent' => 5, + 'priority' => 0, + 'position' => 0, + 'amount' => 0.66, //combined amount + 'base_amount' => 0.66, + 'process' => 0, + 'base_real_amount' => 0.3, //portion for specific rate + ], + //city tax + '37' => [ + 'order_id' => self::ORDERID, + 'code' => 'CityTax', + 'title' => 'CityTax', + 'hidden' => 0, + 'percent' => 3, + 'priority' => 0, + 'position' => 0, + 'amount' => 0.2, //combined amount + 'base_amount' => 0.2, + 'process' => 0, + 'base_real_amount' => 0.18018018018018, //this number is meaningless since this is single rate + ], + ], + 'expected_item_taxes' => [ + [ + //state tax for item + 'item_id' => null, + 'tax_id' => '35', + 'tax_percent' => 6, + 'associated_item_id' => null, + 'amount' => 0.11, + 'base_amount' => 0.11, + 'real_amount' => 0.06, + 'real_base_amount' => 0.06, + 'taxable_item_type' => 'product', + ], + [ + //state tax for shipping + 'item_id' => null, + 'tax_id' => '35', + 'tax_percent' => 6, + 'associated_item_id' => null, + 'amount' => 0.55, + 'base_amount' => 0.55, + 'real_amount' => 0.3, + 'real_base_amount' => 0.3, + 'taxable_item_type' => 'shipping', + ], + [ + //federal tax for item + 'item_id' => null, + 'tax_id' => '36', + 'tax_percent' => 5, + 'associated_item_id' => null, + 'amount' => 0.11, + 'base_amount' => 0.11, + 'real_amount' => 0.05, + 'real_base_amount' => 0.05, + 'taxable_item_type' => 'product', + ], + [ + //federal tax for shipping + 'item_id' => null, + 'tax_id' => '36', + 'tax_percent' => 5, + 'associated_item_id' => null, + 'amount' => 0.55, + 'base_amount' => 0.55, + 'real_amount' => 0.25, + 'real_base_amount' => 0.25, + 'taxable_item_type' => 'shipping', + ], + [ + //city tax for item + 'item_id' => null, + 'tax_id' => '37', + 'tax_percent' => 3.33, + 'associated_item_id' => null, + 'amount' => 0.03, + 'base_amount' => 0.03, + 'real_amount' => 0.03, + 'real_base_amount' => 0.03, + 'taxable_item_type' => 'product', + ], + [ + //city tax for shipping + 'item_id' => null, + 'tax_id' => '37', + 'tax_percent' => 3.33, + 'associated_item_id' => null, + 'amount' => 0.17, + 'base_amount' => 0.17, + 'real_amount' => 0.17, + 'real_base_amount' => 0.17, + 'taxable_item_type' => 'shipping', + ], + ], + 'item_id' => null, ], ]; } diff --git a/app/code/Magento/TaxImportExport/Controller/Adminhtml/Rate/ExportPost.php b/app/code/Magento/TaxImportExport/Controller/Adminhtml/Rate/ExportPost.php index 844cfc535cfb2..f23fe8ffae7ae 100644 --- a/app/code/Magento/TaxImportExport/Controller/Adminhtml/Rate/ExportPost.php +++ b/app/code/Magento/TaxImportExport/Controller/Adminhtml/Rate/ExportPost.php @@ -81,7 +81,10 @@ public function execute() $content .= $rate->toString($template) . "\n"; } - return $this->fileFactory->create('tax_rates.csv', $content, DirectoryList::VAR_DIR); + // pass 'rm' parameter to delete a file after download + $fileContent = ['type' => 'string', 'value' => $content, 'rm' => true]; + + return $this->fileFactory->create('tax_rates.csv', $fileContent, DirectoryList::VAR_DIR); } /** diff --git a/app/code/Magento/TaxImportExport/Test/Unit/Controller/Adminhtml/Rate/ExportPostTest.php b/app/code/Magento/TaxImportExport/Test/Unit/Controller/Adminhtml/Rate/ExportPostTest.php index 0c8d0cf80544b..f4d31f3e421eb 100644 --- a/app/code/Magento/TaxImportExport/Test/Unit/Controller/Adminhtml/Rate/ExportPostTest.php +++ b/app/code/Magento/TaxImportExport/Test/Unit/Controller/Adminhtml/Rate/ExportPostTest.php @@ -101,10 +101,11 @@ public function testExecute() ]); $rateCollectionMock->expects($this->once())->method('joinCountryTable')->willReturnSelf(); $rateCollectionMock->expects($this->once())->method('joinRegionTable')->willReturnSelf(); + $fileContent = ['type' => 'string', 'value' => $content, 'rm' => true]; $this->fileFactoryMock ->expects($this->once()) ->method('create') - ->with('tax_rates.csv', $content, DirectoryList::VAR_DIR); + ->with('tax_rates.csv', $fileContent, DirectoryList::VAR_DIR); $this->controller->execute(); } } diff --git a/app/code/Magento/TaxImportExport/view/adminhtml/templates/importExport.phtml b/app/code/Magento/TaxImportExport/view/adminhtml/templates/importExport.phtml index 79d833771768d..35b2ce454d3c1 100644 --- a/app/code/Magento/TaxImportExport/view/adminhtml/templates/importExport.phtml +++ b/app/code/Magento/TaxImportExport/view/adminhtml/templates/importExport.phtml @@ -74,7 +74,7 @@ script; <div class="fieldset admin__field"> <span class="admin__field-label"><span><?= $block->escapeHtml(__('Export Tax Rates')) ?></span></span> <div class="admin__field-control"> - <?= $block->getButtonHtml(__('Export Tax Rates'), "this.form.submit()") ?> + <?= $block->getButtonHtml(__('Export Tax Rates'), "export_form.submit()") ?> </div> </div> <?php if ($block->getUseContainer()):?> diff --git a/app/code/Magento/Theme/Block/Adminhtml/System/Design/Theme/Edit/Tab/Css.php b/app/code/Magento/Theme/Block/Adminhtml/System/Design/Theme/Edit/Tab/Css.php index 2d55bbce2ec2c..5e3bb8774d246 100644 --- a/app/code/Magento/Theme/Block/Adminhtml/System/Design/Theme/Edit/Tab/Css.php +++ b/app/code/Magento/Theme/Block/Adminhtml/System/Design/Theme/Edit/Tab/Css.php @@ -194,8 +194,7 @@ protected function _addCustomCssFieldset() Storage::PARAM_CONTENT_TYPE => \Magento\Theme\Model\Wysiwyg\Storage::TYPE_IMAGE ] ) . "', null, null,'" . $this->escapeJs( - __('Upload Images'), - true + __('Upload Images') ) . "');", ] ); @@ -222,8 +221,7 @@ protected function _addCustomCssFieldset() Storage::PARAM_CONTENT_TYPE => \Magento\Theme\Model\Wysiwyg\Storage::TYPE_FONT ] ) . "', null, null,'" . $this->escapeJs( - __('Upload Fonts'), - true + __('Upload Fonts') ) . "');", ] ); diff --git a/app/code/Magento/Theme/Model/Indexer/Design/IndexerHandler.php b/app/code/Magento/Theme/Model/Indexer/Design/IndexerHandler.php new file mode 100644 index 0000000000000..1acc75a6c949c --- /dev/null +++ b/app/code/Magento/Theme/Model/Indexer/Design/IndexerHandler.php @@ -0,0 +1,76 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +declare(strict_types=1); + +namespace Magento\Theme\Model\Indexer\Design; + +use Magento\Framework\App\ResourceConnection; +use Magento\Framework\Indexer\IndexStructureInterface; +use Magento\Framework\Indexer\SaveHandler\Batch; +use Magento\Framework\Indexer\SaveHandler\Grid; +use Magento\Framework\Indexer\SaveHandler\IndexerInterface; +use Magento\Framework\Indexer\ScopeResolver\FlatScopeResolver; +use Magento\Framework\Indexer\ScopeResolver\IndexScopeResolver; +use Magento\Framework\Search\Request\Dimension; + +class IndexerHandler extends Grid +{ + /** + * @var FlatScopeResolver + */ + private $flatScopeResolver; + + /** + * @param IndexStructureInterface $indexStructure + * @param ResourceConnection $resource + * @param Batch $batch + * @param IndexScopeResolver $indexScopeResolver + * @param FlatScopeResolver $flatScopeResolver + * @param array $data + * @param int $batchSize + */ + public function __construct( + IndexStructureInterface $indexStructure, + ResourceConnection $resource, + Batch $batch, + IndexScopeResolver $indexScopeResolver, + FlatScopeResolver $flatScopeResolver, + array $data, + $batchSize = 100 + ) { + parent::__construct( + $indexStructure, + $resource, + $batch, + $indexScopeResolver, + $flatScopeResolver, + $data, + $batchSize + ); + + $this->flatScopeResolver = $flatScopeResolver; + } + + /** + * Clean index table by deleting all records unconditionally or create the index table if not exists + * + * @param Dimension[] $dimensions + * @return IndexerInterface + */ + public function cleanIndex($dimensions) + { + $tableName = $this->flatScopeResolver->resolve($this->getIndexName(), $dimensions); + + if ($this->connection->isTableExists($tableName)) { + $this->connection->delete($tableName); + } else { + $this->indexStructure->create($this->getIndexName(), $this->fields, $dimensions); + } + + return $this; + } +} diff --git a/app/code/Magento/Theme/Model/PageLayout/Config/Builder.php b/app/code/Magento/Theme/Model/PageLayout/Config/Builder.php index 13b8aa23073ce..c998c02d46b3c 100644 --- a/app/code/Magento/Theme/Model/PageLayout/Config/Builder.php +++ b/app/code/Magento/Theme/Model/PageLayout/Config/Builder.php @@ -5,25 +5,38 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); + namespace Magento\Theme\Model\PageLayout\Config; +use Magento\Framework\App\Cache\Type\Layout; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\View\Model\PageLayout\Config\BuilderInterface; +use Magento\Framework\View\PageLayout\ConfigFactory; +use Magento\Framework\View\PageLayout\File\Collector\Aggregated; +use Magento\Theme\Model\ResourceModel\Theme\Collection; +use Magento\Theme\Model\Theme\Data; +use Magento\Framework\Serialize\SerializerInterface; + /** * Page layout config builder */ -class Builder implements \Magento\Framework\View\Model\PageLayout\Config\BuilderInterface +class Builder implements BuilderInterface { + const CACHE_KEY_LAYOUTS = 'THEME_LAYOUTS_FILES_MERGED'; + /** - * @var \Magento\Framework\View\PageLayout\ConfigFactory + * @var ConfigFactory */ protected $configFactory; /** - * @var \Magento\Framework\View\PageLayout\File\Collector\Aggregated + * @var Aggregated */ protected $fileCollector; /** - * @var \Magento\Theme\Model\ResourceModel\Theme\Collection + * @var Collection */ protected $themeCollection; @@ -33,19 +46,36 @@ class Builder implements \Magento\Framework\View\Model\PageLayout\Config\Builder private $configFiles = []; /** - * @param \Magento\Framework\View\PageLayout\ConfigFactory $configFactory - * @param \Magento\Framework\View\PageLayout\File\Collector\Aggregated $fileCollector - * @param \Magento\Theme\Model\ResourceModel\Theme\Collection $themeCollection + * @var Layout|null + */ + private $cacheModel; + /** + * @var SerializerInterface|null + */ + private $serializer; + + /** + * @param ConfigFactory $configFactory + * @param Aggregated $fileCollector + * @param Collection $themeCollection + * @param Layout|null $cacheModel + * @param SerializerInterface|null $serializer */ public function __construct( - \Magento\Framework\View\PageLayout\ConfigFactory $configFactory, - \Magento\Framework\View\PageLayout\File\Collector\Aggregated $fileCollector, - \Magento\Theme\Model\ResourceModel\Theme\Collection $themeCollection + ConfigFactory $configFactory, + Aggregated $fileCollector, + Collection $themeCollection, + ?Layout $cacheModel = null, + ?SerializerInterface $serializer = null ) { $this->configFactory = $configFactory; $this->fileCollector = $fileCollector; $this->themeCollection = $themeCollection; - $this->themeCollection->setItemObjectClass(\Magento\Theme\Model\Theme\Data::class); + $this->themeCollection->setItemObjectClass(Data::class); + $this->cacheModel = $cacheModel + ?? ObjectManager::getInstance()->get(Layout::class); + $this->serializer = $serializer + ?? ObjectManager::getInstance()->get(SerializerInterface::class); } /** @@ -57,18 +87,26 @@ public function getPageLayoutsConfig() } /** - * Retrieve configuration files. + * Retrieve configuration files. Caches merged layouts.xml XML files. * * @return array */ protected function getConfigFiles() { if (!$this->configFiles) { - $configFiles = []; - foreach ($this->themeCollection->loadRegisteredThemes() as $theme) { - $configFiles[] = $this->fileCollector->getFilesContent($theme, 'layouts.xml'); + $this->configFiles = $this->cacheModel->load(self::CACHE_KEY_LAYOUTS); + if (!empty($this->configFiles)) { + //if value in cache is corrupted. + $this->configFiles = $this->serializer->unserialize($this->configFiles); + } + if (empty($this->configFiles)) { + $configFiles = []; + foreach ($this->themeCollection->loadRegisteredThemes() as $theme) { + $configFiles[] = $this->fileCollector->getFilesContent($theme, 'layouts.xml'); + } + $this->configFiles = array_merge([], ...$configFiles); + $this->cacheModel->save($this->serializer->serialize($this->configFiles), self::CACHE_KEY_LAYOUTS); } - $this->configFiles = array_merge(...$configFiles); } return $this->configFiles; diff --git a/app/code/Magento/Theme/Model/ResourceModel/Design/Config/Collection.php b/app/code/Magento/Theme/Model/ResourceModel/Design/Config/Collection.php index 3a19ff99a9270..6db521978cfab 100644 --- a/app/code/Magento/Theme/Model/ResourceModel/Design/Config/Collection.php +++ b/app/code/Magento/Theme/Model/ResourceModel/Design/Config/Collection.php @@ -77,12 +77,13 @@ protected function _afterLoad() 'value', $this->valueProcessor->process( $item->getData('value'), - $this->getData('scope'), - $this->getData('scope_id'), + $item->getData('scope'), + $item->getData('scope_id'), $item->getData('path') ) ); } - parent::_afterLoad(); + + return parent::_afterLoad(); } } diff --git a/app/code/Magento/Theme/Test/Mftf/Section/StorefrontHeaderSection.xml b/app/code/Magento/Theme/Test/Mftf/Section/StorefrontHeaderSection.xml index e2f0a01fc733b..b9940fe42052c 100644 --- a/app/code/Magento/Theme/Test/Mftf/Section/StorefrontHeaderSection.xml +++ b/app/code/Magento/Theme/Test/Mftf/Section/StorefrontHeaderSection.xml @@ -10,5 +10,6 @@ <section name="StorefrontHeaderSection"> <element name="welcomeMessage" type="text" selector=".greet.welcome"/> <element name="logoLink" type="button" selector=".header .logo"/> + <element name="logoImage" type="button" selector=".header .logo > img[src*='{{filename}}']" parameterized="true"/> </section> </sections> diff --git a/app/code/Magento/Theme/Test/Unit/Block/Adminhtml/Design/Config/Edit/SaveButtonTest.php b/app/code/Magento/Theme/Test/Unit/Block/Adminhtml/Design/Config/Edit/SaveButtonTest.php index 65e2b934741ee..b7f2def1c0fbd 100644 --- a/app/code/Magento/Theme/Test/Unit/Block/Adminhtml/Design/Config/Edit/SaveButtonTest.php +++ b/app/code/Magento/Theme/Test/Unit/Block/Adminhtml/Design/Config/Edit/SaveButtonTest.php @@ -7,10 +7,7 @@ namespace Magento\Theme\Test\Unit\Block\Adminhtml\Design\Config\Edit; -use Magento\Backend\Block\Widget\Context; -use Magento\Framework\UrlInterface; use Magento\Theme\Block\Adminhtml\Design\Config\Edit\SaveButton; -use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; class SaveButtonTest extends TestCase @@ -20,16 +17,9 @@ class SaveButtonTest extends TestCase */ protected $block; - /** - * @var Context|MockObject - */ - protected $context; - protected function setUp(): void { - $this->initContext(); - - $this->block = new SaveButton($this->context); + $this->block = new SaveButton(); } public function testGetButtonData() @@ -41,18 +31,4 @@ public function testGetButtonData() $this->assertArrayHasKey('data_attribute', $result); $this->assertIsArray($result['data_attribute']); } - - protected function initContext() - { - $this->urlBuilder = $this->getMockBuilder(UrlInterface::class) - ->disableOriginalConstructor() - ->getMockForAbstractClass(); - - $this->context = $this->getMockBuilder(Context::class) - ->disableOriginalConstructor() - ->getMock(); - $this->context->expects($this->any()) - ->method('getUrlBuilder') - ->willReturn($this->urlBuilder); - } } diff --git a/app/code/Magento/Theme/Test/Unit/Block/Html/Header/LogoTest.php b/app/code/Magento/Theme/Test/Unit/Block/Html/Header/LogoTest.php index 0bbf35e244241..1978362810763 100644 --- a/app/code/Magento/Theme/Test/Unit/Block/Html/Header/LogoTest.php +++ b/app/code/Magento/Theme/Test/Unit/Block/Html/Header/LogoTest.php @@ -35,7 +35,7 @@ public function testGetLogoSrc() )->method( 'getBaseUrl' )->willReturn( - 'http://localhost/pub/media/' + 'http://localhost/media/' ); $mediaDirectory->expects($this->any())->method('isFile')->willReturn(true); @@ -53,7 +53,7 @@ public function testGetLogoSrc() ]; $block = $objectManager->getObject(Logo::class, $arguments); - $this->assertEquals('http://localhost/pub/media/logo/default/image.gif', $block->getLogoSrc()); + $this->assertEquals('http://localhost/media/logo/default/image.gif', $block->getLogoSrc()); } /** diff --git a/app/code/Magento/Theme/Test/Unit/Controller/Adminhtml/System/Design/Theme/SaveTest.php b/app/code/Magento/Theme/Test/Unit/Controller/Adminhtml/System/Design/Theme/SaveTest.php index b2641d304fb84..f14b176e95e5c 100644 --- a/app/code/Magento/Theme/Test/Unit/Controller/Adminhtml/System/Design/Theme/SaveTest.php +++ b/app/code/Magento/Theme/Test/Unit/Controller/Adminhtml/System/Design/Theme/SaveTest.php @@ -57,7 +57,7 @@ public function testSaveAction() ->with('js_order') ->willReturn($jsOrder); - $this->_request->expects($this->once(5))->method('getPostValue')->willReturn(true); + $this->_request->expects($this->once())->method('getPostValue')->willReturn(true); $themeMock = $this->getMockBuilder(Theme::class) ->addMethods(['setCustomization']) diff --git a/app/code/Magento/Theme/Test/Unit/Model/Design/Backend/FileTest.php b/app/code/Magento/Theme/Test/Unit/Model/Design/Backend/FileTest.php index 78a56013ae042..691a94e37e932 100644 --- a/app/code/Magento/Theme/Test/Unit/Model/Design/Backend/FileTest.php +++ b/app/code/Magento/Theme/Test/Unit/Model/Design/Backend/FileTest.php @@ -195,7 +195,7 @@ public function testAfterLoad() $this->urlBuilder->expects($this->once()) ->method('getBaseUrl') ->with(['_type' => UrlInterface::URL_TYPE_MEDIA]) - ->willReturn('http://magento2.com/pub/media/'); + ->willReturn('http://magento2.com/media/'); $this->mediaDirectory->expects($this->once()) ->method('getRelativePath') ->with('value') @@ -212,7 +212,7 @@ public function testAfterLoad() $this->assertEquals( [ [ - 'url' => 'http://magento2.com/pub/media/design/file/' . $value, + 'url' => 'http://magento2.com/media/design/file/' . $value, 'file' => $value, 'size' => 234234, 'exists' => true, @@ -241,7 +241,7 @@ public function testBeforeSave(string $fileName) 'scope_id' => 1, 'value' => [ [ - 'url' => 'http://magento2.com/pub/media/tmp/image/' . $fileName, + 'url' => 'http://magento2.com/media/tmp/image/' . $fileName, 'file' => $fileName, 'size' => 234234, ] @@ -314,7 +314,7 @@ public function testBeforeSaveWithExistingFile() [ 'value' => [ [ - 'url' => 'http://magento2.com/pub/media/tmp/image/' . $value, + 'url' => 'http://magento2.com/media/tmp/image/' . $value, 'file' => $value, 'size' => 234234, 'exists' => true @@ -358,7 +358,7 @@ public function getRelativeMediaPathDataProvider(): array { return [ 'Normal path' => ['pub/media/', 'filename.jpg'], - 'Complex path' => ['some_path/pub/media/', 'filename.jpg'], + 'Complex path' => ['some_path/media/', 'filename.jpg'], ]; } } diff --git a/app/code/Magento/Theme/Test/Unit/Model/Design/Config/FileUploader/FileProcessorTest.php b/app/code/Magento/Theme/Test/Unit/Model/Design/Config/FileUploader/FileProcessorTest.php index ab7d622801f63..c16d7a49a7e6f 100644 --- a/app/code/Magento/Theme/Test/Unit/Model/Design/Config/FileUploader/FileProcessorTest.php +++ b/app/code/Magento/Theme/Test/Unit/Model/Design/Config/FileUploader/FileProcessorTest.php @@ -111,7 +111,7 @@ public function testSaveToTmp() $this->store->expects($this->once()) ->method('getBaseUrl') ->with(UrlInterface::URL_TYPE_MEDIA) - ->willReturn('http://magento2.com/pub/media/'); + ->willReturn('http://magento2.com/media/'); $this->directoryWrite->expects($this->once()) ->method('getAbsolutePath') ->with('tmp/' . FileProcessor::FILE_DIR) @@ -160,7 +160,7 @@ public function testSaveToTmp() 'name' => 'file.jpg', 'size' => '234234', 'type' => 'image/jpg', - 'url' => 'http://magento2.com/pub/media/tmp/' . FileProcessor::FILE_DIR . '/file.jpg' + 'url' => 'http://magento2.com/media/tmp/' . FileProcessor::FILE_DIR . '/file.jpg' ], $this->fileProcessor->saveToTmp($fieldCode) ); diff --git a/app/code/Magento/Theme/Test/Unit/Model/Favicon/FaviconTest.php b/app/code/Magento/Theme/Test/Unit/Model/Favicon/FaviconTest.php index 77cf71f75ac28..0ccaf9e65b675 100644 --- a/app/code/Magento/Theme/Test/Unit/Model/Favicon/FaviconTest.php +++ b/app/code/Magento/Theme/Test/Unit/Model/Favicon/FaviconTest.php @@ -105,7 +105,7 @@ public function testGetFaviconFileNegative() public function testGetFaviconFile() { $scopeConfigValue = 'path'; - $urlToMediaDir = 'http://magento.url/pub/media/'; + $urlToMediaDir = 'http://magento.url/media/'; $expectedFile = ImageFavicon::UPLOAD_DIR . '/' . $scopeConfigValue; $expectedUrl = $urlToMediaDir . $expectedFile; diff --git a/app/code/Magento/Theme/Test/Unit/Model/Indexer/Design/ConfigTest.php b/app/code/Magento/Theme/Test/Unit/Model/Indexer/Design/ConfigTest.php index ff9a4302f49c0..753a05eda21bb 100644 --- a/app/code/Magento/Theme/Test/Unit/Model/Indexer/Design/ConfigTest.php +++ b/app/code/Magento/Theme/Test/Unit/Model/Indexer/Design/ConfigTest.php @@ -10,82 +10,196 @@ */ namespace Magento\Theme\Test\Unit\Model\Indexer\Design; +use Magento\Framework\App\ResourceConnection; use Magento\Framework\Data\Collection; use Magento\Framework\Indexer\FieldsetInterface; use Magento\Framework\Indexer\FieldsetPool; use Magento\Framework\Indexer\HandlerInterface; use Magento\Framework\Indexer\HandlerPool; use Magento\Framework\Indexer\IndexStructureInterface; -use Magento\Framework\Indexer\SaveHandler\IndexerInterface; +use Magento\Framework\Indexer\SaveHandler\Batch; use Magento\Framework\Indexer\SaveHandlerFactory; +use Magento\Framework\Indexer\ScopeResolver\FlatScopeResolver; +use Magento\Framework\Indexer\ScopeResolver\IndexScopeResolver; use Magento\Framework\Indexer\StructureFactory; +use Magento\Theme\Model\Data\Design\Config as DesignConfig; use Magento\Theme\Model\Indexer\Design\Config; +use Magento\Theme\Model\ResourceModel\Design\Config\Scope\CollectionFactory; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Magento\Theme\Model\Indexer\Design\IndexerHandler; +use Magento\Framework\DB\Adapter\AdapterInterface; +/** + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ class ConfigTest extends TestCase { - /** @var Config */ - protected $model; + /** + * @var AdapterInterface|MockObject + */ + private $adapter; + /** + * @var ResourceConnection|MockObject + */ + private $resourceConnection; + /** + * @var Batch|MockObject + */ + private $batch; + /** + * @var IndexStructureInterface|MockObject + */ + private $indexerStructure; + /** + * @var IndexScopeResolver|MockObject + */ + private $indexScopeResolver; + /** + * @var FlatScopeResolver|MockObject + */ + private $flatScopeResolver; + /** + * @var SaveHandlerFactory|MockObject + */ + private $saveHandlerFactory; + /** + * @var StructureFactory|MockObject + */ + private $structureFactory; + /** + * @var FieldsetInterface|MockObject + */ + private $indexerFieldset; + /** + * @var FieldsetPool|MockObject + */ + private $fieldsetPool; + /** + * @var HandlerInterface|MockObject + */ + private $indexerHandler; + /** + * @var HandlerPool|MockObject + */ + private $handlerPool; + /** + * @var Collection|MockObject + */ + private $collection; + /** + * @var CollectionFactory|MockObject + */ + private $collectionFactory; protected function setUp(): void { - $indexerStructure = $this->getMockBuilder(IndexStructureInterface::class) + $this->indexerStructure = $this->getMockBuilder(IndexStructureInterface::class) ->getMockForAbstractClass(); - $structureFactory = $this->getMockBuilder(StructureFactory::class) + $this->structureFactory = $this->getMockBuilder(StructureFactory::class) ->disableOriginalConstructor() ->getMock(); - $structureFactory->expects($this->any()) - ->method('create') - ->willReturn($indexerStructure); - - $indexer = $this->getMockBuilder(IndexerInterface::class) + $this->resourceConnection = $this->getMockBuilder(ResourceConnection::class) + ->disableOriginalConstructor() + ->getMock(); + $this->adapter = $this->getMockBuilder(AdapterInterface::class) ->getMockForAbstractClass(); - $saveHandlerFactory = $this->getMockBuilder(SaveHandlerFactory::class) + $this->batch = $this->getMockBuilder(Batch::class) + ->disableOriginalConstructor() + ->getMock(); + $this->indexScopeResolver = $this->getMockBuilder(IndexScopeResolver::class) + ->disableOriginalConstructor() + ->getMock(); + $this->flatScopeResolver = $this->getMockBuilder(FlatScopeResolver::class) + ->disableOriginalConstructor() + ->getMock(); + $this->saveHandlerFactory = $this->getMockBuilder(SaveHandlerFactory::class) + ->disableOriginalConstructor() + ->getMock(); + $this->fieldsetPool = $this->getMockBuilder(FieldsetPool::class) ->disableOriginalConstructor() ->getMock(); - $saveHandlerFactory->expects($this->any()) + $this->collection = $this->getMockBuilder(Collection::class) + ->disableOriginalConstructor() + ->getMock(); + $this->collectionFactory = $this->getMockBuilder(CollectionFactory::class) + ->disableOriginalConstructor() + ->getMock(); + $this->indexerHandler = $this->getMockBuilder(HandlerInterface::class) + ->getMockForAbstractClass(); + $this->handlerPool = $this->getMockBuilder(HandlerPool::class) + ->disableOriginalConstructor() + ->getMock(); + $this->indexerFieldset = $this->getMockBuilder(FieldsetInterface::class) + ->getMockForAbstractClass(); + } + + /** + * Generate flat index table name from design config grid index ID + * + * @return string + */ + private function getFlatIndexTableName(): string + { + return DesignConfig::DESIGN_CONFIG_GRID_INDEXER_ID . '_flat'; + } + + /** + * Initialize and return Design Config Indexer Model + * + * @return Config + */ + private function getDesignConfigIndexerModel(): Config + { + $this->structureFactory->expects($this->any()) + ->method('create') + ->willReturn($this->indexerStructure); + $this->resourceConnection + ->expects($this->any()) + ->method('getConnection') + ->willReturn($this->adapter); + $this->flatScopeResolver->expects($this->any()) + ->method('resolve') + ->willReturn($this->getFlatIndexTableName()); + + $indexer = new IndexerHandler( + $this->indexerStructure, + $this->resourceConnection, + $this->batch, + $this->indexScopeResolver, + $this->flatScopeResolver, + [ + 'fieldsets' => [], + 'indexer_id' => DesignConfig::DESIGN_CONFIG_GRID_INDEXER_ID + ] + ); + + $this->saveHandlerFactory->expects($this->any()) ->method('create') ->willReturn($indexer); - $indexerFieldset = $this->getMockBuilder(FieldsetInterface::class) - ->getMockForAbstractClass(); - $indexerFieldset->expects($this->any()) + $this->indexerFieldset->expects($this->any()) ->method('addDynamicData') ->willReturnArgument(0); - $fieldsetPool = $this->getMockBuilder(FieldsetPool::class) - ->disableOriginalConstructor() - ->getMock(); - $fieldsetPool->expects($this->any()) + + $this->fieldsetPool->expects($this->any()) ->method('get') - ->willReturn($indexerFieldset); + ->willReturn($this->indexerFieldset); - $indexerHandler = $this->getMockBuilder(HandlerInterface::class) - ->getMockForAbstractClass(); - $handlerPool = $this->getMockBuilder(HandlerPool::class) - ->disableOriginalConstructor() - ->getMock(); - $handlerPool->expects($this->any()) + $this->handlerPool->expects($this->any()) ->method('get') - ->willReturn($indexerHandler); + ->willReturn($this->indexerHandler); - $collection = $this->getMockBuilder(Collection::class) - ->disableOriginalConstructor() - ->getMock(); - $collectionFactory = - $this->getMockBuilder(\Magento\Theme\Model\ResourceModel\Design\Config\Scope\CollectionFactory::class) - ->disableOriginalConstructor() - ->setMethods(['create']) - ->getMock(); - $collectionFactory->expects($this->any()) + $this->collectionFactory->expects($this->any()) ->method('create') - ->willReturn($collection); - - $this->model = new Config( - $structureFactory, - $saveHandlerFactory, - $fieldsetPool, - $handlerPool, - $collectionFactory, + ->willReturn($this->collection); + + return new Config( + $this->structureFactory, + $this->saveHandlerFactory, + $this->fieldsetPool, + $this->handlerPool, + $this->collectionFactory, [ 'fieldsets' => ['test_fieldset' => [ 'fields' => [ @@ -102,7 +216,7 @@ protected function setUp(): void 'handler' => null, ], ], - 'provider' => $indexerFieldset, + 'provider' => $this->indexerFieldset, ] ], 'saveHandler' => 'saveHandlerClass', @@ -111,9 +225,46 @@ protected function setUp(): void ); } - public function testExecuteFull() + public function testFullReindex() { - $result = $this->model->executeFull(); - $this->assertNull($result); + $this->adapter->expects($this->any()) + ->method('isTableExists') + ->willReturn(true); + $this->indexerStructure->expects($this->never())->method('create') + ->with(DesignConfig::DESIGN_CONFIG_GRID_INDEXER_ID); + $this->adapter->expects($this->once())->method('delete') + ->with($this->getFlatIndexTableName()); + $this->batch->expects($this->any()) + ->method('getItems')->willReturn([]); + + $this->getDesignConfigIndexerModel()->executeFull(); + } + + public function testFullReindexWithFlatTableCreate() + { + $this->adapter->expects($this->any())->method('isTableExists') + ->willReturn(false); + $this->indexerStructure->expects($this->once())->method('create') + ->with(DesignConfig::DESIGN_CONFIG_GRID_INDEXER_ID); + $this->adapter->expects($this->never())->method('delete') + ->with($this->getFlatIndexTableName()); + $this->batch->expects($this->any())->method('getItems') + ->willReturn([]); + + $this->getDesignConfigIndexerModel()->executeFull(); + } + + public function testPartialReindex() + { + $this->adapter->expects($this->any())->method('isTableExists') + ->willReturn(true); + $this->indexerStructure->expects($this->never())->method('create') + ->with(DesignConfig::DESIGN_CONFIG_GRID_INDEXER_ID); + $this->adapter->expects($this->once())->method('delete') + ->with($this->getFlatIndexTableName(), ['entity_id IN(?)' => [1, 2, 3]]); + $this->batch->expects($this->any())->method('getItems') + ->willReturn([[1, 2, 3]]); + + $this->getDesignConfigIndexerModel()->executeList([1, 2, 3]); } } diff --git a/app/code/Magento/Theme/Test/Unit/Model/PageLayout/Config/BuilderTest.php b/app/code/Magento/Theme/Test/Unit/Model/PageLayout/Config/BuilderTest.php index d9eccdb871222..2e2117b79e5ab 100644 --- a/app/code/Magento/Theme/Test/Unit/Model/PageLayout/Config/BuilderTest.php +++ b/app/code/Magento/Theme/Test/Unit/Model/PageLayout/Config/BuilderTest.php @@ -10,8 +10,11 @@ */ namespace Magento\Theme\Test\Unit\Model\PageLayout\Config; +use Magento\Framework\App\Cache\Type\Layout; +use Magento\Framework\Serialize\SerializerInterface; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; use Magento\Framework\View\PageLayout\Config; +use Magento\Framework\View\PageLayout\ConfigFactory; use Magento\Framework\View\PageLayout\File\Collector\Aggregated; use Magento\Theme\Model\PageLayout\Config\Builder; use Magento\Theme\Model\ResourceModel\Theme\Collection; @@ -27,7 +30,7 @@ class BuilderTest extends TestCase protected $builder; /** - * @var \Magento\Framework\View\PageLayout\ConfigFactory|MockObject + * @var ConfigFactory|MockObject */ protected $configFactory; @@ -41,6 +44,15 @@ class BuilderTest extends TestCase */ protected $themeCollection; + /** + * @var Layout|MockObject + */ + protected $cacheModel; + /** + * @var SerializerInterface|MockObject + */ + protected $serializer; + /** * SetUp method * @@ -48,19 +60,24 @@ class BuilderTest extends TestCase */ protected function setUp(): void { - $this->configFactory = $this->getMockBuilder(\Magento\Framework\View\PageLayout\ConfigFactory::class) + $this->configFactory = $this->getMockBuilder(ConfigFactory::class) ->disableOriginalConstructor() ->setMethods(['create']) ->getMock(); - $this->fileCollector = $this->getMockBuilder( - Aggregated::class - )->disableOriginalConstructor() + $this->fileCollector = $this->getMockBuilder(Aggregated::class) + ->disableOriginalConstructor() ->getMock(); $this->themeCollection = $this->getMockBuilder(Collection::class) ->disableOriginalConstructor() ->getMock(); + $this->cacheModel = $this->getMockBuilder(Layout::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->serializer = $this->getMockForAbstractClass(SerializerInterface::class); + $this->themeCollection->expects($this->once()) ->method('setItemObjectClass') ->with(Data::class) @@ -72,7 +89,9 @@ protected function setUp(): void [ 'configFactory' => $this->configFactory, 'fileCollector' => $this->fileCollector, - 'themeCollection' => $this->themeCollection + 'themeCollection' => $this->themeCollection, + 'cacheModel' => $this->cacheModel, + 'serializer' => $this->serializer, ] ); } @@ -84,8 +103,10 @@ protected function setUp(): void */ public function testGetPageLayoutsConfig() { + $this->cacheModel->clean(); $files1 = ['content layouts_1.xml', 'content layouts_2.xml']; $files2 = ['content layouts_3.xml', 'content layouts_4.xml']; + $configFiles = array_merge($files1, $files2); $theme1 = $this->getMockBuilder(Data::class) ->disableOriginalConstructor() @@ -113,9 +134,17 @@ public function testGetPageLayoutsConfig() $this->configFactory->expects($this->once()) ->method('create') - ->with(['configFiles' => array_merge($files1, $files2)]) + ->with(['configFiles' => $configFiles]) ->willReturn($config); + $this->serializer->expects($this->once()) + ->method('serialize') + ->with($configFiles); + + $this->cacheModel->expects($this->once()) + ->method('save') + ->willReturnSelf(); + $this->assertSame($config, $this->builder->getPageLayoutsConfig()); } } diff --git a/app/code/Magento/Theme/etc/indexer.xml b/app/code/Magento/Theme/etc/indexer.xml index 7ed25878e383c..8cc8971024e48 100644 --- a/app/code/Magento/Theme/etc/indexer.xml +++ b/app/code/Magento/Theme/etc/indexer.xml @@ -17,7 +17,7 @@ <field name="store_group_id" xsi:type="filterable" dataType="int"/> <field name="store_id" xsi:type="filterable" dataType="int"/> </fieldset> - <saveHandler class="Magento\Framework\Indexer\SaveHandler\Grid"/> + <saveHandler class="Magento\Theme\Model\Indexer\Design\IndexerHandler"/> <structure class="Magento\Framework\Indexer\GridStructure"/> </indexer> </config> diff --git a/app/code/Magento/Theme/view/base/requirejs-config.js b/app/code/Magento/Theme/view/base/requirejs-config.js index 4bd854f2e4670..1a001ce62b93f 100644 --- a/app/code/Magento/Theme/view/base/requirejs-config.js +++ b/app/code/Magento/Theme/view/base/requirejs-config.js @@ -31,7 +31,7 @@ var config = { paths: { 'jquery/validate': 'jquery/jquery.validate', 'jquery/hover-intent': 'jquery/jquery.hoverIntent', - 'jquery/file-uploader': 'jquery/fileUploader/jquery.fileupload-fp', + 'jquery/file-uploader': 'jquery/fileUploader/jquery.fileuploader', 'prototype': 'legacy-build.min', 'jquery/jquery-storageapi': 'jquery/jquery.storageapi.min', 'text': 'mage/requirejs/text', diff --git a/app/code/Magento/Theme/view/frontend/layout/default.xml b/app/code/Magento/Theme/view/frontend/layout/default.xml index 8eaac4aa3e794..bf76933b356c0 100644 --- a/app/code/Magento/Theme/view/frontend/layout/default.xml +++ b/app/code/Magento/Theme/view/frontend/layout/default.xml @@ -8,6 +8,7 @@ <page layout="3columns" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd"> <update handle="default_head_blocks"/> <body> + <attribute name="id" value="html-body"/> <block name="require.js" class="Magento\Framework\View\Element\Template" template="Magento_Theme::page/js/require_js.phtml" /> <referenceContainer name="after.body.start"> <block class="Magento\RequireJs\Block\Html\Head\Config" name="requirejs-config"/> diff --git a/app/code/Magento/Theme/view/frontend/templates/html/sections.phtml b/app/code/Magento/Theme/view/frontend/templates/html/sections.phtml index 7f1128b3b07c7..9c6cf4fa07cf2 100644 --- a/app/code/Magento/Theme/view/frontend/templates/html/sections.phtml +++ b/app/code/Magento/Theme/view/frontend/templates/html/sections.phtml @@ -11,12 +11,12 @@ $group = $block->getGroupName(); $groupCss = $block->getGroupCss(); ?> -<?php if ($detailedInfoGroup = $block->getGroupChildNames($group, 'getChildHtml')) :?> +<?php if ($detailedInfoGroup = $block->getGroupChildNames($group)):?> <div class="sections <?= $block->escapeHtmlAttr($groupCss) ?>"> <?php $layout = $block->getLayout(); ?> <div class="section-items <?= $block->escapeHtmlAttr($groupCss) ?>-items" data-mage-init='{"tabs":{"openedState":"active"}}'> - <?php foreach ($detailedInfoGroup as $name) :?> + <?php foreach ($detailedInfoGroup as $name):?> <?php $html = $layout->renderElement($name); if (!trim($html) && ($block->getUseForce() != true)) { diff --git a/app/code/Magento/Tinymce3/Test/Mftf/Test/AdminSwitchWYSIWYGOptionsTest.xml b/app/code/Magento/Tinymce3/Test/Mftf/Test/AdminSwitchWYSIWYGOptionsTest.xml index ddb6c4071a0e7..0ba20d201f909 100644 --- a/app/code/Magento/Tinymce3/Test/Mftf/Test/AdminSwitchWYSIWYGOptionsTest.xml +++ b/app/code/Magento/Tinymce3/Test/Mftf/Test/AdminSwitchWYSIWYGOptionsTest.xml @@ -25,8 +25,7 @@ <actionGroup ref="AdminLoginActionGroup" stepKey="loginGetFromGeneralFile"/> <actionGroup ref="EnabledWYSIWYGActionGroup" stepKey="enableWYSIWYG"/> </before> - <amOnPage url="{{ConfigurationStoresPage.url}}" stepKey="navigateToWYSIWYGConfigPage1"/> - <waitForPageLoad stepKey="wait1"/> + <actionGroup ref="AdminOpenConfigurationStoresPageActionGroup" stepKey="navigateToWYSIWYGConfigPage1"/> <conditionalClick stepKey="expandWYSIWYGOptions1" selector="{{ContentManagementSection.WYSIWYGOptions}}" dependentSelector="{{ContentManagementSection.CheckIfTabExpand}}" visible="true" /> <waitForElementVisible selector="{{ContentManagementSection.SwitcherSystemValue}}" stepKey="waitForCheckbox1" /> <uncheckOption selector="{{ContentManagementSection.SwitcherSystemValue}}" stepKey="uncheckUseSystemValue1"/> @@ -52,8 +51,7 @@ <waitForPageLoad stepKey="wait3" /> <!--see widget on Storefront--> <see userInput="Hello TinyMCE4!" stepKey="seeContent1"/> - <amOnPage url="{{ConfigurationStoresPage.url}}" stepKey="navigateToWYSIWYGConfigPage2"/> - <waitForPageLoad stepKey="wait4"/> + <actionGroup ref="AdminOpenConfigurationStoresPageActionGroup" stepKey="navigateToWYSIWYGConfigPage2"/> <conditionalClick stepKey="expandWYSIWYGOptions" selector="{{ContentManagementSection.WYSIWYGOptions}}" dependentSelector="{{ContentManagementSection.CheckIfTabExpand}}" visible="true" /> <waitForElementVisible selector="{{ContentManagementSection.SwitcherSystemValue}}" stepKey="waitForCheckbox2" /> <uncheckOption selector="{{ContentManagementSection.SwitcherSystemValue}}" stepKey="uncheckUseSystemValue2"/> diff --git a/app/code/Magento/Translation/Test/Mftf/Test/StorefrontInlineTranslationOnCheckoutTest.xml b/app/code/Magento/Translation/Test/Mftf/Test/StorefrontInlineTranslationOnCheckoutTest.xml index e30ab98982b78..4eff032ce160e 100644 --- a/app/code/Magento/Translation/Test/Mftf/Test/StorefrontInlineTranslationOnCheckoutTest.xml +++ b/app/code/Magento/Translation/Test/Mftf/Test/StorefrontInlineTranslationOnCheckoutTest.xml @@ -120,8 +120,8 @@ </actionGroup> <!-- 3. Go to storefront and click on cart button on the top --> - <reloadPage stepKey="reloadPage"/> - <waitForPageLoad stepKey="waitForReload"/> + <actionGroup ref="ReloadPageActionGroup" stepKey="reloadPage"/> + <comment userInput="Replacing reload action and preserve Backward Compatibility" stepKey="waitForReload"/> <actionGroup ref="StorefrontOpenMiniCartActionGroup" stepKey="openMiniCart"/> <!-- Check button "Proceed to Checkout". There must be red borders and "book" icons on labels that can be translated. --> @@ -490,8 +490,9 @@ <resetCookie userInput="mage-translation-file-version" stepKey="resetTranslationFileVersion"/> <!-- Reload page after full clear --> - <reloadPage stepKey="reloadPageAfterFullClean"/> - <waitForPageLoad stepKey="waitForPageLoadAfterFullClean"/> + <actionGroup ref="ReloadPageActionGroup" stepKey="reloadPageAfterFullClean"/> + <comment userInput="Replacing reload action and preserve Backward Compatibility" stepKey="waitForPageLoadAfterFullClean"/> + <!-- Add product to cart and go through Checkout process like you did in steps ##3-6 and check translation you maid. --> <actionGroup ref="StorefrontOpenProductEntityPageActionGroup" stepKey="openProductPage1"> diff --git a/app/code/Magento/Ui/Component/Form/Element/DataType/Date.php b/app/code/Magento/Ui/Component/Form/Element/DataType/Date.php index ef2df77e7daff..3600992011ed6 100644 --- a/app/code/Magento/Ui/Component/Form/Element/DataType/Date.php +++ b/app/code/Magento/Ui/Component/Form/Element/DataType/Date.php @@ -111,7 +111,7 @@ public function getComponentName() public function convertDate($date, $hour = 0, $minute = 0, $second = 0, $setUtcTimeZone = true) { try { - $dateObj = $this->localeDate->date($date, $this->getLocale(), false); + $dateObj = $this->localeDate->date($date, $this->getLocale(), false, false); $dateObj->setTime($hour, $minute, $second); //convert store date to default date in UTC timezone without DST if ($setUtcTimeZone) { diff --git a/app/code/Magento/Ui/Component/Listing/Columns/Column.php b/app/code/Magento/Ui/Component/Listing/Columns/Column.php index e69658540c51f..a4abf7551c5ce 100644 --- a/app/code/Magento/Ui/Component/Listing/Columns/Column.php +++ b/app/code/Magento/Ui/Component/Listing/Columns/Column.php @@ -5,10 +5,10 @@ */ namespace Magento\Ui\Component\Listing\Columns; -use Magento\Ui\Component\AbstractComponent; +use Magento\Framework\View\Element\UiComponent\ContextInterface; use Magento\Framework\View\Element\UiComponentFactory; use Magento\Framework\View\Element\UiComponentInterface; -use Magento\Framework\View\Element\UiComponent\ContextInterface; +use Magento\Ui\Component\AbstractComponent; /** * @api @@ -64,6 +64,7 @@ public function getComponentName() * Prepare component configuration * * @return void + * @throws \Magento\Framework\Exception\LocalizedException */ public function prepare() { @@ -97,18 +98,19 @@ public function prepare() } /** - * To prepare items of a column + * Prepares items of a column * * @param array $items * @return array */ - public function prepareItems(array & $items) + public function prepareItems(array &$items) { return $items; } /** - * Add field to select + * Adds additional field to select object + * * @return void */ protected function addFieldToSelect() @@ -131,6 +133,7 @@ protected function applySorting() && !empty($sorting['field']) && !empty($sorting['direction']) && $sorting['field'] === $this->getName() + && in_array(strtoupper($sorting['direction']), ['ASC', 'DESC'], true) ) { $this->getContext()->getDataProvider()->addOrder( $this->getName(), diff --git a/app/code/Magento/Ui/Model/Manager.php b/app/code/Magento/Ui/Model/Manager.php index 357a41285e275..ce9e4e51ea7fe 100644 --- a/app/code/Magento/Ui/Model/Manager.php +++ b/app/code/Magento/Ui/Model/Manager.php @@ -298,6 +298,7 @@ protected function createDataForComponent($name, array $componentsPool) $createdComponents = []; $rootComponent = $this->createRawComponentData($name, false); foreach ($componentsPool as $key => $component) { + $resultConfiguration = []; $resultConfiguration = [ManagerInterface::CHILDREN_KEY => []]; $instanceName = $this->createName($component, $key, $name); $resultConfiguration[ManagerInterface::COMPONENT_ARGUMENTS_KEY] = $this->mergeArguments( @@ -312,15 +313,16 @@ protected function createDataForComponent($name, array $componentsPool) unset($component[Converter::DATA_ATTRIBUTES_KEY]); // Create inner components + $children = []; foreach ($component as $subComponentName => $subComponent) { if (is_array($subComponent)) { - // phpcs:ignore Magento2.Performance.ForeachArrayMerge - $resultConfiguration[ManagerInterface::CHILDREN_KEY] = array_merge( - $resultConfiguration[ManagerInterface::CHILDREN_KEY], - $this->createDataForComponent($subComponentName, $subComponent) - ); + $children[] = $this->createDataForComponent($subComponentName, $subComponent); } } + + // phpcs:ignore Magento2.Performance.ForeachArrayMerge + $resultConfiguration[ManagerInterface::CHILDREN_KEY] = array_merge([], ...$children); + $createdComponents[$instanceName] = $resultConfiguration; } diff --git a/app/code/Magento/Ui/Test/Mftf/ActionGroup/AdminAssertNumberOfRecordsInUiGridActionGroup.xml b/app/code/Magento/Ui/Test/Mftf/ActionGroup/AdminAssertNumberOfRecordsInUiGridActionGroup.xml new file mode 100644 index 0000000000000..5928833bf4794 --- /dev/null +++ b/app/code/Magento/Ui/Test/Mftf/ActionGroup/AdminAssertNumberOfRecordsInUiGridActionGroup.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminAssertNumberOfRecordsInUiGridActionGroup"> + <annotations> + <description>Validates that the Number of Records listed on the Ui grid page is present and correct.</description> + </annotations> + <arguments> + <argument name="number" type="string" defaultValue="1"/> + </arguments> + <see userInput="{{number}} records found" selector="{{AdminGridHeaders.totalRecords}}" stepKey="seeRecords"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Ui/Test/Mftf/ActionGroup/AdminGridBulkActionGroup.xml b/app/code/Magento/Ui/Test/Mftf/ActionGroup/AdminGridBulkActionGroup.xml new file mode 100644 index 0000000000000..9db9ea7becfc8 --- /dev/null +++ b/app/code/Magento/Ui/Test/Mftf/ActionGroup/AdminGridBulkActionGroup.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminGridBulkActionGroup"> + <annotations> + <description> + Massive action for all rows on Admin Grid page. + </description> + </annotations> + <arguments> + <argument name="actionLabel" type="string"/> + </arguments> + + <click selector="{{AdminGridSelectRows.multicheckDropdown}}" stepKey="openMulticheckDropdown"/> + <click selector="{{AdminGridSelectRows.multicheckOption('Select All')}}" stepKey="selectAllRows"/> + <click selector="{{AdminGridSelectRows.bulkActionDropdown}}" stepKey="clickActionDropdown"/> + <click selector="{{AdminGridSelectRows.bulkActionOption(actionLabel)}}" stepKey="clickActionLabel"/> + <waitForElementVisible selector="{{AdminMessagesSection.success}}" stepKey="waitForSuccessMessage"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Ui/Test/Mftf/ActionGroup/AdminGridColumnShowActionGroup.xml b/app/code/Magento/Ui/Test/Mftf/ActionGroup/AdminGridColumnShowActionGroup.xml new file mode 100644 index 0000000000000..6440cc01bcafe --- /dev/null +++ b/app/code/Magento/Ui/Test/Mftf/ActionGroup/AdminGridColumnShowActionGroup.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminGridColumnShowActionGroup"> + <annotations> + <description> + Shows new column on Admin Grid page. + </description> + </annotations> + <arguments> + <argument name="columnLabel" type="string"/> + </arguments> + + <click selector="{{AdminDataGridHeaderSection.columnsToggle}}" stepKey="openColumnsTab"/> + <checkOption selector="{{AdminDataGridHeaderSection.columnCheckbox(columnLabel)}}" stepKey="showNewColumn"/> + <click selector="{{AdminDataGridHeaderSection.columnsToggle}}" stepKey="closeColumnsTab"/> + <seeElement selector="{{AdminDataGridTableSection.columnHeader(columnLabel)}}" stepKey="seeNewColumnInGrid"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Ui/Test/Mftf/ActionGroup/AdminGridSelectAllActionGroup.xml b/app/code/Magento/Ui/Test/Mftf/ActionGroup/AdminGridSelectAllActionGroup.xml new file mode 100644 index 0000000000000..bbfb7e46d89ec --- /dev/null +++ b/app/code/Magento/Ui/Test/Mftf/ActionGroup/AdminGridSelectAllActionGroup.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminGridSelectAllActionGroup"> + <annotations> + <description>Click on select all option on the grid</description> + </annotations> + + <waitForElementVisible selector="{{AdminGridSelectRows.multicheckDropdown}}" stepKey="waitForElement"/> + <click selector="{{AdminGridSelectRows.multicheckDropdown}}" stepKey="openMulticheckDropdown"/> + <click selector="{{AdminGridSelectRows.multicheckOption('Select All')}}" stepKey="clickSelectAllCustomers"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Ui/Test/Mftf/ActionGroup/ReloadPageActionGroup.xml b/app/code/Magento/Ui/Test/Mftf/ActionGroup/ReloadPageActionGroup.xml new file mode 100644 index 0000000000000..3976a2ac0f872 --- /dev/null +++ b/app/code/Magento/Ui/Test/Mftf/ActionGroup/ReloadPageActionGroup.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="ReloadPageActionGroup"> + <annotations> + <description>Reload page and wait for page load.</description> + </annotations> + + <reloadPage stepKey="reloadPage"/> + <waitForPageLoad stepKey="waitForPageLoad"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Ui/Test/Mftf/Section/AdminGridControlsSection/AdminGridHeadersSection.xml b/app/code/Magento/Ui/Test/Mftf/Section/AdminGridControlsSection/AdminGridHeadersSection.xml index 89831359657bf..c7aa7604d7ade 100644 --- a/app/code/Magento/Ui/Test/Mftf/Section/AdminGridControlsSection/AdminGridHeadersSection.xml +++ b/app/code/Magento/Ui/Test/Mftf/Section/AdminGridControlsSection/AdminGridHeadersSection.xml @@ -11,5 +11,6 @@ <element name="title" type="text" selector=".page-title-wrapper h1"/> <element name="headerByName" type="text" selector="//div[@data-role='grid-wrapper']//span[@class='data-grid-cell-content' and contains(text(), '{{var1}}')]/parent::*" parameterized="true"/> <element name="columnsNames" type="text" selector="[data-role='grid-wrapper'] .data-grid-th > span"/> + <element name="totalRecords" type="text" selector="div.admin__data-grid-header-row.row.row-gutter div.row div.admin__control-support-text"/> </section> </sections> diff --git a/app/code/Magento/Ui/Test/Unit/Component/Listing/Columns/AttributeSetIdTest.php b/app/code/Magento/Ui/Test/Unit/Component/Listing/Columns/AttributeSetIdTest.php new file mode 100644 index 0000000000000..50515105e82ae --- /dev/null +++ b/app/code/Magento/Ui/Test/Unit/Component/Listing/Columns/AttributeSetIdTest.php @@ -0,0 +1,55 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types = 1); + +namespace Magento\Ui\Test\Unit\Component\Listing\Columns; + +use Magento\Catalog\Ui\Component\Listing\Columns\AttributeSetId; +use Magento\Framework\DB\Select; +use Magento\Framework\View\Element\UiComponent\DataProvider\DataProviderInterface; +use Magento\Eav\Model\Entity\Collection\AbstractCollection; + +/** + * Testing for the AttributeSetID UI column + */ +class AttributeSetIdTest extends ColumnTest +{ + /** + * @var string + */ + protected $columnClass = AttributeSetId::class; + + /** + * @inheritDoc + */ + public function testPrepare() + { + $collectionMock = $this->getMockBuilder(AbstractCollection::class) + ->disableOriginalConstructor() + ->getMock(); + + $selectMock = $this->createMock(Select::class); + + $selectMock->expects($this->once()) + ->method('order') + ->with('attribute_set_name asc'); + + $this->dataProviderMock = $this->getMockBuilder(DataProviderInterface::class) + ->addMethods(['getCollection', 'getSelect']) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + + $this->dataProviderMock->expects($this->once()) + ->method('getCollection') + ->willReturn($collectionMock); + + $collectionMock->expects($this->once()) + ->method('getSelect') + ->willReturn($selectMock); + + parent::testPrepare(); + } +} diff --git a/app/code/Magento/Ui/Test/Unit/Component/Listing/Columns/ColumnTest.php b/app/code/Magento/Ui/Test/Unit/Component/Listing/Columns/ColumnTest.php index 0bade901361a3..fb0f9f215163b 100644 --- a/app/code/Magento/Ui/Test/Unit/Component/Listing/Columns/ColumnTest.php +++ b/app/code/Magento/Ui/Test/Unit/Component/Listing/Columns/ColumnTest.php @@ -14,9 +14,11 @@ use Magento\Framework\View\Element\UiComponentFactory; use Magento\Framework\View\Element\UiComponentInterface; use Magento\Ui\Component\Listing\Columns\Column; -use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +/** + * Testing for generic UI column classes & for custom ones such as Websites + */ class ColumnTest extends TestCase { /** @@ -29,6 +31,23 @@ class ColumnTest extends TestCase */ protected $objectManager; + /** + * @var UiComponentFactory + */ + protected $uiComponentFactoryMock; + + protected $dataProviderMock; + + /** + * @var string + */ + protected $columnClass = Column::class; + + /** + * @var string + */ + protected $columnName = Column::NAME; + /** * Set up */ @@ -45,6 +64,8 @@ protected function setUp(): void true, [] ); + + $this->uiComponentFactoryMock = $this->createMock(UiComponentFactory::class); } /** @@ -56,7 +77,7 @@ public function testGetComponentName() { $this->contextMock->expects($this->never())->method('getProcessor'); $column = $this->objectManager->getObject( - Column::class, + $this->columnClass, [ 'context' => $this->contextMock, 'data' => [ @@ -70,7 +91,7 @@ public function testGetComponentName() ] ); - $this->assertEquals($column->getComponentName(), Column::NAME . '.testType'); + $this->assertEquals($column->getComponentName(), $this->columnName . '.testType'); } /** @@ -82,7 +103,7 @@ public function testPrepareItems() { $testItems = ['item1','item2', 'item3']; $column = $this->objectManager->getObject( - Column::class, + $this->columnClass, ['context' => $this->contextMock] ); @@ -92,57 +113,70 @@ public function testPrepareItems() /** * Run test prepare method * + * @param null $dataProviderMock * @return void */ public function testPrepare() { - $processor = $this->getMockBuilder(Processor::class) - ->disableOriginalConstructor() - ->getMock(); - $this->contextMock->expects($this->atLeastOnce())->method('getProcessor')->willReturn($processor); $data = [ 'name' => 'test_name', 'js_config' => ['extends' => 'test_config_extends'], 'config' => ['dataType' => 'test_type', 'sortable' => true] ]; - /** @var UiComponentFactory|MockObject $uiComponentFactoryMock */ - $uiComponentFactoryMock = $this->createMock(UiComponentFactory::class); + /** @var Column $column */ + $column = $this->objectManager->getObject( + $this->columnClass, + [ + 'context' => $this->contextMock, + 'uiComponentFactory' => $this->uiComponentFactoryMock, + 'data' => $data + ] + ); - /** @var UiComponentInterface|MockObject $wrappedComponentMock */ + /** @var UiComponentInterface|PHPUnit\Framework\MockObject\MockObject $wrappedComponentMock */ $wrappedComponentMock = $this->getMockForAbstractClass( UiComponentInterface::class, [], '', false ); - /** @var DataProviderInterface|MockObject $dataProviderMock */ - $dataProviderMock = $this->getMockForAbstractClass( - DataProviderInterface::class, - [], - '', - false - ); + + if ($this->dataProviderMock === null) { + $this->dataProviderMock = $this->getMockForAbstractClass( + DataProviderInterface::class, + [], + '', + false + ); + + $this->dataProviderMock->expects($this->once()) + ->method('addOrder') + ->with('test_name', 'ASC'); + } + + $processor = $this->getMockBuilder(Processor::class) + ->disableOriginalConstructor() + ->getMock(); + $this->contextMock->expects($this->atLeastOnce()) + ->method('getProcessor') + ->willReturn($processor); $this->contextMock->expects($this->atLeastOnce()) ->method('getNamespace') ->willReturn('test_namespace'); $this->contextMock->expects($this->atLeastOnce()) ->method('getDataProvider') - ->willReturn($dataProviderMock); + ->willReturn($this->dataProviderMock); $this->contextMock->expects($this->atLeastOnce()) ->method('getRequestParam') ->with('sorting') ->willReturn(['field' => 'test_name', 'direction' => 'asc']); $this->contextMock->expects($this->atLeastOnce()) ->method('addComponentDefinition') - ->with(Column::NAME . '.test_type', ['extends' => 'test_config_extends']); + ->with($this->columnName . '.test_type', ['extends' => 'test_config_extends']); - $dataProviderMock->expects($this->once()) - ->method('addOrder') - ->with('test_name', 'ASC'); - - $uiComponentFactoryMock->expects($this->once()) + $this->uiComponentFactoryMock->expects($this->once()) ->method('create') ->with('test_name', 'test_type', array_merge(['context' => $this->contextMock], $data)) ->willReturn($wrappedComponentMock); @@ -153,16 +187,71 @@ public function testPrepare() $wrappedComponentMock->expects($this->once()) ->method('prepare'); - /** @var Column $column */ + $column->prepare(); + } + + /** + * Run a test on sorting function + * + * @param array $config + * @param string $direction + * @param int $numOfProviderCalls + * @throws \ReflectionException + * + * @dataProvider sortingDataProvider + */ + public function testSorting(array $config, string $direction, int $numOfProviderCalls) + { + $data = [ + 'name' => 'test_name', + 'config' => $config + ]; + + $this->dataProviderMock = $this->getMockForAbstractClass( + DataProviderInterface::class, + [], + '', + false + ); + + $this->dataProviderMock->expects($this->exactly($numOfProviderCalls)) + ->method('addOrder') + ->with('test_name', $direction); + + $this->contextMock->expects($this->atLeastOnce()) + ->method('getRequestParam') + ->with('sorting') + ->willReturn(['field' => 'test_name', 'direction' => $direction]); + + $this->contextMock->expects($this->exactly($numOfProviderCalls)) + ->method('getDataProvider') + ->willReturn($this->dataProviderMock); + $column = $this->objectManager->getObject( - Column::class, + $this->columnClass, [ 'context' => $this->contextMock, - 'uiComponentFactory' => $uiComponentFactoryMock, + 'uiComponentFactory' => $this->uiComponentFactoryMock, 'data' => $data ] ); - $column->prepare(); + // get access to the method + $method = new \ReflectionMethod( + Column::class, + 'applySorting' + ); + $method->setAccessible(true); + + $method->invokeArgs($column, []); + } + + public function sortingDataProvider() + { + return [ + [['dataType' => 'test_type', 'sortable' => true], 'ASC', 1], + [['dataType' => 'test_type', 'sortable' => false], 'ASC', 0], + [['dataType' => 'test_type', 'sortable' => true], 'foobar', 0] + ]; } } diff --git a/app/code/Magento/Ui/Test/Unit/Component/Listing/Columns/WebsitesTest.php b/app/code/Magento/Ui/Test/Unit/Component/Listing/Columns/WebsitesTest.php new file mode 100644 index 0000000000000..ba842f330b3b8 --- /dev/null +++ b/app/code/Magento/Ui/Test/Unit/Component/Listing/Columns/WebsitesTest.php @@ -0,0 +1,85 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types = 1); + +namespace Magento\Ui\Test\Unit\Component\Listing\Columns; + +use Magento\Catalog\Ui\Component\Listing\Columns\Websites; +use Magento\Framework\DB\Adapter\AdapterInterface; +use Magento\Framework\DB\Select; +use Magento\Framework\View\Element\UiComponent\DataProvider\DataProviderInterface; +use Magento\Eav\Model\Entity\Collection\AbstractCollection; + +/** + * Testing for the Websites UI column + */ +class WebsitesTest extends ColumnTest +{ + /** + * @var string + */ + protected $columnClass = Websites::class; + + /** + * @var string + */ + protected $columnName = Websites::NAME; + + /** + * @inheritDoc + */ + public function testPrepare() + { + $collectionMock = $this->getMockBuilder(AbstractCollection::class) + ->disableOriginalConstructor() + ->getMock(); + + $selectMock = $this->createMock(Select::class); + + $selectMock->expects($this->once()) + ->method('order'); + + $selectMock->expects($this->once()) + ->method('from') + ->willReturn($selectMock); + + $selectMock->expects($this->atLeastOnce()) + ->method('joinLeft') + ->willReturn($selectMock); + + $selectMock->expects($this->once()) + ->method('group'); + + $connectionMock = $this->createMock(AdapterInterface::class); + + $connectionMock->expects($this->once()) + ->method('select') + ->willReturn($selectMock); + + $this->dataProviderMock = $this->getMockBuilder(DataProviderInterface::class) + ->addMethods(['getCollection', 'getSelect']) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + + $this->dataProviderMock->expects($this->once()) + ->method('getCollection') + ->willReturn($collectionMock); + + $collectionMock->expects($this->once()) + ->method('getConnection') + ->willReturn($connectionMock); + + $collectionMock->expects($this->atLeastOnce()) + ->method('getTable') + ->willReturn('test_table'); + + $collectionMock->expects($this->once()) + ->method('getSelect') + ->willReturn($selectMock); + + parent::testPrepare(); + } +} diff --git a/app/code/Magento/Ui/view/base/web/js/form/components/insert.js b/app/code/Magento/Ui/view/base/web/js/form/components/insert.js index d8cbcc9cc1732..0484b3cc15a08 100644 --- a/app/code/Magento/Ui/view/base/web/js/form/components/insert.js +++ b/app/code/Magento/Ui/view/base/web/js/form/components/insert.js @@ -201,7 +201,7 @@ define([ var links = {}; _.each(data, function (value, key) { - if (value.split('.')[0] === ns) { + if (typeof value === 'string' && value.split('.')[0] === ns) { links[key] = value; } }); diff --git a/app/code/Magento/Ui/view/base/web/js/form/element/file-uploader.js b/app/code/Magento/Ui/view/base/web/js/form/element/file-uploader.js index 73bef62910644..e7dc245d47d6f 100644 --- a/app/code/Magento/Ui/view/base/web/js/form/element/file-uploader.js +++ b/app/code/Magento/Ui/view/base/web/js/form/element/file-uploader.js @@ -54,13 +54,14 @@ define([ this.$fileInput = fileInput; _.extend(this.uploaderConfig, { - dropZone: $(fileInput).closest(this.dropZone), - change: this.onFilesChoosed.bind(this), - drop: this.onFilesChoosed.bind(this), - add: this.onBeforeFileUpload.bind(this), - done: this.onFileUploaded.bind(this), - start: this.onLoadingStart.bind(this), - stop: this.onLoadingStop.bind(this) + dropZone: $(fileInput).closest(this.dropZone), + change: this.onFilesChoosed.bind(this), + drop: this.onFilesChoosed.bind(this), + add: this.onBeforeFileUpload.bind(this), + fail: this.onFail.bind(this), + done: this.onFileUploaded.bind(this), + start: this.onLoadingStart.bind(this), + stop: this.onLoadingStop.bind(this) }); $(fileInput).fileupload(this.uploaderConfig); @@ -328,11 +329,12 @@ define([ * May be used for implementation of additional validation rules, * e.g. total files and a total size rules. * - * @param {Event} e - Event object. + * @param {Event} event - Event object. * @param {Object} data - File data that will be uploaded. */ - onFilesChoosed: function (e, data) { - // no option exists in fileuploader for restricting upload chains to single files; this enforces that policy + onFilesChoosed: function (event, data) { + // no option exists in file uploader for restricting upload chains to single files + // this enforces that policy if (!this.isMultipleFiles) { data.files.splice(1); } @@ -341,13 +343,13 @@ define([ /** * Handler which is invoked prior to the start of a file upload. * - * @param {Event} e - Event object. + * @param {Event} event - Event object. * @param {Object} data - File data that will be uploaded. */ - onBeforeFileUpload: function (e, data) { - var file = data.files[0], - allowed = this.isFileAllowed(file), - target = $(e.target); + onBeforeFileUpload: function (event, data) { + var file = data.files[0], + allowed = this.isFileAllowed(file), + target = $(event.target); if (this.disabled()) { this.notifyError($t('The file upload field is disabled.')); @@ -356,7 +358,7 @@ define([ } if (allowed.passed) { - target.on('fileuploadsend', function (event, postData) { + target.on('fileuploadsend', function (eventBound, postData) { postData.data.append('param_name', this.paramName); }.bind(data)); @@ -386,16 +388,25 @@ define([ }); }, + /** + * @param {Event} event + * @param {Object} data + */ + onFail: function (event, data) { + console.error(data.jqXHR.responseText); + console.error(data.jqXHR.status); + }, + /** * Handler of the file upload complete event. * - * @param {Event} e + * @param {Event} event * @param {Object} data */ - onFileUploaded: function (e, data) { + onFileUploaded: function (event, data) { var uploadedFilename = data.files[0].name, - file = data.result, - error = file.error; + file = data.result, + error = file.error; error ? this.aggregateError(uploadedFilename, error) : @@ -469,10 +480,10 @@ define([ * Handler of the preview image load event. * * @param {Object} file - File associated with an image. - * @param {Event} e + * @param {Event} event */ - onPreviewLoad: function (file, e) { - var img = e.currentTarget; + onPreviewLoad: function (file, event) { + var img = event.currentTarget; file.previewWidth = img.naturalWidth; file.previewHeight = img.naturalHeight; diff --git a/app/code/Magento/Ui/view/base/web/js/form/element/region.js b/app/code/Magento/Ui/view/base/web/js/form/element/region.js index cd9c2aee85dc6..68b480d25a38c 100644 --- a/app/code/Magento/Ui/view/base/web/js/form/element/region.js +++ b/app/code/Magento/Ui/view/base/web/js/form/element/region.js @@ -23,6 +23,22 @@ define([ } }, + /** + * {@inheritdoc} + */ + initialize: function () { + var option; + + this._super(); + + option = _.find(this.countryOptions, function (row) { + return row['is_default'] === true; + }); + this.hideRegion(option); + + return this; + }, + /** * Method called every time country selector's value gets changed. * Updates all validations and requirements for certain country. @@ -42,16 +58,9 @@ define([ return; } - defaultPostCodeResolver.setUseDefaultPostCode(!option['is_zipcode_optional']); - - if (option['is_region_visible'] === false) { - // Hide select and corresponding text input field if region must not be shown for selected country. - this.setVisible(false); + this.hideRegion(option); - if (this.customEntry) { // eslint-disable-line max-depth - this.toggleInput(false); - } - } + defaultPostCodeResolver.setUseDefaultPostCode(!option['is_zipcode_optional']); isRegionRequired = !this.skipValidation && !!option['is_region_required']; @@ -67,7 +76,24 @@ define([ input.validation['required-entry'] = isRegionRequired; input.validation['validate-not-number-first'] = !this.options().length; }.bind(this)); + }, + + /** + * Hide select and corresponding text input field if region must not be shown for selected country. + * + * @private + * @param {Object}option + */ + hideRegion: function (option) { + if (!option || option['is_region_visible'] !== false) { + return; + } + + this.setVisible(false); + + if (this.customEntry) { + this.toggleInput(false); + } } }); }); - diff --git a/app/code/Magento/Ui/view/base/web/js/form/element/ui-select.js b/app/code/Magento/Ui/view/base/web/js/form/element/ui-select.js index 65443fadf8007..fa49cef8670ec 100644 --- a/app/code/Magento/Ui/view/base/web/js/form/element/ui-select.js +++ b/app/code/Magento/Ui/view/base/web/js/form/element/ui-select.js @@ -760,7 +760,7 @@ define([ * * @param {Object} event - mousemove event */ - onDelegatedMouseMouve: function (event) { + onDelegatedMouseMove: function (event) { var target = $(event.currentTarget).closest(this.visibleOptionSelector)[0]; if (this.isCursorPositionChange(event) || this.hoveredElement === target) { @@ -1145,7 +1145,7 @@ define([ $(this.rootList).on( 'mousemove', targetSelector, - this.onDelegatedMouseMouve.bind(this) + this.onDelegatedMouseMove.bind(this) ); }, diff --git a/app/code/Magento/Ui/view/base/web/js/grid/columns/multiselect.js b/app/code/Magento/Ui/view/base/web/js/grid/columns/multiselect.js index ba0f4d25c25a4..828bbccee0478 100644 --- a/app/code/Magento/Ui/view/base/web/js/grid/columns/multiselect.js +++ b/app/code/Magento/Ui/view/base/web/js/grid/columns/multiselect.js @@ -52,6 +52,7 @@ define([ listens: { '${ $.provider }:params.filters': 'onFilter', + '${ $.provider }:params.search': 'onSearch', selected: 'onSelectedChange', rows: 'onRowsChange' }, @@ -235,7 +236,7 @@ define([ * @returns {Multiselect} Chainable. */ togglePage: function () { - return this.isPageSelected() ? this.deselectPage() : this.selectPage(); + return this.isPageSelected() && !this.excluded().length ? this.deselectPage() : this.selectPage(); }, /** @@ -496,6 +497,13 @@ define([ if (!this.preserveSelectionsOnFilter) { this.deselectAll(); } + }, + + /** + * Is invoked when search is applied or removed + */ + onSearch: function () { + this.onFilter(); } }); }); diff --git a/app/code/Magento/Ui/view/base/web/js/grid/filters/elements/ui-select.js b/app/code/Magento/Ui/view/base/web/js/grid/filters/elements/ui-select.js index a913f3fa4a042..cddcc7d49ffe8 100644 --- a/app/code/Magento/Ui/view/base/web/js/grid/filters/elements/ui-select.js +++ b/app/code/Magento/Ui/view/base/web/js/grid/filters/elements/ui-select.js @@ -18,13 +18,15 @@ define([ loadedOption: [], validationLoading: true, imports: { + applied: '${ $.filterChipsProvider }:applied', activeIndex: '${ $.bookmarkProvider }:activeIndex' }, modules: { filterChips: '${ $.filterChipsProvider }' }, listens: { - activeIndex: 'validateInitialValue' + activeIndex: 'validateInitialValue', + applied: 'validateInitialValue' } }, diff --git a/app/code/Magento/Ui/view/base/web/js/grid/search/search.js b/app/code/Magento/Ui/view/base/web/js/grid/search/search.js index ce53b23b79e11..3f5434761ba18 100644 --- a/app/code/Magento/Ui/view/base/web/js/grid/search/search.js +++ b/app/code/Magento/Ui/view/base/web/js/grid/search/search.js @@ -19,7 +19,7 @@ define([ return Element.extend({ defaults: { template: 'ui/grid/search/search', - placeholder: 'Search by keyword', + placeholder: $t('Search by keyword'), label: $t('Keyword'), value: '', previews: [], diff --git a/app/code/Magento/Ui/view/base/web/js/lib/key-codes.js b/app/code/Magento/Ui/view/base/web/js/lib/key-codes.js index 1f5a4210793ba..1f25e0d2c089f 100644 --- a/app/code/Magento/Ui/view/base/web/js/lib/key-codes.js +++ b/app/code/Magento/Ui/view/base/web/js/lib/key-codes.js @@ -21,6 +21,7 @@ define([], function () { 17: 'ctrlKey', 18: 'altKey', 16: 'shiftKey', + 191: 'forwardSlashKey', 66: 'bKey', 73: 'iKey', 85: 'uKey' diff --git a/app/code/Magento/Ui/view/base/web/templates/dynamic-rows/cells/thumbnail.html b/app/code/Magento/Ui/view/base/web/templates/dynamic-rows/cells/thumbnail.html index 1bff60064b983..cbb00f379a655 100644 --- a/app/code/Magento/Ui/view/base/web/templates/dynamic-rows/cells/thumbnail.html +++ b/app/code/Magento/Ui/view/base/web/templates/dynamic-rows/cells/thumbnail.html @@ -4,4 +4,4 @@ * See COPYING.txt for license details. */ --> -<img class = 'admin__control-thumbnail' data-bind="attr: {src: $data.value}"> +<img class = 'admin__control-thumbnail' style="max-height: 75px; max-width: 75px;" data-bind="attr: {src: $data.value}"> diff --git a/app/code/Magento/Ups/Model/Carrier.php b/app/code/Magento/Ups/Model/Carrier.php index b6e539bdadcb9..28a4a3a7e0b41 100644 --- a/app/code/Magento/Ups/Model/Carrier.php +++ b/app/code/Magento/Ups/Model/Carrier.php @@ -1118,6 +1118,7 @@ protected function _getXmlTracking($trackings) /** @var HttpResponseDeferredInterface[] $trackingResponses */ $trackingResponses = []; + $tracking = ''; foreach ($trackings as $tracking) { /** * RequestOption==>'1' to request all activities @@ -1135,7 +1136,7 @@ protected function _getXmlTracking($trackings) </TrackRequest> XMLAuth; - $trackingResponses[] = $this->asyncHttpClient->request( + $trackingResponses[$tracking] = $this->asyncHttpClient->request( new Request( $url, Request::METHOD_POST, @@ -1144,13 +1145,9 @@ protected function _getXmlTracking($trackings) ) ); } - foreach ($trackingResponses as $response) { + foreach ($trackingResponses as $tracking => $response) { $httpResponse = $response->get(); - if ($httpResponse->getStatusCode() >= 400) { - $xmlResponse = ''; - } else { - $xmlResponse = $httpResponse->getBody(); - } + $xmlResponse = $httpResponse->getStatusCode() >= 400 ? '' : $httpResponse->getBody(); $this->_parseXmlTrackingResponse($tracking, $xmlResponse); } @@ -1362,10 +1359,11 @@ public function getAllowedMethods() protected function _formShipmentRequest(DataObject $request) { $packages = $request->getPackages(); + $shipmentItems = []; foreach ($packages as $package) { $shipmentItems[] = $package['items']; } - $shipmentItems = array_merge(...$shipmentItems); + $shipmentItems = array_merge([], ...$shipmentItems); $xmlRequest = $this->_xmlElFactory->create( ['data' => '<?xml version = "1.0" ?><ShipmentConfirmRequest xml:lang="en-US"/>'] @@ -1528,24 +1526,18 @@ protected function _formShipmentRequest(DataObject $request) } if ($deliveryConfirmation && $deliveryConfirmationLevel === self::DELIVERY_CONFIRMATION_PACKAGE) { - $serviceOptionsNode = $packagePart[$packageId]->addChild('PackageServiceOptions'); - $serviceOptionsNode->addChild( - 'DeliveryConfirmation' - )->addChild( - 'DCISType', - $deliveryConfirmation - ); + $serviceOptionsNode = $packagePart[$packageId]->addChild('PackageServiceOptions'); + $serviceOptionsNode + ->addChild('DeliveryConfirmation') + ->addChild('DCISType', $deliveryConfirmation); } } - if (isset($deliveryConfirmation) && $deliveryConfirmationLevel === self::DELIVERY_CONFIRMATION_SHIPMENT) { + if (!empty($deliveryConfirmation) && $deliveryConfirmationLevel === self::DELIVERY_CONFIRMATION_SHIPMENT) { $serviceOptionsNode = $shipmentPart->addChild('ShipmentServiceOptions'); - $serviceOptionsNode->addChild( - 'DeliveryConfirmation' - )->addChild( - 'DCISType', - $deliveryConfirmation - ); + $serviceOptionsNode + ->addChild('DeliveryConfirmation') + ->addChild('DCISType', $deliveryConfirmation); } $shipmentPart->addChild('PaymentInformation') @@ -1624,9 +1616,11 @@ protected function _sendShipmentAcceptRequest(Element $shipmentConfirmResponse) $xmlResponse = ''; } + $response = ''; try { $response = $this->_xmlElFactory->create(['data' => $xmlResponse]); } catch (Throwable $e) { + $response = $this->_xmlElFactory->create(['data' => '']); $debugData['result'] = ['error' => $e->getMessage(), 'code' => $e->getCode()]; } @@ -1800,6 +1794,7 @@ protected function _doShipmentRequest(DataObject $request) $this->setXMLAccessRequest(); $xmlRequest = $this->_xmlAccessRequest . $rawXmlRequest; $xmlResponse = $this->_getCachedQuotes($xmlRequest); + $debugData = []; if ($xmlResponse === null) { $debugData['request'] = $this->filterDebugData($this->_xmlAccessRequest) . $rawXmlRequest; diff --git a/app/code/Magento/Ups/Plugin/Block/DataProviders/Tracking/ChangeTitle.php b/app/code/Magento/Ups/Plugin/Block/DataProviders/Tracking/ChangeTitle.php new file mode 100644 index 0000000000000..973b199217271 --- /dev/null +++ b/app/code/Magento/Ups/Plugin/Block/DataProviders/Tracking/ChangeTitle.php @@ -0,0 +1,34 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +namespace Magento\Ups\Plugin\Block\DataProviders\Tracking; + +use Magento\Ups\Model\Carrier; +use Magento\Shipping\Model\Tracking\Result\Status; +use Magento\Shipping\Block\DataProviders\Tracking\DeliveryDateTitle as Subject; + +/** + * Plugin to change the "Delivery on" title to a customized value for UPS + */ +class ChangeTitle +{ + /** + * Modify title only when UPS is used as carrier + * + * @param Subject $subject + * @param \Magento\Framework\Phrase|string $result + * @param Status $trackingStatus + * @return \Magento\Framework\Phrase|string + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterGetTitle(Subject $subject, $result, Status $trackingStatus) + { + if ($trackingStatus->getCarrier() === Carrier::CODE) { + $result = __('Status Updated On:'); + } + return $result; + } +} diff --git a/app/code/Magento/Ups/Test/Unit/Plugin/Block/DataProviders/Tracking/ChangeTitleTest.php b/app/code/Magento/Ups/Test/Unit/Plugin/Block/DataProviders/Tracking/ChangeTitleTest.php new file mode 100644 index 0000000000000..fa608584be964 --- /dev/null +++ b/app/code/Magento/Ups/Test/Unit/Plugin/Block/DataProviders/Tracking/ChangeTitleTest.php @@ -0,0 +1,81 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +declare(strict_types=1); + +namespace Magento\Ups\Test\Unit\Plugin\Block\DataProviders\Tracking; + +use Magento\Ups\Model\Carrier; +use Magento\Ups\Plugin\Block\DataProviders\Tracking\ChangeTitle; +use Magento\Framework\Phrase; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; +use Magento\Shipping\Block\DataProviders\Tracking\DeliveryDateTitle; +use Magento\Shipping\Model\Tracking\Result\Status; +use Magento\Ups\Model\Carrier as UpsCarrier; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * Unit Test for @see ChangeTitle + */ +class ChangeTitleTest extends TestCase +{ + /** + * @var ChangeTitle|MockObject + */ + private $plugin; + + /** + * @inheritDoc + */ + protected function setUp(): void + { + $objectManagerHelper = new ObjectManager($this); + $this->plugin = $objectManagerHelper->getObject(ChangeTitle::class); + } + + /** + * Check if DeliveryDateTitle was changed if the carrier is UPS + * + * @param string $carrierCode + * @param string $originalResult + * @param Phrase|string $finalResult + * @dataProvider testAfterGetTitleDataProvider + */ + public function testAfterGetTitle(string $carrierCode, string $originalResult, $finalResult) + { + /** @var DeliveryDateTitle|MockObject $subjectMock */ + $subjectMock = $this->getMockBuilder(DeliveryDateTitle::class) + ->disableOriginalConstructor() + ->getMock(); + + /** @var Status|MockObject $trackingStatusMock */ + $trackingStatusMock = $this->getMockBuilder(Status::class) + ->disableOriginalConstructor() + ->setMethods(['getCarrier']) + ->getMock(); + $trackingStatusMock->expects($this::once()) + ->method('getCarrier') + ->willReturn($carrierCode); + + $actual = $this->plugin->afterGetTitle($subjectMock, $originalResult, $trackingStatusMock); + + $this->assertEquals($finalResult, $actual); + } + + /** + * Data provider + * + * @return array + */ + public function testAfterGetTitleDataProvider(): array + { + return [ + [Carrier::CODE, 'Original Title', __('Status Updated On:')], + ['not-fedex', 'Original Title', 'Original Title'], + ]; + } +} diff --git a/app/code/Magento/Ups/etc/di.xml b/app/code/Magento/Ups/etc/di.xml index a04a5eb48bdab..08d751fc3e2c8 100644 --- a/app/code/Magento/Ups/etc/di.xml +++ b/app/code/Magento/Ups/etc/di.xml @@ -28,4 +28,7 @@ </argument> </arguments> </type> + <type name="Magento\Shipping\Block\DataProviders\Tracking\DeliveryDateTitle"> + <plugin name="ups_update_delivery_date_title" type="Magento\Ups\Plugin\Block\DataProviders\Tracking\ChangeTitle"/> + </type> </config> diff --git a/app/code/Magento/UrlRewriteGraphQl/Model/Resolver/EntityUrl.php b/app/code/Magento/UrlRewriteGraphQl/Model/Resolver/EntityUrl.php index e6b03755bea47..6430f71765fe4 100644 --- a/app/code/Magento/UrlRewriteGraphQl/Model/Resolver/EntityUrl.php +++ b/app/code/Magento/UrlRewriteGraphQl/Model/Resolver/EntityUrl.php @@ -64,7 +64,9 @@ public function resolve( $storeId = (int)$context->getExtensionAttributes()->getStore()->getId(); $result = null; - $url = $args['url']; + // phpcs:ignore Magento2.Functions.DiscouragedFunction + $urlParts = parse_url($args['url']); + $url = $urlParts['path'] ?? $args['url']; if (substr($url, 0, 1) === '/' && $url !== '/') { $url = ltrim($url, '/'); } @@ -81,6 +83,9 @@ public function resolve( 'redirectCode' => $this->redirectType, 'type' => $this->sanitizeType($finalUrlRewrite->getEntityType()) ]; + if (!empty($urlParts['query'])) { + $resultArray['relative_url'] .= '?' . $urlParts['query']; + } if (empty($resultArray['id'])) { throw new GraphQlNoSuchEntityException( diff --git a/app/code/Magento/User/Controller/Adminhtml/User/Role/SaveRole.php b/app/code/Magento/User/Controller/Adminhtml/User/Role/SaveRole.php index 97ecb778b8cb1..39ee382709e56 100644 --- a/app/code/Magento/User/Controller/Adminhtml/User/Role/SaveRole.php +++ b/app/code/Magento/User/Controller/Adminhtml/User/Role/SaveRole.php @@ -102,11 +102,12 @@ public function execute() 'admin_permissions_role_prepare_save', ['object' => $role, 'request' => $this->getRequest()] ); - $role->save(); - - $this->_rulesFactory->create()->setRoleId($role->getId())->setResources($resource)->saveRel(); $this->processPreviousUsers($role, $oldRoleUsers); $this->processCurrentUsers($role, $roleUsers); + + $role->save(); + $this->_rulesFactory->create()->setRoleId($role->getId())->setResources($resource)->saveRel(); + $this->messageManager->addSuccessMessage(__('You saved the role.')); } catch (UserLockedException $e) { $this->_auth->logout(); @@ -155,6 +156,7 @@ protected function validateUser() private function parseRequestVariable($paramName): array { $value = $this->getRequest()->getParam($paramName, null); + // phpcs:ignore Magento2.Functions.DiscouragedFunction parse_str($value, $value); $value = array_keys($value); return $value; diff --git a/app/code/Magento/User/Model/ResourceModel/User.php b/app/code/Magento/User/Model/ResourceModel/User.php index d9bc555b8e391..5fa099c041165 100644 --- a/app/code/Magento/User/Model/ResourceModel/User.php +++ b/app/code/Magento/User/Model/ResourceModel/User.php @@ -14,6 +14,7 @@ use Magento\Framework\Acl\Data\CacheInterface; use Magento\Framework\App\ObjectManager; use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\Model\AbstractModel; use Magento\User\Model\Backend\Config\ObserverConfig; use Magento\User\Model\User as ModelUser; @@ -146,7 +147,7 @@ public function hasAssigned2Role($user) { if (is_numeric($user)) { $userId = $user; - } elseif ($user instanceof \Magento\Framework\Model\AbstractModel) { + } elseif ($user instanceof AbstractModel) { $userId = $user->getUserId(); } else { return null; @@ -171,13 +172,25 @@ public function hasAssigned2Role($user) } } + /** + * @inheritDoc + */ + protected function _beforeSave(AbstractModel $user) + { + if ($user->hasRoleId()) { + $user->setReloadAclFlag(1); + } + + return parent::_beforeSave($user); + } + /** * Unserialize user extra data after user save * - * @param \Magento\Framework\Model\AbstractModel $user + * @param AbstractModel $user * @return $this */ - protected function _afterSave(\Magento\Framework\Model\AbstractModel $user) + protected function _afterSave(AbstractModel $user) { $user->setExtra($this->getSerializer()->unserialize($user->getExtra())); if ($user->hasRoleId()) { @@ -234,10 +247,10 @@ protected function _createUserRole($parentId, ModelUser $user) /** * Unserialize user extra data after user load * - * @param \Magento\Framework\Model\AbstractModel $user + * @param AbstractModel $user * @return $this */ - protected function _afterLoad(\Magento\Framework\Model\AbstractModel $user) + protected function _afterLoad(AbstractModel $user) { if (is_string($user->getExtra())) { $user->setExtra($this->getSerializer()->unserialize($user->getExtra())); @@ -248,11 +261,11 @@ protected function _afterLoad(\Magento\Framework\Model\AbstractModel $user) /** * Delete user role record with user * - * @param \Magento\Framework\Model\AbstractModel $user + * @param AbstractModel $user * @return bool * @throws LocalizedException */ - public function delete(\Magento\Framework\Model\AbstractModel $user) + public function delete(AbstractModel $user) { $uid = $user->getId(); if (!$uid) { @@ -283,10 +296,10 @@ public function delete(\Magento\Framework\Model\AbstractModel $user) /** * Get user roles * - * @param \Magento\Framework\Model\AbstractModel $user + * @param AbstractModel $user * @return array */ - public function getRoles(\Magento\Framework\Model\AbstractModel $user) + public function getRoles(AbstractModel $user) { if (!$user->getId()) { return []; @@ -324,10 +337,10 @@ public function getRoles(\Magento\Framework\Model\AbstractModel $user) /** * Delete user role * - * @param \Magento\Framework\Model\AbstractModel $user + * @param AbstractModel $user * @return $this */ - public function deleteFromRole(\Magento\Framework\Model\AbstractModel $user) + public function deleteFromRole(AbstractModel $user) { if ($user->getUserId() <= 0) { return $this; @@ -351,10 +364,10 @@ public function deleteFromRole(\Magento\Framework\Model\AbstractModel $user) /** * Check if role user exists * - * @param \Magento\Framework\Model\AbstractModel $user + * @param AbstractModel $user * @return array */ - public function roleUserExists(\Magento\Framework\Model\AbstractModel $user) + public function roleUserExists(AbstractModel $user) { if ($user->getUserId() > 0) { $roleTable = $this->getTable('authorization_role'); @@ -381,10 +394,10 @@ public function roleUserExists(\Magento\Framework\Model\AbstractModel $user) /** * Check if user exists * - * @param \Magento\Framework\Model\AbstractModel $user + * @param AbstractModel $user * @return array */ - public function userExists(\Magento\Framework\Model\AbstractModel $user) + public function userExists(AbstractModel $user) { $connection = $this->getConnection(); $select = $connection->select(); @@ -409,10 +422,10 @@ public function userExists(\Magento\Framework\Model\AbstractModel $user) /** * Whether a user's identity is confirmed * - * @param \Magento\Framework\Model\AbstractModel $user + * @param AbstractModel $user * @return bool */ - public function isUserUnique(\Magento\Framework\Model\AbstractModel $user) + public function isUserUnique(AbstractModel $user) { return !$this->userExists($user); } @@ -420,7 +433,7 @@ public function isUserUnique(\Magento\Framework\Model\AbstractModel $user) /** * Save user extra data * - * @param \Magento\Framework\Model\AbstractModel $object + * @param AbstractModel $object * @param string $data * @return $this */ diff --git a/app/code/Magento/User/Model/User.php b/app/code/Magento/User/Model/User.php index 61af14d943615..f7d05decfeb01 100644 --- a/app/code/Magento/User/Model/User.php +++ b/app/code/Magento/User/Model/User.php @@ -65,6 +65,11 @@ class User extends AbstractModel implements StorageInterface, UserInterface const MESSAGE_ID_PASSWORD_EXPIRED = 'magento_user_password_expired'; + /** + * Tag to use for user assigned role caching. + */ + private const CACHE_TAG = 'user_assigned_role'; + /** * Model event prefix * @@ -149,6 +154,14 @@ class User extends AbstractModel implements StorageInterface, UserInterface */ private $deploymentConfig; + /** + * @var array + */ + protected $_cacheTag = [ + \Magento\Backend\Block\Menu::CACHE_TAGS, + self::CACHE_TAG, + ]; + /** * @param \Magento\Framework\Model\Context $context * @param \Magento\Framework\Registry $registry @@ -684,7 +697,27 @@ public function loadByUsername($username) */ public function hasAssigned2Role($user) { - return $this->getResource()->hasAssigned2Role($user); + if ($user instanceof AbstractModel) { + $userId = $user->getUserId(); + } elseif (is_numeric($user) && (int)$user !== 0) { + $userId = $user; + } else { + return null; + } + $data = $this->_cacheManager->load('assigned_role_' . $userId); + if (false === $data) { + $data = $this->getResource()->hasAssigned2Role($user); + + $this->_cacheManager->save( + $this->serializer->serialize($data), + 'assigned_role_' . $userId, + [self::CACHE_TAG] + ); + } else { + $data = $this->serializer->unserialize($data); + } + + return $data; } /** diff --git a/app/code/Magento/User/Observer/Backend/TrackAdminNewPasswordObserver.php b/app/code/Magento/User/Observer/Backend/TrackAdminNewPasswordObserver.php index 059879ab9613f..9573aaa1547ab 100644 --- a/app/code/Magento/User/Observer/Backend/TrackAdminNewPasswordObserver.php +++ b/app/code/Magento/User/Observer/Backend/TrackAdminNewPasswordObserver.php @@ -8,52 +8,57 @@ use Magento\Framework\Event\Observer as EventObserver; use Magento\Framework\Event\ObserverInterface; +use Magento\Framework\Message\ManagerInterface; use Magento\User\Model\User; +use Magento\User\Model\Backend\Config\ObserverConfig; +use Magento\User\Model\ResourceModel\User as UserResource; +use Magento\Backend\Model\Auth\Session as AuthSession; /** * User backend observer model for passwords + * @SuppressWarnings(PHPMD.CookieAndSessionMisuse) */ class TrackAdminNewPasswordObserver implements ObserverInterface { /** * Backend configuration interface * - * @var \Magento\User\Model\Backend\Config\ObserverConfig + * @var ObserverConfig */ protected $observerConfig; /** * Admin user resource model * - * @var \Magento\User\Model\ResourceModel\User + * @var UserResource */ protected $userResource; /** * Backend authorization session * - * @var \Magento\Backend\Model\Auth\Session + * @var AuthSession */ protected $authSession; /** * Message manager interface * - * @var \Magento\Framework\Message\ManagerInterface + * @var ManagerInterface */ protected $messageManager; /** - * @param \Magento\User\Model\Backend\Config\ObserverConfig $observerConfig - * @param \Magento\User\Model\ResourceModel\User $userResource - * @param \Magento\Backend\Model\Auth\Session $authSession - * @param \Magento\Framework\Message\ManagerInterface $messageManager + * @param ObserverConfig $observerConfig + * @param UserResource $userResource + * @param AuthSession $authSession + * @param ManagerInterface $messageManager */ public function __construct( - \Magento\User\Model\Backend\Config\ObserverConfig $observerConfig, - \Magento\User\Model\ResourceModel\User $userResource, - \Magento\Backend\Model\Auth\Session $authSession, - \Magento\Framework\Message\ManagerInterface $messageManager + ObserverConfig $observerConfig, + UserResource $userResource, + AuthSession $authSession, + ManagerInterface $messageManager ) { $this->observerConfig = $observerConfig; $this->userResource = $userResource; @@ -69,11 +74,11 @@ public function __construct( */ public function execute(EventObserver $observer) { - /* @var $user \Magento\User\Model\User */ + /* @var $user User */ $user = $observer->getEvent()->getObject(); if ($user->getId()) { $passwordHash = $user->getPassword(); - if ($passwordHash && !$user->getForceNewPassword()) { + if ($passwordHash && $user->dataHasChangedFor('password')) { $this->userResource->trackPassword($user, $passwordHash); $this->messageManager->getMessages()->deleteMessageByIdentifier(User::MESSAGE_ID_PASSWORD_EXPIRED); $this->authSession->unsPciAdminUserIsPasswordExpired(); diff --git a/app/code/Magento/User/Test/Mftf/ActionGroup/AdminChooseUserRoleResourceActionGroup.xml b/app/code/Magento/User/Test/Mftf/ActionGroup/AdminChooseUserRoleResourceActionGroup.xml new file mode 100644 index 0000000000000..7072830e2036b --- /dev/null +++ b/app/code/Magento/User/Test/Mftf/ActionGroup/AdminChooseUserRoleResourceActionGroup.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminChooseUserRoleResourceActionGroup"> + <annotations> + <description>Check the resource access checkbox. Leave the form open.</description> + </annotations> + <arguments> + <argument name="resourceId" type="string" defaultValue="Magento_Backend::dashboard"/> + <argument name="resourceName" type="string" defaultValue="Dashboard"/> + </arguments> + + <waitForElementVisible selector="{{AdminEditRoleResourcesSection.resourceCheckboxLink(resourceId, resourceName)}}" stepKey="waitForResourceCheckboxVisible"/> + <click selector="{{AdminEditRoleResourcesSection.resourceCheckboxLink(resourceId, resourceName)}}" stepKey="checkResource"/> + <seeCheckboxIsChecked selector="{{AdminEditRoleResourcesSection.resourceCheckbox(resourceId)}}" stepKey="seeCheckedResource"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/User/Test/Mftf/ActionGroup/AdminDeleteRoleActionGroup.xml b/app/code/Magento/User/Test/Mftf/ActionGroup/AdminDeleteRoleActionGroup.xml index 46ad2e228c6c1..1fd4b2fa4fe65 100644 --- a/app/code/Magento/User/Test/Mftf/ActionGroup/AdminDeleteRoleActionGroup.xml +++ b/app/code/Magento/User/Test/Mftf/ActionGroup/AdminDeleteRoleActionGroup.xml @@ -10,12 +10,13 @@ xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> <actionGroup name="AdminDeleteRoleActionGroup"> <annotations> - <description>Deletes a User Role.</description> + <description>DEPRECATED. Deletes a User Role. ActionGroup duplicates existing 'AdminDeleteUserRoleActionGroup'</description> </annotations> <arguments> <argument name="role" defaultValue=""/> </arguments> - + <amOnPage url="{{AdminRolesPage.url}}" stepKey="navigateToUserRoleGrid" /> + <waitForPageLoad stepKey="waitForRolesGridLoad" /> <click stepKey="clickResetFilterButtonBefore" selector="{{AdminRoleGridSection.resetButton}}"/> <waitForPageLoad stepKey="waitForRolesGridFilterResetBefore" time="10"/> <fillField stepKey="TypeRoleFilter" selector="{{AdminRoleGridSection.roleNameFilterTextField}}" userInput="{{role.name}}"/> @@ -33,7 +34,7 @@ <click stepKey="clickToConfirm" selector="{{AdminDeleteRoleSection.confirm}}"/> <waitForPageLoad stepKey="waitForPageLoad" time="10"/> <see stepKey="seeSuccessMessage" userInput="You deleted the role."/> - <waitForPageLoad stepKey="waitForRolesGridLoad" /> + <waitForPageLoad stepKey="waitForRolesGridLoadAfterDelete" /> <waitForElementVisible stepKey="waitForResetFilterButtonAfter" selector="{{AdminRoleGridSection.resetButton}}" time="10"/> <click stepKey="clickResetFilterButtonAfter" selector="{{AdminRoleGridSection.resetButton}}"/> <waitForPageLoad stepKey="waitForRolesGridFilterResetAfter" time="10"/> diff --git a/app/code/Magento/User/Test/Mftf/ActionGroup/AdminSaveUserRoleActionGroup.xml b/app/code/Magento/User/Test/Mftf/ActionGroup/AdminSaveUserRoleActionGroup.xml new file mode 100644 index 0000000000000..4a90630161e99 --- /dev/null +++ b/app/code/Magento/User/Test/Mftf/ActionGroup/AdminSaveUserRoleActionGroup.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminSaveUserRoleActionGroup"> + <annotations> + <description>Click to Save Role</description> + </annotations> + <click selector="{{AdminEditRoleInfoSection.saveButton}}" stepKey="clickSaveRoleButton" /> + <waitForElementVisible selector="{{AdminMessagesSection.success}}" stepKey="waitForSuccessMessage"/> + <see selector="{{AdminMessagesSection.success}}" userInput="You saved the role." stepKey="seeSuccessMessageForSavedRole"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/User/Test/Mftf/ActionGroup/AdminStartCreateUserRoleActionGroup.xml b/app/code/Magento/User/Test/Mftf/ActionGroup/AdminStartCreateUserRoleActionGroup.xml new file mode 100644 index 0000000000000..2a1dec5b8574e --- /dev/null +++ b/app/code/Magento/User/Test/Mftf/ActionGroup/AdminStartCreateUserRoleActionGroup.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminStartCreateUserRoleActionGroup"> + <annotations> + <description>Open Admin Edit Role page. Fills role, user password, resource access. Leave the form open.</description> + </annotations> + <arguments> + <argument name="roleName" type="string" defaultValue="{{limitedRole.name}}"/> + <argument name="userPassword" type="string" defaultValue="123123q"/> + <argument name="resourceAccess" type="string" defaultValue="Custom"/> + </arguments> + <amOnPage url="{{AdminEditRolePage.url}}" stepKey="openNewAdminRolePage"/> + <waitForElementVisible selector="{{AdminCreateRoleSection.name}}" stepKey="waitForName"/> + <fillField selector="{{AdminCreateRoleSection.name}}" userInput="{{roleName}}" stepKey="setTheRoleName"/> + <fillField selector="{{AdminCreateRoleSection.password}}" userInput="{{userPassword}}" stepKey="setPassword"/> + <click selector="{{AdminCreateRoleSection.roleResources}}" stepKey="clickToOpenRoleResources"/> + <selectOption selector="{{AdminEditRoleResourcesSection.resourceAccess}}" userInput="{{resourceAccess}}" stepKey="chooseResourceAccess"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/User/Test/Mftf/ActionGroup/AdminUserSaveRoleActionGroup.xml b/app/code/Magento/User/Test/Mftf/ActionGroup/AdminUserSaveRoleActionGroup.xml index 824e9407125f5..e247db64deeab 100644 --- a/app/code/Magento/User/Test/Mftf/ActionGroup/AdminUserSaveRoleActionGroup.xml +++ b/app/code/Magento/User/Test/Mftf/ActionGroup/AdminUserSaveRoleActionGroup.xml @@ -9,7 +9,7 @@ xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> <actionGroup name="AdminUserSaveRoleActionGroup"> <annotations> - <description>Click to Save Role</description> + <description>Deprecated. Please use AdminSaveUserRoleActionGroup</description> </annotations> <click selector="{{AdminEditRoleInfoSection.saveButton}}" stepKey="clickSaveRoleButton" /> <see userInput="You saved the role." stepKey="seeUserRoleSavedMessage"/> diff --git a/app/code/Magento/User/Test/Mftf/Section/AdminCreateRoleSection.xml b/app/code/Magento/User/Test/Mftf/Section/AdminCreateRoleSection.xml index 93acfc2753b61..96aaf879e2054 100644 --- a/app/code/Magento/User/Test/Mftf/Section/AdminCreateRoleSection.xml +++ b/app/code/Magento/User/Test/Mftf/Section/AdminCreateRoleSection.xml @@ -9,13 +9,13 @@ <sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> <section name="AdminCreateRoleSection"> - <element name="create" type="button" selector="#add"/> + <element name="create" type="button" selector="#add" timeout="30"/> <element name="name" type="button" selector="#role_name"/> <element name="password" type="input" selector="#current_password"/> <element name="resourceAccess" type="select" selector="[data-ui-id='adminhtml-user-editroles-tab-content-account'] [name='all']"/> <element name="resourceTree" type="block" selector="[data-ui-id='adminhtml-user-editroles-tab-content-account'] [data-role='resource-tree']"/> - <element name="roleResources" type="button" selector="#role_info_tabs_account"/> + <element name="roleResources" type="button" selector="#role_info_tabs_account" timeout="30"/> <element name="roleResource" type="button" selector="#gws_is_all"/> <element name="roleResourceNew" type="button" selector="#all"/> <element name="resourceValue" type="button" selector="//*[text()='{{arg1}}']" parameterized="true"/> @@ -24,7 +24,7 @@ <element name="scopeValue" type="button" selector="//select[@id='all']/*[text()='{{arg2}}']" parameterized="true"/> <element name="website" type="checkbox" selector="//*[contains(text(), '{{arg3}}')]" parameterized="true"/> <element name="selectWebsite" type="checkbox" selector="//label[contains(text(), '{{websiteName}}')]/preceding-sibling::input" parameterized="true"/> - <element name="save" type="button" selector="//button[@title='Save Role']"/> + <element name="save" type="button" selector="//button[@title='Save Role']" timeout="30"/> <element name="roleNameFilterTextField" type="input" selector="#permissionsUserRolesGrid_filter_role_name"/> <element name="searchButton" type="button" selector=".admin__data-grid-header button[title=Search]"/> <element name="searchResultFirstRow" type="text" selector=".data-grid>tbody>tr"/> diff --git a/app/code/Magento/User/Test/Mftf/Section/AdminDeleteRoleSection.xml b/app/code/Magento/User/Test/Mftf/Section/AdminDeleteRoleSection.xml deleted file mode 100644 index 6a0d0c9210396..0000000000000 --- a/app/code/Magento/User/Test/Mftf/Section/AdminDeleteRoleSection.xml +++ /dev/null @@ -1,20 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - /** - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. - */ ---> - -<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> - <section name="AdminDeleteRoleSection"> - <element name="theRole" selector="//td[contains(text(), 'Role')]" type="button"/> - <element name="salesRole" selector="//td[contains(text(), 'Sales')]" type="button"/> - <element name="role" parameterized="true" selector="//td[contains(@class,'col-role_name') and contains(text(), '{{roleName}}')]" type="button"/> - <element name="current_pass" type="button" selector="#current_password"/> - <element name="delete" selector="//button/span[contains(text(), 'Delete Role')]" type="button"/> - <element name="confirm" selector="//*[@class='action-primary action-accept']" type="button"/> - </section> -</sections> - diff --git a/app/code/Magento/User/Test/Mftf/Section/AdminDeleteUserSection.xml b/app/code/Magento/User/Test/Mftf/Section/AdminDeleteUserSection.xml deleted file mode 100644 index 21ca1cb36f988..0000000000000 --- a/app/code/Magento/User/Test/Mftf/Section/AdminDeleteUserSection.xml +++ /dev/null @@ -1,17 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<!-- - /** - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. - */ ---> - -<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> - <section name="AdminDeleteUserSection"> - <element name="role" parameterized="true" selector="//td[contains(text(), '{{roleName}}')]" type="button"/> - <element name="password" selector="#user_current_password" type="input"/> - <element name="delete" selector="//button/span[contains(text(), 'Delete User')]" type="button"/> - <element name="confirm" selector=".action-primary.action-accept" type="button"/> - </section> -</sections> diff --git a/app/code/Magento/User/Test/Mftf/Section/AdminEditRoleInfoSection.xml b/app/code/Magento/User/Test/Mftf/Section/AdminEditRoleInfoSection.xml index 57659e1aff075..b8430eb3b7313 100644 --- a/app/code/Magento/User/Test/Mftf/Section/AdminEditRoleInfoSection.xml +++ b/app/code/Magento/User/Test/Mftf/Section/AdminEditRoleInfoSection.xml @@ -13,7 +13,7 @@ <element name="backButton" type="button" selector="button[title='Back']"/> <element name="resetButton" type="button" selector="button[title='Reset']"/> <element name="deleteButton" type="button" selector="button[title='Delete Role']"/> - <element name="saveButton" type="button" selector="button[title='Save Role']"/> + <element name="saveButton" type="button" selector="button[title='Save Role']" timeout="30"/> <element name="message" type="text" selector=".modal-popup.confirm div.modal-content"/> <element name="cancel" type="button" selector=".modal-popup.confirm button.action-dismiss"/> <element name="ok" type="button" selector=".modal-popup.confirm button.action-accept" timeout="60"/> diff --git a/app/code/Magento/User/Test/Mftf/Section/AdminEditRoleResourcesSection.xml b/app/code/Magento/User/Test/Mftf/Section/AdminEditRoleResourcesSection.xml index 48873bd9d152e..2352575257afb 100644 --- a/app/code/Magento/User/Test/Mftf/Section/AdminEditRoleResourcesSection.xml +++ b/app/code/Magento/User/Test/Mftf/Section/AdminEditRoleResourcesSection.xml @@ -11,6 +11,8 @@ <element name="resources" type="checkbox" selector="#role_info_tabs_account"/> <element name="storeName" type="checkbox" selector="//label[contains(text(),'{{var1}}')]" parameterized="true"/> <element name="reportsCheckbox" type="text" selector="//li[@data-id='Magento_Reports::report']//a[text()='Reports']"/> + <element name="resourceCheckboxLink" type="checkbox" selector="//li[@data-id='{{resourceId}}']//a[text()='{{resourceName}}']" timeout="30" parameterized="true"/> + <element name="resourceCheckbox" type="checkbox" selector="//li[@data-id='{{resourceId}}']/input" timeout="30" parameterized="true"/> <element name="userRoles" type="text" selector="//span[contains(text(), 'User Roles')]"/> </section> </sections> diff --git a/app/code/Magento/User/Test/Mftf/Section/AdminRoleGridSection/AdminDeleteRoleSection.xml b/app/code/Magento/User/Test/Mftf/Section/AdminRoleGridSection/AdminDeleteRoleSection.xml index e369d037d28f6..dba7dd4cd520c 100644 --- a/app/code/Magento/User/Test/Mftf/Section/AdminRoleGridSection/AdminDeleteRoleSection.xml +++ b/app/code/Magento/User/Test/Mftf/Section/AdminRoleGridSection/AdminDeleteRoleSection.xml @@ -9,7 +9,8 @@ xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> <section name="AdminDeleteRoleSection"> <element name="theRole" selector="//td[contains(text(), 'Role')]" type="button"/> - <element name="role" parameterized="true" selector="//td[contains(text(), '{{args}}')]" type="button"/> + <element name="salesRole" selector="//td[contains(text(), 'Sales')]" type="button"/> + <element name="role" parameterized="true" selector="//td[contains(@class,'col-role_name') and contains(text(), '{{roleName}}')]" type="button"/> <element name="current_pass" type="button" selector="#current_password"/> <element name="delete" selector="//button/span[contains(text(), 'Delete Role')]" type="button"/> <element name="confirm" selector="//*[@class='action-primary action-accept']" type="button"/> diff --git a/app/code/Magento/User/Test/Mftf/Section/AdminUserGridSection/AdminDeleteUserSection.xml b/app/code/Magento/User/Test/Mftf/Section/AdminUserGridSection/AdminDeleteUserSection.xml index d4718ca43d6cf..3937ee75c6b7d 100644 --- a/app/code/Magento/User/Test/Mftf/Section/AdminUserGridSection/AdminDeleteUserSection.xml +++ b/app/code/Magento/User/Test/Mftf/Section/AdminUserGridSection/AdminDeleteUserSection.xml @@ -11,7 +11,7 @@ <element name="theUser" selector="//td[contains(text(), '{{userName}}')]" type="button" parameterized="true"/> <element name="password" selector="#user_current_password" type="input"/> <element name="delete" selector="//button/span[contains(text(), 'Delete User')]" type="button"/> - <element name="confirm" selector="//*[@class='action-primary action-accept']" type="button"/> + <element name="confirm" selector=".action-primary.action-accept" type="button"/> <element name="role" parameterized="true" selector="//td[contains(text(), '{{args}}')]" type="button"/> </section> </sections> diff --git a/app/code/Magento/User/Test/Mftf/Test/AdminReviewOrderWithReportsPermissionTest.xml b/app/code/Magento/User/Test/Mftf/Test/AdminReviewOrderWithReportsPermissionTest.xml new file mode 100644 index 0000000000000..8629187fe3ffb --- /dev/null +++ b/app/code/Magento/User/Test/Mftf/Test/AdminReviewOrderWithReportsPermissionTest.xml @@ -0,0 +1,109 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminReviewOrderWithReportsPermissionTest"> + <annotations> + <features value="User"/> + <stories value="Admin with restricted permissions"/> + <title value="User should be able to review ordered products with only 'Reports' permission"/> + <description value="User should be able to review ordered products with only 'Reports' permission"/> + <severity value="MAJOR"/> + <testCaseId value="MAGETWO-25812"/> + <group value="user"/> + </annotations> + <before> + <createData entity="ApiCategory" stepKey="createCategory"/> + <createData entity="ApiSimpleProduct" stepKey="createProduct"> + <requiredEntity createDataKey="createCategory"/> + </createData> + + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + <actionGroup ref="AdminCreateWebsiteActionGroup" stepKey="createWebsite"> + <argument name="newWebsiteName" value="{{NewWebSiteData.name}}"/> + <argument name="websiteCode" value="{{NewWebSiteData.code}}"/> + </actionGroup> + <actionGroup ref="AdminCreateNewStoreGroupActionGroup" stepKey="createNewStore"> + <argument name="website" value="{{NewWebSiteData.name}}"/> + <argument name="storeGroupName" value="{{NewStoreData.name}}"/> + <argument name="storeGroupCode" value="{{NewStoreData.code}}"/> + </actionGroup> + <actionGroup ref="AdminCreateStoreViewActionGroup" stepKey="createCustomStoreView"> + <argument name="StoreGroup" value="NewStoreData"/> + <argument name="customStore" value="NewStoreViewData"/> + </actionGroup> + <actionGroup ref="AdminCreateCustomerWithWebsiteAndStoreViewActionGroup" stepKey="createCustomerWithWebsiteAndStoreView"> + <argument name="customerData" value="Simple_US_Customer"/> + <argument name="address" value="US_Address_NY"/> + <argument name="website" value="{{NewWebSiteData.name}}"/> + <argument name="storeView" value="{{NewStoreViewData.name}}"/> + </actionGroup> + <actionGroup ref="SearchForProductOnBackendActionGroup" stepKey="searchForProductOnAdmin"> + <argument name="product" value="$createProduct$"/> + </actionGroup> + <actionGroup ref="CreatedProductConnectToWebsiteActionGroup" stepKey="productConnectToWebsite"> + <argument name="website" value="NewWebSiteData"/> + <argument name="product" value="$createProduct$"/> + </actionGroup> + <actionGroup ref="CreateOrderInStoreChoosingPaymentMethodActionGroup" stepKey="createOrder"> + <argument name="product" value="$createProduct$"/> + <argument name="customer" value="Simple_US_Customer"/> + <argument name="storeView" value="NewStoreViewData"/> + </actionGroup> + <actionGroup ref="AdminStartCreateUserRoleActionGroup" stepKey="startCreateUserRole"> + <argument name="roleName" value="{{limitedRole.name}}"/> + <argument name="userPassword" value="{{_ENV.MAGENTO_ADMIN_PASSWORD}}"/> + <argument name="resourceAccess" value="Custom"/> + </actionGroup> + <actionGroup ref="AdminChooseUserRoleResourceActionGroup" stepKey="setResourceAccess"> + <argument name="resourceId" value="Magento_Reports::report"/> + <argument name="resourceName" value="Reports"/> + </actionGroup> + <actionGroup ref="AdminSaveUserRoleActionGroup" stepKey="saveRole"/> + <actionGroup ref="AdminCreateUserWithRoleActionGroup" stepKey="createUser"> + <argument name="role" value="limitedRole"/> + <argument name="user" value="NewAdminUser"/> + </actionGroup> + </before> + <after> + <deleteData createDataKey="createProduct" stepKey="deleteProduct"/> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + + <actionGroup ref="AdminLogoutActionGroup" stepKey="logoutAdminUser"/> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + <actionGroup ref="AdminDeleteCreatedUserActionGroup" stepKey="deleteUser"> + <argument name="user" value="NewAdminUser"/> + </actionGroup> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearAdminUserGridFilters"/> + <amOnPage url="{{AdminRolesPage.url}}" stepKey="navigateToUserRoleGrid"/> + <actionGroup ref="AdminDeleteRoleActionGroup" stepKey="deleteRole"> + <argument name="role" value="limitedRole"/> + </actionGroup> + + <actionGroup ref="AdminDeleteCustomerActionGroup" stepKey="deleteCustomer"> + <argument name="customerEmail" value="Simple_US_Customer.email"/> + </actionGroup> + <actionGroup ref="AdminClearCustomersFiltersActionGroup" stepKey="clearCustomerFilters"/> + <actionGroup ref="AdminDeleteWebsiteActionGroup" stepKey="deleteWebsite"> + <argument name="websiteName" value="{{NewWebSiteData.name}}"/> + </actionGroup> + <actionGroup ref="AdminClearFiltersActionGroup" stepKey="clearProductFilters"/> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logoutFromAdmin"/> + </after> + + <actionGroup ref="AdminLogoutActionGroup" stepKey="logoutFromAdmin"/> + <actionGroup ref="AdminLoginActionGroup" stepKey="logAsNewUser"> + <argument name="username" value="{{NewAdminUser.username}}"/> + <argument name="password" value="{{NewAdminUser.password}}"/> + </actionGroup> + <actionGroup ref="AdminReviewOrderActionGroup" stepKey="reviewOrder"> + <argument name="productName" value="$createProduct.name$"/> + </actionGroup> + </test> +</tests> diff --git a/app/code/Magento/User/Test/Unit/Observer/Backend/TrackAdminNewPasswordObserverTest.php b/app/code/Magento/User/Test/Unit/Observer/Backend/TrackAdminNewPasswordObserverTest.php index 10477bdf80303..90e5f04b9c73e 100644 --- a/app/code/Magento/User/Test/Unit/Observer/Backend/TrackAdminNewPasswordObserverTest.php +++ b/app/code/Magento/User/Test/Unit/Observer/Backend/TrackAdminNewPasswordObserverTest.php @@ -112,14 +112,17 @@ public function testTrackAdminPassword() /** @var \Magento\User\Model\User|MockObject $userMock */ $userMock = $this->getMockBuilder(\Magento\User\Model\User::class) ->disableOriginalConstructor() - ->setMethods(['getId', 'getPassword', 'getForceNewPassword']) + ->setMethods(['getId', 'getPassword', 'dataHasChangedFor']) ->getMock(); $eventObserverMock->expects($this->once())->method('getEvent')->willReturn($eventMock); $eventMock->expects($this->once())->method('getObject')->willReturn($userMock); $userMock->expects($this->once())->method('getId')->willReturn($uid); $userMock->expects($this->once())->method('getPassword')->willReturn($newPW); - $userMock->expects($this->once())->method('getForceNewPassword')->willReturn(false); + $userMock->expects($this->once()) + ->method('dataHasChangedFor') + ->with('password') + ->willReturn(true); /** @var Collection|MockObject $collectionMock */ $collectionMock = $this->getMockBuilder(Collection::class) diff --git a/app/code/Magento/User/i18n/en_US.csv b/app/code/Magento/User/i18n/en_US.csv index 064b6428387fe..cd550015401d0 100644 --- a/app/code/Magento/User/i18n/en_US.csv +++ b/app/code/Magento/User/i18n/en_US.csv @@ -106,8 +106,8 @@ username,username Custom,Custom All,All Resources,Resources -"Warning!\r\nThis action will remove this user from already assigned role\r\nAre you sure?","Warning!\r\nThis action will remove this user from already assigned role\r\nAre you sure?" -"Warning!\r\nThis action will remove those users from already assigned roles\r\nAre you sure?","Warning!\r\nThis action will remove those users from already assigned roles\r\nAre you sure?" +"Warning!<br>This action will remove this user from already assigned role.<br>Are you sure?","Warning!<br>This action will remove this user from already assigned role.<br>Are you sure?" +"Warning!<br>This action will remove those users from already assigned roles.<br>Are you sure?","Warning!<br>This action will remove those users from already assigned roles.<br>Are you sure?" "Password Reset Confirmation for %name","Password Reset Confirmation for %name" "%name,","%name," "There was recently a request to change the password for your account.","There was recently a request to change the password for your account." diff --git a/app/code/Magento/User/view/adminhtml/templates/role/users_grid_js.phtml b/app/code/Magento/User/view/adminhtml/templates/role/users_grid_js.phtml index 2042479832898..b0107a53593d3 100644 --- a/app/code/Magento/User/view/adminhtml/templates/role/users_grid_js.phtml +++ b/app/code/Magento/User/view/adminhtml/templates/role/users_grid_js.phtml @@ -51,8 +51,8 @@ if (is_object($myBlock) && $myBlock->getJsObjectName()): if (checked) { confirm({ - content: "{$myBlock->escapeJs(__('Warning!\r\nThis action will remove this user from already ' . - 'assigned role\r\nAre you sure?'))}", + content: "{$myBlock->escapeJs(__('Warning!<br>This action will remove this user from already ' . + 'assigned role.<br>Are you sure?'))}", actions: { confirm: function () { checkbox[0].checked = false; @@ -102,7 +102,7 @@ if (is_object($myBlock) && $myBlock->getJsObjectName()): allCheckbox.checked = true; confirm({ content: "{$myBlock->escapeJs( - __('Warning!\r\nThis action will remove those users from already assigned roles\r\nAre you sure?') + __('Warning!<br>This action will remove those users from already assigned roles.<br>Are you sure?') )}", actions: { confirm: function () { diff --git a/app/code/Magento/User/view/adminhtml/templates/user/roles_grid_js.phtml b/app/code/Magento/User/view/adminhtml/templates/user/roles_grid_js.phtml index 71a866f945693..7455c26334c02 100644 --- a/app/code/Magento/User/view/adminhtml/templates/user/roles_grid_js.phtml +++ b/app/code/Magento/User/view/adminhtml/templates/user/roles_grid_js.phtml @@ -45,7 +45,7 @@ if (is_object($myBlock) && $myBlock->getJsObjectName()): var checked = isInput ? checkbox[0].checked : !checkbox[0].checked; if (checked && warning && radioBoxes.size() > 0) { if ( !confirm("{$myBlock->escapeJs( - __('Warning!\r\nThis action will remove this user from already assigned role\r\nAre you sure?') + __('Warning!<br>This action will remove this user from already assigned role.<br>Are you sure?') )}") ) { checkbox[0].checked = false; for(i in radioBoxes) { diff --git a/app/code/Magento/User/view/adminhtml/web/app-config.js b/app/code/Magento/User/view/adminhtml/web/app-config.js index 6387bec03ea90..0567e95fcc6e9 100644 --- a/app/code/Magento/User/view/adminhtml/web/app-config.js +++ b/app/code/Magento/User/view/adminhtml/web/app-config.js @@ -26,7 +26,7 @@ require.config({ 'jquery/ui': 'jquery/jquery-ui-1.9.2', 'jquery/validate': 'jquery/jquery.validate', 'jquery/hover-intent': 'jquery/jquery.hoverIntent', - 'jquery/file-uploader': 'jquery/fileUploader/jquery.fileupload-fp', + 'jquery/file-uploader': 'jquery/fileUploader/jquery.fileuploader', 'prototype': 'prototype/prototype-amd', 'text': 'requirejs/text', 'domReady': 'requirejs/domReady', diff --git a/app/code/Magento/Webapi/Model/Rest/Swagger/Generator.php b/app/code/Magento/Webapi/Model/Rest/Swagger/Generator.php index f38c0f0978536..5ead1beb722dd 100644 --- a/app/code/Magento/Webapi/Model/Rest/Swagger/Generator.php +++ b/app/code/Magento/Webapi/Model/Rest/Swagger/Generator.php @@ -33,10 +33,8 @@ class Generator extends AbstractSchemaGenerator */ const ERROR_SCHEMA = '#/definitions/error-response'; - /** Unauthorized description */ const UNAUTHORIZED_DESCRIPTION = '401 Unauthorized'; - /** Array signifier */ const ARRAY_SIGNIFIER = '[0]'; /** @@ -758,7 +756,7 @@ private function handleComplex($name, $type, $prefix, $isArray) ); } - return empty($queryNames) ? [] : array_merge(...$queryNames); + return array_merge([], ...$queryNames); } /** diff --git a/app/code/Magento/Weee/Model/Total/Quote/Weee.php b/app/code/Magento/Weee/Model/Total/Quote/Weee.php index 449c6cd688668..e7ae84c15a51f 100644 --- a/app/code/Magento/Weee/Model/Total/Quote/Weee.php +++ b/app/code/Magento/Weee/Model/Total/Quote/Weee.php @@ -306,12 +306,12 @@ protected function getNextIncrement() */ protected function recalculateParent(AbstractItem $item) { - $associatedTaxables = [[]]; + $associatedTaxables = []; foreach ($item->getChildren() as $child) { $associatedTaxables[] = $child->getAssociatedTaxables(); } $item->setAssociatedTaxables( - array_unique(array_merge(...$associatedTaxables)) + array_unique(array_merge([], ...$associatedTaxables)) ); } diff --git a/app/code/Magento/Weee/Plugin/Catalog/ResourceModel/Attribute/RemoveProductWeeData.php b/app/code/Magento/Weee/Plugin/Catalog/ResourceModel/Attribute/RemoveProductWeeData.php new file mode 100644 index 0000000000000..b1b228296eb6b --- /dev/null +++ b/app/code/Magento/Weee/Plugin/Catalog/ResourceModel/Attribute/RemoveProductWeeData.php @@ -0,0 +1,63 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Weee\Plugin\Catalog\ResourceModel\Attribute; + +use Magento\Catalog\Model\ResourceModel\Attribute\RemoveProductAttributeData; +use Magento\Framework\App\ResourceConnection; +use Magento\Framework\Model\AbstractModel; + +/** + * Plugin for deleting wee tax attributes data on unassigning weee attribute from attribute set. + */ +class RemoveProductWeeData +{ + /** + * @var ResourceConnection + */ + private $resourceConnection; + + /** + * @param ResourceConnection $resourceConnection + */ + public function __construct( + ResourceConnection $resourceConnection + ) { + $this->resourceConnection = $resourceConnection; + } + + /** + * Deletes wee tax attributes data on unassigning weee attribute from attribute set. + * + * @param RemoveProductAttributeData $subject + * @param \Closure $proceed + * @param AbstractModel $object + * @param int $attributeSetId + * @return void + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function aroundRemoveData( + RemoveProductAttributeData $subject, + \Closure $proceed, + AbstractModel $object, + int $attributeSetId + ) { + if ($object->getFrontendInput() == 'weee') { + $select =$this->resourceConnection->getConnection()->select() + ->from(['b' => $this->resourceConnection->getTableName('weee_tax')]) + ->join( + ['e' => $object->getEntity()->getEntityTable()], + 'b.entity_id = e.entity_id' + )->where('b.attribute_id = ?', $object->getAttributeId()) + ->where('e.attribute_set_id = ?', $attributeSetId); + + $this->resourceConnection->getConnection()->query($select->deleteFromSelect('b')); + } else { + $proceed($object, $attributeSetId); + } + } +} diff --git a/app/code/Magento/Weee/etc/di.xml b/app/code/Magento/Weee/etc/di.xml index 8b433163cad22..ccc849f4d8493 100644 --- a/app/code/Magento/Weee/etc/di.xml +++ b/app/code/Magento/Weee/etc/di.xml @@ -81,4 +81,7 @@ <type name="Magento\Catalog\Controller\Adminhtml\Product\Initialization\Helper"> <plugin name="weeeAttributeOptionsProcess" type="Magento\Weee\Plugin\Catalog\Controller\Adminhtml\Product\Initialization\Helper\ProcessTaxAttribute"/> </type> + <type name="Magento\Catalog\Model\ResourceModel\Attribute\RemoveProductAttributeData"> + <plugin name="removeWeeAttributesData" type="Magento\Weee\Plugin\Catalog\ResourceModel\Attribute\RemoveProductWeeData" /> + </type> </config> diff --git a/app/code/Magento/Widget/Controller/Adminhtml/Widget/Instance/Validate.php b/app/code/Magento/Widget/Controller/Adminhtml/Widget/Instance/Validate.php index f6eb42d1cfeb6..f30a44920dc6a 100644 --- a/app/code/Magento/Widget/Controller/Adminhtml/Widget/Instance/Validate.php +++ b/app/code/Magento/Widget/Controller/Adminhtml/Widget/Instance/Validate.php @@ -6,7 +6,12 @@ */ namespace Magento\Widget\Controller\Adminhtml\Widget\Instance; -class Validate extends \Magento\Widget\Controller\Adminhtml\Widget\Instance +use Magento\Framework\App\Action\HttpPostActionInterface; +use Magento\Framework\DataObject; +use Magento\Framework\Phrase; +use Magento\Widget\Controller\Adminhtml\Widget\Instance; + +class Validate extends Instance implements HttpPostActionInterface { /** * Validate action @@ -15,12 +20,12 @@ class Validate extends \Magento\Widget\Controller\Adminhtml\Widget\Instance */ public function execute() { - $response = new \Magento\Framework\DataObject(); + $response = new DataObject(); $response->setError(false); $widgetInstance = $this->_initWidgetInstance(); $result = $widgetInstance->validate(); - if ($result !== true && is_string($result)) { - $this->messageManager->addError($result); + if ($result !== true && (is_string($result) || $result instanceof Phrase)) { + $this->messageManager->addErrorMessage((string) $result); $this->_view->getLayout()->initMessages(); $response->setError(true); $response->setHtmlMessage($this->_view->getLayout()->getMessagesBlock()->getGroupedHtml()); diff --git a/app/code/Magento/Widget/Test/Mftf/ActionGroup/AdminCreateCatalogProductsListWidgetActionGroup.xml b/app/code/Magento/Widget/Test/Mftf/ActionGroup/AdminCreateCatalogProductsListWidgetActionGroup.xml new file mode 100644 index 0000000000000..e1de4d7147df6 --- /dev/null +++ b/app/code/Magento/Widget/Test/Mftf/ActionGroup/AdminCreateCatalogProductsListWidgetActionGroup.xml @@ -0,0 +1,40 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminCreateCatalogProductsListWidgetActionGroup" extends="AdminCreateWidgetActionGroup"> + <annotations> + <description>EXTENDS: AdminCreateWidgetActionGroup. Creates a Product List Widget. Validates that the Success Message is present and correct.</description> + </annotations> + <arguments> + <argument name="title" defaultValue="" type="string"/> + <argument name="displayPageControl" defaultValue="0" type="string"/> + <argument name="numberOfProductsToDisplay" defaultValue="10" type="string"/> + <argument name="cacheLifetime" defaultValue="" type="string"/> + <argument name="condition" defaultValue="SKU" type="string"/> + <argument name="conditionsOperator" defaultValue="is one of" type="string"/> + <argument name="conditionParameter" defaultValue="" type="string"/> + </arguments> + <fillField selector="{{AdminNewWidgetSection.title}}" userInput="{{title}}" stepKey="fillTitleWidgetOption"/> + <selectOption selector="{{AdminNewWidgetSection.displayPageControl}}" userInput="{{displayPageControl}}" stepKey="selectDisplayPageControl"/> + <fillField selector="{{AdminNewWidgetSection.numberOfProductsToDisplay}}" userInput="{{numberOfProductsToDisplay}}" stepKey="fillNumberOfProductsToDisplay"/> + <fillField selector="{{AdminNewWidgetSection.cacheLifetime}}" userInput="{{cacheLifetime}}" stepKey="fillCacheLifetime"/> + <click selector="{{AdminNewWidgetSection.addNewCondition}}" stepKey="clickAddNewCondition"/> + <selectOption selector="{{AdminNewWidgetSection.selectCondition}}" userInput="{{condition}}" stepKey="selectCondition"/> + <waitForPageLoad stepKey="waitForConditionsOperator"/> + <click selector="{{AdminNewWidgetSection.conditionOperator}}" stepKey="clickConditionsOperator"/> + <selectOption selector="{{AdminNewWidgetSection.selectOperator}}" userInput="{{conditionsOperator}}" stepKey="selectConditionsOperator"/> + <click selector="{{AdminNewWidgetSection.ruleParameter}}" stepKey="clickConditionParameter"/> + <fillField selector="{{AdminNewWidgetSection.setRuleParameter}}" userInput="{{conditionParameter}}" stepKey="fillConditionParameter"/> + <click selector="{{AdminNewWidgetSection.applyParameter}}" stepKey="clickApplyCondition"/> + <click selector="{{AdminMainActionsSection.save}}" stepKey="clickSaveWidget"/> + <waitForPageLoad stepKey="waitForSave"/> + <waitForText selector="{{AdminMessagesSection.success}}" userInput="The widget instance has been saved" stepKey="waitForSuccess"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Widget/Test/Mftf/ActionGroup/AdminCreateProductsListWidgetActionGroup.xml b/app/code/Magento/Widget/Test/Mftf/ActionGroup/AdminCreateProductsListWidgetActionGroup.xml index e3845adc9cd4a..85389ddd59bf8 100644 --- a/app/code/Magento/Widget/Test/Mftf/ActionGroup/AdminCreateProductsListWidgetActionGroup.xml +++ b/app/code/Magento/Widget/Test/Mftf/ActionGroup/AdminCreateProductsListWidgetActionGroup.xml @@ -8,7 +8,7 @@ <actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> - <actionGroup name="AdminCreateProductsListWidgetActionGroup" extends="AdminCreateWidgetActionGroup"> + <actionGroup name="AdminCreateProductsListWidgetActionGroup" extends="AdminCreateWidgetActionGroup" deprecated="Use AdminCreateCatalogProductsListWidgetActionGroup instead"> <annotations> <description>EXTENDS: AdminCreateWidgetActionGroup. Creates a Product List Widget. Validates that the Success Message is present and correct.</description> </annotations> diff --git a/app/code/Magento/Widget/Test/Mftf/ActionGroup/AdminCreateSpecificEntityWidgetActionGroup.xml b/app/code/Magento/Widget/Test/Mftf/ActionGroup/AdminCreateSpecificEntityWidgetActionGroup.xml new file mode 100644 index 0000000000000..1fcb6d47f555f --- /dev/null +++ b/app/code/Magento/Widget/Test/Mftf/ActionGroup/AdminCreateSpecificEntityWidgetActionGroup.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminCreateSpecificEntityWidgetActionGroup" extends="AdminCreateWidgetActionGroup"> + <annotations> + <description>Fill widget main fields and widget layout by index for anchor categories DisplayOn option</description> + </annotations> + <selectOption selector="{{AdminNewWidgetSection.specificEntitySelectContainer}}" userInput="{{widget.container}}" stepKey="setContainer"/> + <seeElement selector="{{AdminNewWidgetSection.specificEntitySelectRadio}}" stepKey="seeSpecificEntityRadio" after="waitForPageLoad"/> + <click selector="{{AdminNewWidgetSection.specificEntitySelectRadio}}" stepKey="clickSpecificEntityRadio" after="seeSpecificEntityRadio"/> + <seeElement selector="{{AdminNewWidgetSection.specificEntityOptionsChooser}}" stepKey="seeChooserTrigger" after="clickSpecificEntityRadio"/> + <click selector="{{AdminNewWidgetSection.specificEntityOptionsChooser}}" stepKey="clickChooserTrigger" after="seeChooserTrigger"/> + <waitForAjaxLoad stepKey="waitForAjaxCategoryLoad" after="clickChooserTrigger"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Widget/Test/Mftf/ActionGroup/AdminSaveAndContinueWidgetActionGroup.xml b/app/code/Magento/Widget/Test/Mftf/ActionGroup/AdminSaveAndContinueWidgetActionGroup.xml new file mode 100644 index 0000000000000..6d17a5c687b1a --- /dev/null +++ b/app/code/Magento/Widget/Test/Mftf/ActionGroup/AdminSaveAndContinueWidgetActionGroup.xml @@ -0,0 +1,19 @@ +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminSaveAndContinueWidgetActionGroup"> + <annotations> + <description>Click on the Save an Continue button and check the success message</description> + </annotations> + <scrollToTopOfPage stepKey="scrollToTopOfPage"/> + <click selector="{{AdminNewWidgetSection.saveAndContinue}}" stepKey="clickSaveWidget"/> + <waitForElementVisible selector="{{AdminMessagesSection.success}}" stepKey="waitForSuccessMessageAppeared"/> + <see selector="{{AdminMessagesSection.success}}" userInput="The widget instance has been saved" stepKey="seeSuccess"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Widget/Test/Mftf/ActionGroup/AdminSetWidgetNameAndStoreActionGroup.xml b/app/code/Magento/Widget/Test/Mftf/ActionGroup/AdminSetWidgetNameAndStoreActionGroup.xml new file mode 100644 index 0000000000000..ce19c1b086328 --- /dev/null +++ b/app/code/Magento/Widget/Test/Mftf/ActionGroup/AdminSetWidgetNameAndStoreActionGroup.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminSetWidgetNameAndStoreActionGroup"> + <annotations> + <description>Set widget name, store IDs and sort order on Widget edit page</description> + </annotations> + <arguments> + <argument name="widgetTitle" defaultValue="{{ProductsListWidget.name}}" type="string"/> + <argument name="widgetStoreIds" defaultValue="{{ProductsListWidget.store_ids}}" type="string"/> + <argument name="widgetSortOrder" defaultValue="{{ProductsListWidget.sort_order}}" type="string"/> + </arguments> + <waitForElementVisible selector="{{AdminNewWidgetSection.widgetTitle}}" stepKey="waitForWidgetTitleInputVisible"/> + <fillField selector="{{AdminNewWidgetSection.widgetTitle}}" userInput="{{widgetTitle}}" stepKey="fillTitle"/> + <selectOption selector="{{AdminNewWidgetSection.widgetStoreIds}}" parameterArray="{{widgetStoreIds}}" stepKey="setWidgetStoreId"/> + <fillField selector="{{AdminNewWidgetSection.widgetSortOrder}}" userInput="{{widgetSortOrder}}" stepKey="fillSortOrder"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Widget/Test/Mftf/ActionGroup/AdminSetWidgetTypeAndDesignActionGroup.xml b/app/code/Magento/Widget/Test/Mftf/ActionGroup/AdminSetWidgetTypeAndDesignActionGroup.xml new file mode 100644 index 0000000000000..3a9b4c53572c7 --- /dev/null +++ b/app/code/Magento/Widget/Test/Mftf/ActionGroup/AdminSetWidgetTypeAndDesignActionGroup.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminSetWidgetTypeAndDesignActionGroup"> + <annotations> + <description>Select type and design on Widget edit page</description> + </annotations> + <arguments> + <argument name="widgetType" defaultValue="{{ProductsListWidget.type}}" type="string"/> + <argument name="widgetDesign" defaultValue="{{ProductsListWidget.design_theme}}" type="string"/> + </arguments> + <waitForElementVisible selector="{{AdminNewWidgetSection.widgetType}}" stepKey="waitForTypeInputVisible"/> + <selectOption selector="{{AdminNewWidgetSection.widgetType}}" userInput="{{widgetType}}" stepKey="setWidgetType"/> + <selectOption selector="{{AdminNewWidgetSection.widgetDesignTheme}}" userInput="{{widgetDesign}}" stepKey="setWidgetDesignTheme"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Widget/Test/Mftf/Data/WidgetsData.xml b/app/code/Magento/Widget/Test/Mftf/Data/WidgetsData.xml index ffd2d025548cf..bb739aa88b1f0 100644 --- a/app/code/Magento/Widget/Test/Mftf/Data/WidgetsData.xml +++ b/app/code/Magento/Widget/Test/Mftf/Data/WidgetsData.xml @@ -12,6 +12,7 @@ <data key="type">Catalog Products List</data> <data key="design_theme">Magento Luma</data> <data key="name" unique="suffix">TestWidget</data> + <data key="sort_order">0</data> <array key="store_ids"> <item>All Store Views</item> </array> @@ -19,6 +20,20 @@ <data key="display_on">All Pages</data> <data key="container">Main Content Area</data> </entity> + <entity name="CatalogCategoryLinkSpecifiedCategory" type="widget"> + <data key="type">Catalog Category Link</data> + <data key="design_theme">Magento Luma</data> + <data key="name" unique="suffix">TestCategoryLinkWidgetOnSpecifiedCategory</data> + <array key="store_ids"> + <item>All Store Views</item> + </array> + <data key="sort_order">0</data> + <data key="page">catalog_category_view</data> + <data key="template">Category Link Block Template</data> + <data key="condition">SKU</data> + <data key="display_on">Anchor Categories</data> + <data key="container">Main Content Area</data> + </entity> <entity name="DynamicBlocksRotatorWidget" type="widget"> <data key="type">Dynamic Blocks Rotator</data> <data key="design_theme">Magento Luma</data> diff --git a/app/code/Magento/Widget/Test/Mftf/Section/AdminNewWidgetSection.xml b/app/code/Magento/Widget/Test/Mftf/Section/AdminNewWidgetSection.xml index 0777e6cbd58d9..8a17b589d7ab2 100644 --- a/app/code/Magento/Widget/Test/Mftf/Section/AdminNewWidgetSection.xml +++ b/app/code/Magento/Widget/Test/Mftf/Section/AdminNewWidgetSection.xml @@ -12,6 +12,7 @@ <element name="widgetType" type="select" selector="#code"/> <element name="widgetDesignTheme" type="select" selector="#theme_id"/> <element name="continue" type="button" timeout="30" selector="#continue_button"/> + <element name="resetBtn" type="button" selector=".page-actions-buttons button#reset" timeout="30"/> <element name="widgetTitle" type="input" selector="#title"/> <element name="widgetStoreIds" type="select" selector="#store_ids"/> <element name="widgetSortOrder" type="input" selector="#sort_order"/> @@ -26,6 +27,8 @@ <element name="widgetOptions" type="select" selector="#widget_instace_tabs_properties_section"/> <element name="addNewCondition" type="select" selector=".rule-param.rule-param-new-child"/> <element name="selectCondition" type="input" selector="#conditions__1__new_child"/> + <element name="conditionOperator" type="button" selector="#conditions__1__children>li:nth-child(1)>span:nth-child(3) a"/> + <element name="selectOperator" type="select" selector="#conditions__1__children>li:nth-child(1)>span:nth-child(3) select"/> <element name="ruleParameter" type="select" selector="#conditions__1__children>li:nth-child(1)>span:nth-child(4)>a"/> <element name="setRuleParameter" type="input" selector="#conditions__1--1__value"/> <element name="applyParameter" type="button" selector=".rule-param-apply"/> @@ -36,9 +39,19 @@ <element name="searchBlock" type="button" selector="//div[@class='admin__filter-actions']/button[@title='Search']"/> <element name="blockStatus" type="select" selector="//select[@name='chooser_is_active']"/> <element name="searchedBlock" type="button" selector="//*[@class='magento-message']//tbody/tr/td[1]"/> - <element name="saveWidget" type="select" selector="#save"/> + <element name="saveWidget" type="button" selector="#save" timeout="30"/> <element name="displayMode" type="select" selector="select[id*='display_mode']"/> <element name="restrictTypes" type="select" selector="select[id*='types']"/> <element name="saveAndContinue" type="button" selector="#save_and_edit_button" timeout="30"/> + <element name="specificEntitySelectContainer" type="select" selector="select[name='widget_instance[0][anchor_categories][block]']"/> + <element name="specificEntitySelectRadio" type="input" selector="#specific_anchor_categories_0"/> + <element name="specificEntityOptionsChooser" type="button" selector="#anchor_categories_ids_0 .widget-option-chooser"/> + <element name="widgetInstanceType" type="select" selector=".admin__field-control select#instance_code" /> + <!-- Catalog Product List Widget Options --> + <element name="title" type="input" selector="[name='parameters[title]']"/> + <element name="displayPageControl" type="select" selector="[name='parameters[show_pager]']"/> + <element name="numberOfProductsToDisplay" type="input" selector="[name='parameters[products_count]']"/> + <element name="cacheLifetime" type="input" selector="[name='parameters[cache_lifetime]']"/> </section> </sections> + diff --git a/app/code/Magento/Widget/Test/Mftf/Test/AdminContentWidgetsDisplayOnSpecificEntitiesTest.xml b/app/code/Magento/Widget/Test/Mftf/Test/AdminContentWidgetsDisplayOnSpecificEntitiesTest.xml new file mode 100644 index 0000000000000..96c1874ca4cc8 --- /dev/null +++ b/app/code/Magento/Widget/Test/Mftf/Test/AdminContentWidgetsDisplayOnSpecificEntitiesTest.xml @@ -0,0 +1,31 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminContentWidgetsDisplayOnSpecificEntitiesTest"> + <annotations> + <features value="Widget"/> + <stories value="Widget parameter configuration"/> + <title value="Admin content widgets display on specific entities test"/> + <description value="Admin should be able to select specific entities for widgets"/> + <severity value="CRITICAL"/> + <group value="widget"/> + </annotations> + <before> + <actionGroup ref="AdminLoginActionGroup" stepKey="LoginAsAdmin"/> + </before> + <after> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> + </after> + <amOnPage url="{{AdminNewWidgetPage.url}}" stepKey="createWidgetPage"/> + <actionGroup ref="AdminCreateSpecificEntityWidgetActionGroup" stepKey="fillForm"> + <argument name="widget" value="CatalogCategoryLinkSpecifiedCategory"/> + </actionGroup> + </test> +</tests> diff --git a/app/code/Magento/Widget/Test/Mftf/Test/AdminResetWidgetTest.xml b/app/code/Magento/Widget/Test/Mftf/Test/AdminResetWidgetTest.xml new file mode 100644 index 0000000000000..5e053778fe7ed --- /dev/null +++ b/app/code/Magento/Widget/Test/Mftf/Test/AdminResetWidgetTest.xml @@ -0,0 +1,65 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminResetWidgetTest"> + <annotations> + <features value="Widget"/> + <stories value="CMS Widgets"/> + <title value="Reset Widget"/> + <description value="Check that admin user can reset widget form after filling out all information"/> + <severity value="MAJOR"/> + <testCaseId value="MC-37892"/> + <group value="widget"/> + </annotations> + <before> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + </before> + <after> + <actionGroup ref="AdminDeleteWidgetActionGroup" stepKey="deleteWidget"> + <argument name="widget" value="ProductsListWidget"/> + </actionGroup> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logoutFromAdmin"/> + </after> + <amOnPage url="{{AdminNewWidgetPage.url}}" stepKey="amOnAdminNewWidgetPage"/> + <actionGroup ref="AdminSetWidgetTypeAndDesignActionGroup" stepKey="firstSetTypeAndDesign"> + <argument name="widgetType" value="{{ProductsListWidget.type}}"/> + <argument name="widgetDesign" value="{{ProductsListWidget.design_theme}}"/> + </actionGroup> + <click selector="{{AdminNewWidgetSection.resetBtn}}" stepKey="resetInstance"/> + <dontSeeInField userInput="{{ProductsListWidget.type}}" selector="{{AdminNewWidgetSection.widgetType}}" stepKey="dontSeeTypeAfterReset"/> + <dontSeeInField userInput="{{ProductsListWidget.design_theme}}" selector="{{AdminNewWidgetSection.widgetDesignTheme}}" stepKey="dontSeeDesignAfterReset"/> + <actionGroup ref="AdminSetWidgetTypeAndDesignActionGroup" stepKey="setTypeAndDesignAfterReset"> + <argument name="widgetType" value="{{ProductsListWidget.type}}"/> + <argument name="widgetDesign" value="{{ProductsListWidget.design_theme}}"/> + </actionGroup> + <click selector="{{AdminNewWidgetSection.continue}}" stepKey="clickContinue"/> + <actionGroup ref="AdminSetWidgetNameAndStoreActionGroup" stepKey="setNameAndStore"> + <argument name="widgetTitle" value="{{ProductsListWidget.name}}"/> + <argument name="widgetStoreIds" value="{{ProductsListWidget.store_ids}}"/> + <argument name="widgetSortOrder" value="{{ProductsListWidget.sort_order}}"/> + </actionGroup> + <click selector="{{AdminNewWidgetSection.resetBtn}}" stepKey="resetNameAndStore"/> + <dontSeeInField userInput="{{ProductsListWidget.name}}" selector="{{AdminNewWidgetSection.widgetTitle}}" stepKey="dontSeeNameAfterReset"/> + <dontSeeInField userInput="{{ProductsListWidget.store_ids[0]}}" selector="{{AdminNewWidgetSection.widgetStoreIds}}" stepKey="dontSeeStoreAfterReset"/> + <dontSeeInField userInput="{{ProductsListWidget.sort_order}}" selector="{{AdminNewWidgetSection.widgetSortOrder}}" stepKey="dontSeeSortOrderAfterReset"/> + <actionGroup ref="AdminSetWidgetNameAndStoreActionGroup" stepKey="setNameAndStoreAfterReset"> + <argument name="widgetTitle" value="{{ProductsListWidget.name}}"/> + <argument name="widgetStoreIds" value="{{ProductsListWidget.store_ids}}"/> + <argument name="widgetSortOrder" value="{{ProductsListWidget.sort_order}}"/> + </actionGroup> + <actionGroup ref="AdminSaveAndContinueWidgetActionGroup" stepKey="saveWidgetAndContinue"/> + <click selector="{{AdminNewWidgetSection.resetBtn}}" stepKey="resetWidgetForm"/> + <seeInField userInput="{{ProductsListWidget.name}}" selector="{{AdminNewWidgetSection.widgetTitle}}" stepKey="seeNameAfterReset"/> + <seeInField userInput="{{ProductsListWidget.store_ids[0]}}" selector="{{AdminNewWidgetSection.widgetStoreIds}}" stepKey="seeStoreAfterReset"/> + <seeInField userInput="{{ProductsListWidget.sort_order}}" selector="{{AdminNewWidgetSection.widgetSortOrder}}" stepKey="seeSortOrderAfterReset"/> + <seeInField userInput="{{ProductsListWidget.type}}" selector="{{AdminNewWidgetSection.widgetInstanceType}}" stepKey="seeTypeAfterReset"/> + <seeInField userInput="{{ProductsListWidget.design_theme}}" selector="{{AdminNewWidgetSection.widgetDesignTheme}}" stepKey="seeThemeAfterReset"/> + </test> +</tests> diff --git a/app/code/Magento/Widget/Test/Unit/Controller/Adminhtml/Widget/Instance/ValidateTest.php b/app/code/Magento/Widget/Test/Unit/Controller/Adminhtml/Widget/Instance/ValidateTest.php new file mode 100644 index 0000000000000..6aa78d43b09ea --- /dev/null +++ b/app/code/Magento/Widget/Test/Unit/Controller/Adminhtml/Widget/Instance/ValidateTest.php @@ -0,0 +1,155 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Widget\Test\Unit\Controller\Adminhtml\Widget\Instance; + +use Magento\Backend\App\Action\Context; +use Magento\Framework\App\RequestInterface; +use Magento\Framework\App\ResponseInterface; +use Magento\Framework\App\ViewInterface; +use Magento\Framework\Message\ManagerInterface; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; +use Magento\Framework\View\Element\Messages; +use Magento\Framework\View\Layout; +use Magento\Framework\View\LayoutInterface; +use Magento\Widget\Controller\Adminhtml\Widget\Instance\Validate; +use Magento\Widget\Model\Widget\Instance; +use Magento\Widget\Model\Widget\InstanceFactory; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * Test for \Magento\Widget\Controller\Adminhtml\Widget\Instance\Validate. + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ +class ValidateTest extends TestCase +{ + private $errorMessage = 'We cannot create the widget instance because it is missing required information.'; + + /** + * @var Validate + */ + private $model; + + /** + * @var Layout|MockObject + */ + private $layout; + + /** + * @var ManagerInterface|MockObject + */ + private $messageManagerMock; + + /** + * @var MockObject + */ + private $responseMock; + + /** + * @var MockObject + */ + private $widgetMock; + + /** + * @var Messages|MockObject + */ + private $messagesBlock; + + /** + * @inheritDoc + */ + protected function setUp(): void + { + $objectManager = new ObjectManager($this); + + $request = $this->getMockForAbstractClass(RequestInterface::class); + $this->messageManagerMock = $this->createMock(ManagerInterface::class); + $viewMock = $this->createMock(ViewInterface::class); + $layoutMock = $this->getMockBuilder(LayoutInterface::class) + ->disableOriginalConstructor() + ->addMethods(['initMessages']) + ->getMockForAbstractClass(); + $this->messagesBlock = $this->createMock(Messages::class); + $layoutMock->method('getMessagesBlock')->willReturn($this->messagesBlock); + $viewMock->method('getLayout')->willReturn($layoutMock); + $this->responseMock = $this->getMockBuilder(ResponseInterface::class) + ->disableOriginalConstructor() + ->addMethods(['representJson']) + ->getMockForAbstractClass(); + + $context = $this->createMock(Context::class); + $context->method('getRequest')->willReturn($request); + $context->method('getMessageManager')->willReturn($this->messageManagerMock); + $context->method('getView')->willReturn($viewMock); + $context->method('getResponse')->willReturn($this->responseMock); + + $this->widgetMock = $this->getMockBuilder(Instance::class) + ->disableOriginalConstructor() + ->onlyMethods(['setType', 'setCode', 'getType']) + ->addMethods(['setThemeId', 'getThemeId']) + ->getMock(); + $this->widgetMock->method('setType')->willReturnSelf(); + $this->widgetMock->method('setCode')->willReturnSelf(); + $this->widgetMock->method('setThemeId')->willReturnSelf(); + $widgetFactoryMock = $this->createMock(InstanceFactory::class); + $widgetFactoryMock->method('create')->willReturn($this->widgetMock); + + $this->model = $objectManager->getObject( + Validate::class, + [ + 'widgetFactory' => $widgetFactoryMock, + 'context' => $context, + 'layout' => $this->layout + ] + ); + } + + /** + * Test execute + * + * @return void + */ + public function testExecute(): void + { + $this->widgetMock->expects($this->once()) + ->method('getThemeId') + ->willReturn(777); + $this->widgetMock->expects($this->once()) + ->method('getType') + ->willReturn('some type'); + + $this->messageManagerMock->expects($this->never()) + ->method('addErrorMessage') + ->with($this->errorMessage); + $this->responseMock->expects($this->once()) + ->method('representJson') + ->with(json_encode(['error' => false])); + + $this->model->execute(); + } + + /** + * Test execute with Phrase object + * + * @return void + */ + public function testExecutePhraseObject(): void + { + $this->messageManagerMock->expects($this->once()) + ->method('addErrorMessage') + ->with($this->errorMessage); + $this->messagesBlock->expects($this->once()) + ->method('getGroupedHtml') + ->willReturn($this->errorMessage); + $this->responseMock->expects($this->once()) + ->method('representJson') + ->with(json_encode(['error' => true, 'html_message' => $this->errorMessage])); + + $this->model->execute(); + } +} diff --git a/app/code/Magento/Widget/Test/Unit/Model/Template/FilterTest.php b/app/code/Magento/Widget/Test/Unit/Model/Template/FilterTest.php index 7afc9dc93f46e..6a23b5c66e5ba 100644 --- a/app/code/Magento/Widget/Test/Unit/Model/Template/FilterTest.php +++ b/app/code/Magento/Widget/Test/Unit/Model/Template/FilterTest.php @@ -267,7 +267,7 @@ public function testMediaDirective() { $image = 'wysiwyg/VB.png'; $construction = ['{{media url="' . $image . '"}}', 'media', ' url="' . $image . '"']; - $baseUrl = 'http://localhost/pub/media/'; + $baseUrl = 'http://localhost/media/'; $this->storeMock->expects($this->once()) ->method('getBaseUrl') @@ -285,7 +285,7 @@ public function testMediaDirectiveWithEncodedQuotes() { $image = 'wysiwyg/VB.png'; $construction = ['{{media url="' . $image . '"}}', 'media', ' url="' . $image . '"']; - $baseUrl = 'http://localhost/pub/media/'; + $baseUrl = 'http://localhost/media/'; $this->storeMock->expects($this->once()) ->method('getBaseUrl') diff --git a/app/code/Magento/Widget/view/adminhtml/templates/instance/edit/layout.phtml b/app/code/Magento/Widget/view/adminhtml/templates/instance/edit/layout.phtml index 6dab476115cee..93c5cac33f947 100644 --- a/app/code/Magento/Widget/view/adminhtml/templates/instance/edit/layout.phtml +++ b/app/code/Magento/Widget/view/adminhtml/templates/instance/edit/layout.phtml @@ -5,11 +5,12 @@ */ /** @var \Magento\Widget\Block\Adminhtml\Widget\Instance\Edit\Tab\Main\Layout $block */ +/** @var \Magento\Framework\Escaper $escaper */ /** @var \Magento\Framework\View\Helper\SecureHtmlRenderer $secureRenderer */ ?> <fieldset class="fieldset"> - <legend class="legend"><span><?= $block->escapeHtml(__('Layout Updates')) ?></span></legend> + <legend class="legend"><span><?= $escaper->escapeHtml(__('Layout Updates')) ?></span></legend> <br /> <div class="widget-layout-updates"> <div id="page_group_container"></div> @@ -45,56 +46,56 @@ var pageGroupTemplate = '<div class="fieldset-wrapper page_group_container" id=" script; foreach ($block->getDisplayOnContainers() as $container): $scriptString .= <<<script - '<div class="no-display {$block->escapeJs($container['code'])} group_container" '+ - 'id="{$block->escapeJs($container['name'])}_<%- data.id %>">'+ + '<div class="no-display {$escaper->escapeJs($container['code'])} group_container" '+ + 'id="{$escaper->escapeJs($container['name'])}_<%- data.id %>">'+ '<input disabled="disabled" type="hidden" class="container_name" name="__[container_name]" '+ - 'value="widget_instance[<%- data.id %>][{$block->escapeJs($container['name'])}]" />'+ + 'value="widget_instance[<%- data.id %>][{$escaper->escapeJs($container['name'])}]" />'+ '<input disabled="disabled" type="hidden" '+ - 'name="widget_instance[<%- data.id %>][{$block->escapeJs($container['name'])}][page_id]" '+ + 'name="widget_instance[<%- data.id %>][{$escaper->escapeJs($container['name'])}][page_id]" '+ 'value="<%- data.page_id %>" />'+ '<input disabled="disabled" type="hidden" class="layout_handle_pattern" '+ - 'name="widget_instance[<%- data.id %>][{$block->escapeJs($container['name'])}][layout_handle]" '+ - 'value="{$block->escapeJs($container['layout_handle'])}" />'+ + 'name="widget_instance[<%- data.id %>][{$escaper->escapeJs($container['name'])}][layout_handle]" '+ + 'value="{$escaper->escapeJs($container['layout_handle'])}" />'+ '<table class="data-table">'+ '<col width="200" />'+ '<thead>'+ '<tr>'+ - '<th><label>{$block->escapeJs(__('%1', $container['label']))}</label></th>'+ - '<th><label>{$block->escapeJs(__('Container'))} <span class="required">*</span></label></th>'+ - '<th><label>{$block->escapeJs(__('Template'))}</label></th>'+ + '<th><label>{$escaper->escapeJs(__('%1', $container['label']))}</label></th>'+ + '<th><label>{$escaper->escapeJs(__('Container'))} <span class="required">*</span></label></th>'+ + '<th><label>{$escaper->escapeJs(__('Template'))}</label></th>'+ '</tr>'+ '</thead>'+ '<tbody>'+ '<tr>'+ '<td>'+ '<input disabled="disabled" type="radio" class="radio for_all" '+ - 'id="all_{$block->escapeJs($container['name'])}_<%- data.id %>" '+ - 'name="widget_instance[<%- data.id %>][{$block->escapeJs($container['name'])}][for]" '+ + 'id="all_{$escaper->escapeJs($container['name'])}_<%- data.id %>" '+ + 'name="widget_instance[<%- data.id %>][{$escaper->escapeJs($container['name'])}][for]" '+ 'value="all" checked="checked" /> '+ - '<label for="all_{$block->escapeJs($container['name'])}_<%- data.id %>">'+ - '{$block->escapeJs(__('All'))}</label><br />'+ + '<label for="all_{$escaper->escapeJs($container['name'])}_<%- data.id %>">'+ + '{$escaper->escapeJs(__('All'))}</label><br />'+ '<input disabled="disabled" type="radio" class="radio for_specific" '+ - 'id="specific_{$block->escapeJs($container['name'])}_<%- data.id %>" '+ - 'name="widget_instance[<%- data.id %>][{$block->escapeJs($container['name'])}][for]" '+ + 'id="specific_{$escaper->escapeJs($container['name'])}_<%- data.id %>" '+ + 'name="widget_instance[<%- data.id %>][{$escaper->escapeJs($container['name'])}][for]" '+ 'value="specific" /> '+ - '<label for="specific_{$block->escapeJs($container['name'])}_<%- data.id %>">'+ - '{$block->escapeJs(__('Specific %1', $container['label']))}</label>'+ + '<label for="specific_{$escaper->escapeJs($container['name'])}_<%- data.id %>">'+ + '{$escaper->escapeJs(__('Specific %1', $container['label']))}</label>'+ script; $scriptString1 = $secureRenderer->renderEventListenerAsTag( "onclick", "WidgetInstance.togglePageGroupChooser(this)", - "all_" . $block->escapeJs($container['name']) . "_<%- data.id %>" + "#all_" . $escaper->escapeJs($container['name']) . "_<%- data.id %>" ); - $scriptString .= "'" . $block->escapeJs($scriptString1) . "'+" . PHP_EOL; + $scriptString .= "'" . $escaper->escapeJs($scriptString1) . "'+" . PHP_EOL; $scriptString1 = $secureRenderer->renderEventListenerAsTag( "onclick", "WidgetInstance.togglePageGroupChooser(this)", - "specific_" . $block->escapeJs($container['name']) . "_<%- data.id %>" + "#specific_" . $escaper->escapeJs($container['name']) . "_<%- data.id %>" ); - $scriptString .= "'" . $block->escapeJs($scriptString1) . "'+" . PHP_EOL; + $scriptString .= "'" . $escaper->escapeJs($scriptString1) . "'+" . PHP_EOL; $scriptString .= <<<script '</td>'+ @@ -111,26 +112,30 @@ script; '</tr>'+ '</tbody>'+ '</table>'+ - '<div class="no-display chooser_container" id="{$block->escapeJs($container['name'])}_ids_<%- data.id %>">'+ + '<div class="no-display chooser_container" id="{$escaper->escapeJs($container['name'])}_ids_<%- data.id %>">'+ '<input disabled="disabled" type="hidden" class="is_anchor_only" '+ - 'name="widget_instance[<%- data.id %>][{$block->escapeJs($container['name'])}][is_anchor_only]" '+ - 'value="{$block->escapeJs($container['is_anchor_only'])}" />'+ + 'name="widget_instance[<%- data.id %>][{$escaper->escapeJs($container['name'])}][is_anchor_only]" '+ + 'value="{$escaper->escapeJs($container['is_anchor_only'])}" />'+ '<input disabled="disabled" type="hidden" class="product_type_id" '+ - 'name="widget_instance[<%- data.id %>][{$block->escapeJs($container['name'])}][product_type_id]" '+ - 'value="{$block->escapeJs($container['product_type_id'])}" />'+ + 'name="widget_instance[<%- data.id %>][{$escaper->escapeJs($container['name'])}][product_type_id]" '+ + 'value="{$escaper->escapeJs($container['product_type_id'])}" />'+ '<p>' + '<input disabled="disabled" type="text" class="input-text entities" '+ - 'name="widget_instance[<%- data.id %>][{$block->escapeJs($container['name'])}][entities]" '+ - 'value="<%- data.{$block->escapeJs($container['name'])}_entities %>" readonly="readonly" /> ' + + 'name="widget_instance[<%- data.id %>][{$escaper->escapeJs($container['name'])}][entities]" '+ + 'value="<%- data.{$escaper->escapeJs($container['name'])}_entities %>" readonly="readonly" /> ' + '<a class="widget-option-chooser" href="#" '+ - 'title="{$block->escapeJs(__('Open Chooser'))}">' + - '<img src="{$block->escapeJs($block->getViewFileUrl('images/rule_chooser_trigger.gif'))}" '+ - 'alt="{$block->escapeJs(__('Open Chooser'))}" />' + + 'title="{$escaper->escapeJs(__('Open Chooser'))}">' + + '<img src="{$escaper->escapeJs( + $escaper->escapeUrl($block->getViewFileUrl('images/rule_chooser_trigger.gif')) + )}" '+ + 'alt="{$escaper->escapeJs(__('Open Chooser'))}" />' + '</a> ' + '<a id="widget-apply-<%- data.id %>" href="#" '+ - 'title="{$block->escapeJs(__('Apply'))}">' + - '<img src="{$block->escapeJs($block->getViewFileUrl('images/rule_component_apply.gif'))}" '+ - 'alt="{$block->escapeJs(__('Apply'))}" />' + + 'title="{$escaper->escapeJs(__('Apply'))}">' + + '<img src="{$escaper->escapeJs( + $escaper->escapeUrl($block->getViewFileUrl('images/rule_component_apply.gif')) + )}" '+ + 'alt="{$escaper->escapeJs(__('Apply'))}" />' + '</a>' + '</p>'+ '<div class="chooser"></div>'+ @@ -141,19 +146,19 @@ script; $scriptString1 = $secureRenderer->renderEventListenerAsTag( "onclick", "event.preventDefault(); - WidgetInstance.displayEntityChooser('" .$block->escapeJs($container['code']) . - "', '" . $block->escapeJs($container['name']) . "_ids_<%- data.id %>')", - "div#" . $block->escapeJs($container['name']) . "_ids_<%- data.id %> a.widget-option-chooser" + WidgetInstance.displayEntityChooser('" .$escaper->escapeJs($container['code']) . + "', '" . $escaper->escapeJs($container['name']) . "_ids_<%- data.id %>')", + "div#" . $escaper->escapeJs($container['name']) . "_ids_<%- data.id %> a.widget-option-chooser" ); - $scriptString .= "'" . $block->escapeJs($scriptString1) . "'+" . PHP_EOL; + $scriptString .= "'" . $escaper->escapeJs($scriptString1) . "'+" . PHP_EOL; $scriptString1 = $secureRenderer->renderEventListenerAsTag( 'onclick', "event.preventDefault(); - WidgetInstance.hideEntityChooser('" . $block->escapeJs($container['name']) . "_ids_<%- data.id %>')", + WidgetInstance.hideEntityChooser('" . $escaper->escapeJs($container['name']) . "_ids_<%- data.id %>')", "a#widget-apply-<%- data.id %>" ); - $scriptString .= "'" . $block->escapeJs($scriptString1) . "'+" . PHP_EOL; + $scriptString .= "'" . $escaper->escapeJs($scriptString1) . "'+" . PHP_EOL; $scriptString .= <<<script '</div>'+ @@ -175,8 +180,8 @@ $scriptString .= <<<script '<col width="200" />'+ '<thead>'+ '<tr>'+ - '<th><label>{$block->escapeJs(__('Container'))} <span class="required">*</span></label></th>'+ - '<th><label>{$block->escapeJs(__('Template'))}</label></th>'+ + '<th><label>{$escaper->escapeJs(__('Container'))} <span class="required">*</span></label></th>'+ + '<th><label>{$escaper->escapeJs(__('Template'))}</label></th>'+ '<th> </th>'+ '</tr>'+ '</thead>'+ @@ -208,9 +213,9 @@ $scriptString .= <<<script '<col width="200" />'+ '<thead>'+ '<tr>'+ - '<th><label>{$block->escapeJs(__('Page'))} <span class="required">*</span></label></th>'+ - '<th><label>{$block->escapeJs(__('Container'))} <span class="required">*</span></label></th>'+ - '<th><label>{$block->escapeJs(__('Template'))}</label></th>'+ + '<th><label>{$escaper->escapeJs(__('Page'))} <span class="required">*</span></label></th>'+ + '<th><label>{$escaper->escapeJs(__('Container'))} <span class="required">*</span></label></th>'+ + '<th><label>{$escaper->escapeJs(__('Template'))}</label></th>'+ '</tr>'+ '</thead>'+ '<tbody>'+ @@ -242,9 +247,9 @@ $scriptString .= <<<script '<col width="200" />'+ '<thead>'+ '<tr>'+ - '<th><label>{$block->escapeJs(__('Page'))} <span class="required">*</span></label></th>'+ - '<th><label>{$block->escapeJs(__('Container'))} <span class="required">*</span></label></th>'+ - '<th><label>{$block->escapeJs(__('Template'))}</label></th>'+ + '<th><label>{$escaper->escapeJs(__('Page'))} <span class="required">*</span></label></th>'+ + '<th><label>{$escaper->escapeJs(__('Container'))} <span class="required">*</span></label></th>'+ + '<th><label>{$escaper->escapeJs(__('Template'))}</label></th>'+ '</tr>'+ '</thead>'+ '<tbody>'+ @@ -412,10 +417,10 @@ var WidgetInstance = { additional = {}; } if (type == 'categories') { - additional.url = '{$block->escapeJs($block->getCategoriesChooserUrl())}'; + additional.url = '{$escaper->escapeJs($escaper->escapeUrl($block->getCategoriesChooserUrl()))}'; additional.post_parameters = \$H({'is_anchor_only':$(chooser).down('input.is_anchor_only').value}); } else if (type == 'products') { - additional.url = '{$block->escapeUrl($block->getProductsChooserUrl())}'; + additional.url = '{$escaper->escapeJs($escaper->escapeUrl($block->getProductsChooserUrl()))}'; additional.post_parameters = \$H({'product_type_id':$(chooser).down('input.product_type_id').value}); } if (chooser && additional) { @@ -521,13 +526,13 @@ var WidgetInstance = { selected = ''; parameters = {}; if (type == 'block_reference') { - url = '{$block->escapeJs($block->getBlockChooserUrl())}'; + url = '{$escaper->escapeJs($escaper->escapeUrl($block->getBlockChooserUrl()))}'; if (additional.selectedBlock) { selected = additional.selectedBlock; } parameters.layout = value; } else if (type == 'block_template') { - url = '{$block->escapeJs($block->getTemplateChooserUrl())}'; + url = '{$escaper->escapeJs($escaper->escapeUrl($block->getTemplateChooserUrl()))}'; if (additional.selectedTemplate) { selected = additional.selectedTemplate; } diff --git a/app/code/Magento/Wishlist/Block/AddToWishlist.php b/app/code/Magento/Wishlist/Block/AddToWishlist.php index 3ba350af94176..7997a6ed99031 100644 --- a/app/code/Magento/Wishlist/Block/AddToWishlist.php +++ b/app/code/Magento/Wishlist/Block/AddToWishlist.php @@ -6,13 +6,19 @@ namespace Magento\Wishlist\Block; +use Magento\Catalog\Api\Data\ProductTypeInterface; +use Magento\Catalog\Api\ProductTypeListInterface; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\View\Element\Template; +use Magento\Framework\View\Element\Template\Context; + /** * Wishlist js plugin initialization block * * @api * @since 100.1.0 */ -class AddToWishlist extends \Magento\Framework\View\Element\Template +class AddToWishlist extends Template { /** * Product types @@ -22,17 +28,25 @@ class AddToWishlist extends \Magento\Framework\View\Element\Template private $productTypes; /** - * @param \Magento\Framework\View\Element\Template\Context $context + * @var ProductTypeListInterface + */ + private $productTypeList; + + /** + * AddToWishlist constructor. + * + * @param Context $context * @param array $data + * @param ProductTypeListInterface|null $productTypeList */ public function __construct( - \Magento\Framework\View\Element\Template\Context $context, - array $data = [] + Context $context, + array $data = [], + ?ProductTypeListInterface $productTypeList = null ) { - parent::__construct( - $context, - $data - ); + parent::__construct($context, $data); + $this->productTypes = []; + $this->productTypeList = $productTypeList ?: ObjectManager::getInstance()->get(ProductTypeListInterface::class); } /** @@ -49,36 +63,16 @@ public function getWishlistOptions() /** * Returns an array of product types * - * @return array|null - * @throws \Magento\Framework\Exception\LocalizedException + * @return array */ - private function getProductTypes() + private function getProductTypes(): array { - if ($this->productTypes === null) { - $this->productTypes = []; - $block = $this->getLayout()->getBlock('category.products.list'); - if ($block) { - $productCollection = $block->getLoadedProductCollection(); - $productTypes = []; - /** @var $product \Magento\Catalog\Model\Product */ - foreach ($productCollection as $product) { - $productTypes[] = $this->escapeHtml($product->getTypeId()); - } - $this->productTypes = array_unique($productTypes); - } + if (count($this->productTypes) === 0) { + /** @var ProductTypeInterface productTypes */ + $this->productTypes = array_map(function ($productType) { + return $productType->getName(); + }, $this->productTypeList->getProductTypes()); } return $this->productTypes; } - - /** - * {@inheritdoc} - * @since 100.1.0 - */ - protected function _toHtml() - { - if (!$this->getProductTypes()) { - return ''; - } - return parent::_toHtml(); - } } diff --git a/app/code/Magento/Wishlist/CustomerData/Wishlist.php b/app/code/Magento/Wishlist/CustomerData/Wishlist.php index ae54289d4b1c9..2f6b57a8650c4 100644 --- a/app/code/Magento/Wishlist/CustomerData/Wishlist.php +++ b/app/code/Magento/Wishlist/CustomerData/Wishlist.php @@ -68,7 +68,7 @@ public function __construct( } /** - * {@inheritdoc} + * @inheritdoc */ public function getSectionData() { @@ -80,6 +80,8 @@ public function getSectionData() } /** + * Get counter + * * @return string */ protected function getCounter() @@ -156,7 +158,6 @@ protected function getItemData(\Magento\Wishlist\Model\Item $wishlistItem) * * @param \Magento\Catalog\Model\Product $product * @return array - * @SuppressWarnings(PHPMD.NPathComplexity) */ protected function getImageData($product) { @@ -164,27 +165,11 @@ protected function getImageData($product) $helper = $this->imageHelperFactory->create() ->init($product, 'wishlist_sidebar_block'); - $template = 'Magento_Catalog/product/image_with_borders'; - - try { - $imagesize = $helper->getResizedImageInfo(); - } catch (NotLoadInfoImageException $exception) { - $imagesize = [$helper->getWidth(), $helper->getHeight()]; - } - - $width = $helper->getFrame() - ? $helper->getWidth() - : $imagesize[0]; - - $height = $helper->getFrame() - ? $helper->getHeight() - : $imagesize[1]; - return [ - 'template' => $template, + 'template' => 'Magento_Catalog/product/image_with_borders', 'src' => $helper->getUrl(), - 'width' => $width, - 'height' => $height, + 'width' => $helper->getWidth(), + 'height' => $helper->getHeight(), 'alt' => $helper->getLabel(), ]; } diff --git a/app/code/Magento/Wishlist/Model/Wishlist/AddProductsToWishlist.php b/app/code/Magento/Wishlist/Model/Wishlist/AddProductsToWishlist.php index 7acfb503a5ad0..088805ffe76ac 100644 --- a/app/code/Magento/Wishlist/Model/Wishlist/AddProductsToWishlist.php +++ b/app/code/Magento/Wishlist/Model/Wishlist/AddProductsToWishlist.php @@ -8,6 +8,7 @@ namespace Magento\Wishlist\Model\Wishlist; use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Catalog\Model\Product\Attribute\Source\Status; use Magento\Framework\Exception\AlreadyExistsException; use Magento\Framework\Exception\LocalizedException; use Magento\Framework\Exception\NoSuchEntityException; @@ -113,6 +114,12 @@ private function addItemToWishlist(Wishlist $wishlist, WishlistItem $wishlistIte } try { + if ((int)$wishlistItem->getQuantity() === 0) { + throw new LocalizedException(__("The quantity of a wish list item cannot be 0")); + } + if ($product->getStatus() == Status::STATUS_DISABLED) { + throw new LocalizedException(__("The product is disabled")); + } $options = $this->buyRequestBuilder->build($wishlistItem, (int) $product->getId()); $result = $wishlist->addNewItem($product, $options); diff --git a/app/code/Magento/Wishlist/Model/Wishlist/BuyRequest/BundleDataProvider.php b/app/code/Magento/Wishlist/Model/Wishlist/BuyRequest/BundleDataProvider.php index 1cfa316c3cd01..3a4532d53624a 100644 --- a/app/code/Magento/Wishlist/Model/Wishlist/BuyRequest/BundleDataProvider.php +++ b/app/code/Magento/Wishlist/Model/Wishlist/BuyRequest/BundleDataProvider.php @@ -7,6 +7,7 @@ namespace Magento\Wishlist\Model\Wishlist\BuyRequest; +use Magento\Framework\Exception\LocalizedException; use Magento\Wishlist\Model\Wishlist\Data\WishlistItem; /** @@ -32,15 +33,48 @@ public function execute(WishlistItem $wishlistItem, ?int $productId): array continue; } - [, $optionId, $optionValueId, $optionQuantity] = $optionData; + [$optionType, $optionId, $optionValueId, $optionQuantity] = $optionData; - $bundleOptionsData['bundle_option'][$optionId] = $optionValueId; - $bundleOptionsData['bundle_option_qty'][$optionId] = $optionQuantity; + if ($optionType == self::PROVIDER_OPTION_TYPE) { + $bundleOptionsData['bundle_option'][$optionId] = $optionValueId; + $bundleOptionsData['bundle_option_qty'][$optionId] = $optionQuantity; + } + } + //for bundle options with custom quantity + foreach ($wishlistItem->getEnteredOptions() as $option) { + // phpcs:ignore Magento2.Functions.DiscouragedFunction + $optionData = \explode('/', base64_decode($option->getUid())); + + if ($this->isProviderApplicable($optionData) === false) { + continue; + } + $this->validateInput($optionData); + + [$optionType, $optionId, $optionValueId] = $optionData; + if ($optionType == self::PROVIDER_OPTION_TYPE) { + $optionQuantity = $option->getValue(); + $bundleOptionsData['bundle_option'][$optionId] = $optionValueId; + $bundleOptionsData['bundle_option_qty'][$optionId] = $optionQuantity; + } } return $bundleOptionsData; } + /** + * Validates the provided options structure + * + * @param array $optionData + * @throws LocalizedException + */ + private function validateInput(array $optionData): void + { + if (count($optionData) !== 4) { + $errorMessage = __('Wrong format of the entered option data'); + throw new LocalizedException($errorMessage); + } + } + /** * Checks whether this provider is applicable for the current option * diff --git a/app/code/Magento/Wishlist/Model/Wishlist/RemoveProductsFromWishlist.php b/app/code/Magento/Wishlist/Model/Wishlist/RemoveProductsFromWishlist.php index d143830064752..3599ad237da3a 100644 --- a/app/code/Magento/Wishlist/Model/Wishlist/RemoveProductsFromWishlist.php +++ b/app/code/Magento/Wishlist/Model/Wishlist/RemoveProductsFromWishlist.php @@ -7,6 +7,7 @@ namespace Magento\Wishlist\Model\Wishlist; +use Magento\Framework\Exception\LocalizedException; use Magento\Wishlist\Model\Item as WishlistItem; use Magento\Wishlist\Model\ItemFactory as WishlistItemFactory; use Magento\Wishlist\Model\ResourceModel\Item as WishlistItemResource; @@ -63,7 +64,7 @@ public function __construct( public function execute(Wishlist $wishlist, array $wishlistItemsIds): WishlistOutput { foreach ($wishlistItemsIds as $wishlistItemId) { - $this->removeItemFromWishlist((int) $wishlistItemId); + $this->removeItemFromWishlist((int) $wishlistItemId, $wishlist); } return $this->prepareOutput($wishlist); @@ -73,12 +74,22 @@ public function execute(Wishlist $wishlist, array $wishlistItemsIds): WishlistOu * Remove product item from wishlist * * @param int $wishlistItemId + * @param Wishlist $wishlist * * @return void */ - private function removeItemFromWishlist(int $wishlistItemId): void + private function removeItemFromWishlist(int $wishlistItemId, Wishlist $wishlist): void { try { + if ($wishlist->getItem($wishlistItemId) == null) { + throw new LocalizedException( + __( + 'The wishlist item with ID "%id" does not belong to the wishlist', + ['id' => $wishlistItemId] + ) + ); + } + $wishlist->getItemCollection()->clear(); /** @var WishlistItem $wishlistItem */ $wishlistItem = $this->wishlistItemFactory->create(); $this->wishlistItemResource->load($wishlistItem, $wishlistItemId); @@ -90,6 +101,8 @@ private function removeItemFromWishlist(int $wishlistItemId): void } $this->wishlistItemResource->delete($wishlistItem); + } catch (LocalizedException $exception) { + $this->addError($exception->getMessage()); } catch (\Exception $e) { $this->addError( __( diff --git a/app/code/Magento/Wishlist/Model/Wishlist/UpdateProductsInWishlist.php b/app/code/Magento/Wishlist/Model/Wishlist/UpdateProductsInWishlist.php index 4abcada138362..092d9693f23de 100644 --- a/app/code/Magento/Wishlist/Model/Wishlist/UpdateProductsInWishlist.php +++ b/app/code/Magento/Wishlist/Model/Wishlist/UpdateProductsInWishlist.php @@ -7,6 +7,7 @@ namespace Magento\Wishlist\Model\Wishlist; +use Magento\Catalog\Model\Product\Attribute\Source\Status; use Magento\Framework\Exception\LocalizedException; use Magento\Wishlist\Model\Item as WishlistItem; use Magento\Wishlist\Model\ItemFactory as WishlistItemFactory; @@ -90,11 +91,26 @@ public function execute(Wishlist $wishlist, array $wishlistItems): WishlistOutpu private function updateItemInWishlist(Wishlist $wishlist, WishlistItemData $wishlistItemData): void { try { + if ($wishlist->getItem($wishlistItemData->getId()) == null) { + throw new LocalizedException( + __( + 'The wishlist item with ID "%id" does not belong to the wishlist', + ['id' => $wishlistItemData->getId()] + ) + ); + } + $wishlist->getItemCollection()->clear(); $options = $this->buyRequestBuilder->build($wishlistItemData); /** @var WishlistItem $wishlistItem */ $wishlistItem = $this->wishlistItemFactory->create(); $this->wishlistItemResource->load($wishlistItem, $wishlistItemData->getId()); $wishlistItem->setDescription($wishlistItemData->getDescription()); + if ((int)$wishlistItemData->getQuantity() === 0) { + throw new LocalizedException(__("The quantity of a wish list item cannot be 0")); + } + if ($wishlistItem->getProduct()->getStatus() == Status::STATUS_DISABLED) { + throw new LocalizedException(__("The product is disabled")); + } $resultItem = $wishlist->updateItem($wishlistItem, $options); if (is_string($resultItem)) { diff --git a/app/code/Magento/Wishlist/Setup/Patch/Data/WishlistDataCleanUp.php b/app/code/Magento/Wishlist/Setup/Patch/Data/WishlistDataCleanUp.php new file mode 100644 index 0000000000000..df0aed7daf2c0 --- /dev/null +++ b/app/code/Magento/Wishlist/Setup/Patch/Data/WishlistDataCleanUp.php @@ -0,0 +1,153 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); +namespace Magento\Wishlist\Setup\Patch\Data; + +use Magento\Framework\DB\Query\Generator; +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\Serialize\Serializer\Json; +use Magento\Framework\Setup\ModuleDataSetupInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Psr\Log\LoggerInterface; + +/** + * Class Clean Up Data Removes unused data + */ +class WishlistDataCleanUp implements DataPatchInterface +{ + /** + * Batch size for query + */ + private const BATCH_SIZE = 1000; + + /** + * @var ModuleDataSetupInterface + */ + private $moduleDataSetup; + + /** + * @var Generator + */ + private $queryGenerator; + + /** + * @var Json + */ + private $json; + + /** + * @var LoggerInterface + */ + private $logger; + + /** + * RemoveData constructor. + * @param Json $json + * @param Generator $queryGenerator + * @param ModuleDataSetupInterface $moduleDataSetup + * @param LoggerInterface $logger + */ + public function __construct( + Json $json, + Generator $queryGenerator, + ModuleDataSetupInterface $moduleDataSetup, + LoggerInterface $logger + ) { + $this->json = $json; + $this->queryGenerator = $queryGenerator; + $this->moduleDataSetup = $moduleDataSetup; + $this->logger = $logger; + } + + /** + * @inheritdoc + */ + public function apply() + { + try { + $this->cleanWishlistItemOptionTable(); + } catch (\Throwable $e) { + $this->logger->warning( + 'Wishlist module WishlistDataCleanUp patch experienced an error and could not be completed.' + . ' Please submit a support ticket or email us at security@magento.com.' + ); + + return $this; + } + + return $this; + } + + /** + * Remove login data from wishlist_item_option table. + * + * @throws LocalizedException + */ + private function cleanWishlistItemOptionTable() + { + $tableName = $this->moduleDataSetup->getTable('wishlist_item_option'); + $select = $this->moduleDataSetup + ->getConnection() + ->select() + ->from( + $tableName, + ['option_id', 'value'] + ) + ->where( + 'value LIKE ?', + '%login%' + ); + $iterator = $this->queryGenerator->generate('option_id', $select, self::BATCH_SIZE); + $rowErrorFlag = false; + foreach ($iterator as $selectByRange) { + $optionRows = $this->moduleDataSetup->getConnection()->fetchAll($selectByRange); + foreach ($optionRows as $optionRow) { + try { + $rowValue = $this->json->unserialize($optionRow['value']); + if (is_array($rowValue) + && array_key_exists('login', $rowValue) + ) { + unset($rowValue['login']); + } + $rowValue = $this->json->serialize($rowValue); + $this->moduleDataSetup->getConnection()->update( + $tableName, + ['value' => $rowValue], + ['option_id = ?' => $optionRow['option_id']] + ); + } catch (\Throwable $e) { + $rowErrorFlag = true; + continue; + } + } + } + if ($rowErrorFlag) { + $this->logger->warning( + 'Data clean up could not be completed due to unexpected data format in the table "' + . $tableName + . '". Please submit a support ticket or email us at security@magento.com.' + ); + } + } + + /** + * @inheritdoc + */ + public static function getDependencies() + { + return [ + ConvertSerializedData::class + ]; + } + + /** + * @inheritdoc + */ + public function getAliases() + { + return []; + } +} diff --git a/app/code/Magento/Wishlist/Test/Mftf/ActionGroup/StorefrontCustomerAddProductToWishlistCategoryPageActionGroup.xml b/app/code/Magento/Wishlist/Test/Mftf/ActionGroup/StorefrontCustomerAddProductToWishlistCategoryPageActionGroup.xml new file mode 100644 index 0000000000000..baa4bfcab4ebc --- /dev/null +++ b/app/code/Magento/Wishlist/Test/Mftf/ActionGroup/StorefrontCustomerAddProductToWishlistCategoryPageActionGroup.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="StorefrontCustomerAddProductToWishlistCategoryPageActionGroup"> + <annotations> + <description>Adds the provided Product to the Wish List from the Storefront Category page. Validates that the Success Message is present and correct.</description> + </annotations> + <arguments> + <argument name="productVar"/> + </arguments> + + <click selector="{{StorefrontCategoryPageProductInfoSection.productAddToWishlist(productVar.id)}}" stepKey="addProductToWishlistClickAddToWishlist"/> + <waitForElement selector="{{StorefrontCustomerWishlistSection.successMsg}}" time="30" stepKey="addProductToWishlistWaitForSuccessMessage"/> + <see selector="{{StorefrontCustomerWishlistSection.successMsg}}" userInput="{{productVar.name}} has been added to your Wish List. Click here to continue shopping." stepKey="addProductToWishlistSeeProductNameAddedToWishlist"/> + <seeCurrentUrlMatches regex="~/wishlist_id/\d+/$~" stepKey="seeCurrentUrlMatches"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Wishlist/Test/Mftf/Test/StorefrontCheckOptionsConfigurableProductInWishlistTest.xml b/app/code/Magento/Wishlist/Test/Mftf/Test/StorefrontCheckOptionsConfigurableProductInWishlistTest.xml new file mode 100644 index 0000000000000..638c8f4986a77 --- /dev/null +++ b/app/code/Magento/Wishlist/Test/Mftf/Test/StorefrontCheckOptionsConfigurableProductInWishlistTest.xml @@ -0,0 +1,91 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="StorefrontCheckOptionsConfigurableProductInWishlistTest"> + <annotations> + <stories value="Wishlist"/> + <title value="Move first Configurable Product with selected optional from Category Page to Wishlist."/> + <description value="Move first Configurable Product with selected optional from Category Page to Wishlist. On Page will be present minimum two Configurable Product"/> + <severity value="CRITICAL"/> + <testCaseId value="MC-14211"/> + <group value="wishlist"/> + </annotations> + <before> + <createData entity="ApiCategory" stepKey="createCategory"/> + <createData entity="ApiConfigurableProduct" stepKey="createFirstConfigProduct"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="ApiConfigurableProduct" stepKey="createSecondConfigProduct"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="Simple_US_Customer" stepKey="customer"/> + <actionGroup ref="CliIndexerReindexActionGroup" stepKey="reindex"> + <argument name="indices" value=""/> + </actionGroup> + <actionGroup ref="CliCacheFlushActionGroup" stepKey="flushCache"> + <argument name="tags" value=""/> + </actionGroup> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + </before> + <after> + <deleteData createDataKey="customer" stepKey="deleteCustomer"/> + <actionGroup ref="DeleteProductBySkuActionGroup" stepKey="deleteFirstProducts"> + <argument name="sku" value="$$createFirstConfigProduct.sku$$"/> + </actionGroup> + <actionGroup ref="DeleteProductBySkuActionGroup" stepKey="deleteSecondProducts"> + <argument name="sku" value="$$createSecondConfigProduct.sku$$"/> + </actionGroup> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + <actionGroup ref="AdminDeleteProductAttributeByLabelActionGroup" stepKey="deleteAttribute" > + <argument name="productAttributeLabel" value="{{visualSwatchAttribute.default_label}}"/> + </actionGroup> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> + <actionGroup ref="CliIndexerReindexActionGroup" stepKey="reindex"> + <argument name="indices" value=""/> + </actionGroup> + <actionGroup ref="CliCacheFlushActionGroup" stepKey="flushCache"> + <argument name="tags" value=""/> + </actionGroup> + </after> + <actionGroup ref="AdminProductPageOpenByIdActionGroup" stepKey="navigateToFirstConfigProductPage"> + <argument name="productId" value="$$createFirstConfigProduct.id$$"/> + </actionGroup> + <waitForPageLoad stepKey="waitForFirstProductPageLoad"/> + <actionGroup ref="AddVisualSwatchToProductWithStorefrontConfigActionGroup" stepKey="addSwatchToFirstProduct"> + <argument name="attribute" value="visualSwatchAttribute"/> + <argument name="option1" value="visualSwatchOption1"/> + <argument name="option2" value="visualSwatchOption2"/> + </actionGroup> + + <actionGroup ref="AdminProductPageOpenByIdActionGroup" stepKey="navigateToSecondConfigProductPage"> + <argument name="productId" value="$$createSecondConfigProduct.id$$"/> + </actionGroup> + <waitForPageLoad stepKey="waitForSecondProductPageLoad"/> + <actionGroup ref="AddVisualSwatchToProductWithOutCreatedActionGroup" stepKey="addSwatchToSecondProduct"> + <argument name="attribute" value="visualSwatchAttribute"/> + </actionGroup> + + <actionGroup ref="LoginToStorefrontActionGroup" stepKey="loginToStorefrontAccount"> + <argument name="Customer" value="$$customer$$"/> + </actionGroup> + <actionGroup ref="StorefrontGoToCategoryPageActionGroup" stepKey="openCategoryPage"> + <argument name="categoryName" value="$$createCategory.name$$"/> + </actionGroup> + <actionGroup ref="StorefrontSelectVisualSwatchOptionOnCategoryPageActionGroup" stepKey="selectVisualSwatch"> + <argument name="productId" value="$$createFirstConfigProduct.id$$" /> + <argument name="visualSwatchOptionLabel" value="{{visualSwatchOption1.default_label}}" /> + </actionGroup> + <actionGroup ref="StorefrontCustomerAddProductToWishlistCategoryPageActionGroup" stepKey="addToWishlistProduct"> + <argument name="productVar" value="$$createFirstConfigProduct$$"/> + </actionGroup> + + <seeElement selector="{{StorefrontCustomerWishlistProductSection.productSeeDetailsByName($$createFirstConfigProduct.name$$)}}" stepKey="seeDetails"/> + </test> +</tests> diff --git a/app/code/Magento/Wishlist/Test/Unit/CustomerData/WishlistTest.php b/app/code/Magento/Wishlist/Test/Unit/CustomerData/WishlistTest.php index 79ab3c9ba2082..0a1e40253b71c 100644 --- a/app/code/Magento/Wishlist/Test/Unit/CustomerData/WishlistTest.php +++ b/app/code/Magento/Wishlist/Test/Unit/CustomerData/WishlistTest.php @@ -199,9 +199,6 @@ public function testGetSectionData() $this->catalogImageHelperMock->expects($this->any()) ->method('getFrame') ->willReturn(true); - $this->catalogImageHelperMock->expects($this->once()) - ->method('getResizedImageInfo') - ->willReturn([]); $this->wishlistHelperMock->expects($this->once()) ->method('getProductUrl') @@ -400,9 +397,6 @@ public function testGetSectionDataWithTwoItems() $this->catalogImageHelperMock->expects($this->any()) ->method('getFrame') ->willReturn(true); - $this->catalogImageHelperMock->expects($this->exactly(2)) - ->method('getResizedImageInfo') - ->willReturn([]); $this->wishlistHelperMock->expects($this->exactly(2)) ->method('getProductUrl') diff --git a/app/code/Magento/Wishlist/view/frontend/layout/catalog_category_view.xml b/app/code/Magento/Wishlist/view/frontend/layout/catalog_category_view.xml index a4860ace166d8..81bd966b904d7 100644 --- a/app/code/Magento/Wishlist/view/frontend/layout/catalog_category_view.xml +++ b/app/code/Magento/Wishlist/view/frontend/layout/catalog_category_view.xml @@ -21,7 +21,9 @@ template="Magento_Wishlist::catalog/product/list/addto/wishlist.phtml"/> </referenceBlock> <referenceContainer name="category.product.list.additional"> - <block class="Magento\Wishlist\Block\AddToWishlist" name="category.product.list.additional.wishlist_addto" template="Magento_Wishlist::addto.phtml" /> + <block class="Magento\Wishlist\Block\AddToWishlist" + name="category.product.list.additional.wishlist_addto" + template="Magento_Wishlist::addto.phtml"/> </referenceContainer> </referenceContainer> </body> diff --git a/app/code/Magento/Wishlist/view/frontend/layout/catalogsearch_result_index.xml b/app/code/Magento/Wishlist/view/frontend/layout/catalogsearch_result_index.xml index c293175ccceac..b26aa64ad89b1 100644 --- a/app/code/Magento/Wishlist/view/frontend/layout/catalogsearch_result_index.xml +++ b/app/code/Magento/Wishlist/view/frontend/layout/catalogsearch_result_index.xml @@ -14,5 +14,10 @@ template="Magento_Wishlist::catalog/product/list/addto/wishlist.phtml"/> </referenceBlock> </referenceContainer> + <referenceBlock name="wishlist_page_head_components"> + <block class="Magento\Wishlist\Block\AddToWishlist" + name="catalogsearch.wishlist_addto" + template="Magento_Wishlist::addto.phtml"/> + </referenceBlock> </body> </page> diff --git a/app/code/Magento/Wishlist/view/frontend/web/js/add-to-wishlist.js b/app/code/Magento/Wishlist/view/frontend/web/js/add-to-wishlist.js index 55cd77b196be5..62756f7211cee 100644 --- a/app/code/Magento/Wishlist/view/frontend/web/js/add-to-wishlist.js +++ b/app/code/Magento/Wishlist/view/frontend/web/js/add-to-wishlist.js @@ -17,7 +17,9 @@ define([ downloadableInfo: '#downloadable-links-list input', customOptionsInfo: '.product-custom-option', qtyInfo: '#qty', - actionElement: '[data-action="add-to-wishlist"]' + actionElement: '[data-action="add-to-wishlist"]', + productListWrapper: '.product-item-info', + productPageWrapper: '.product-info-main' }, /** @inheritdoc */ @@ -65,15 +67,19 @@ define([ _updateWishlistData: function (event) { var dataToAdd = {}, isFileUploaded = false, + handleObjSelector = null, self = this; if (event.handleObj.selector == this.options.qtyInfo) { //eslint-disable-line eqeqeq - this._updateAddToWishlistButton({}); + this._updateAddToWishlistButton({}, event); event.stopPropagation(); return; } - $(event.handleObj.selector).each(function (index, element) { + + handleObjSelector = $(event.currentTarget).closest('form').find(event.handleObj.selector); + + handleObjSelector.each(function (index, element) { if ($(element).is('input[type=text]') || $(element).is('input[type=email]') || $(element).is('input[type=number]') || @@ -98,18 +104,20 @@ define([ if (isFileUploaded) { this.bindFormSubmit(); } - this._updateAddToWishlistButton(dataToAdd); + this._updateAddToWishlistButton(dataToAdd, event); event.stopPropagation(); }, /** * @param {Object} dataToAdd + * @param {jQuery.Event} event * @private */ - _updateAddToWishlistButton: function (dataToAdd) { - var self = this; + _updateAddToWishlistButton: function (dataToAdd, event) { + var self = this, + buttons = this._getAddToWishlistButton(event); - $('[data-action="add-to-wishlist"]').each(function (index, element) { + buttons.each(function (index, element) { var params = $(element).data('post'); if (!params) { @@ -125,6 +133,20 @@ define([ }); }, + /** + * @param {jQuery.Event} event + * @private + */ + _getAddToWishlistButton: function (event) { + var productListWrapper = $(event.currentTarget).closest(this.options.productListWrapper); + + if (productListWrapper.length) { + return productListWrapper.find(this.options.actionElement); + } + + return $(event.currentTarget).closest(this.options.productPageWrapper).find(this.options.actionElement); + }, + /** * @param {Object} array1 * @param {Object} array2 diff --git a/app/code/Magento/WishlistGraphQl/Model/Resolver/UpdateProductsInWishlist.php b/app/code/Magento/WishlistGraphQl/Model/Resolver/UpdateProductsInWishlist.php index 47a408d55555b..465ab33744984 100644 --- a/app/code/Magento/WishlistGraphQl/Model/Resolver/UpdateProductsInWishlist.php +++ b/app/code/Magento/WishlistGraphQl/Model/Resolver/UpdateProductsInWishlist.php @@ -101,7 +101,7 @@ public function resolve( } $wishlistItems = $args['wishlistItems']; - $wishlistItems = $this->getWishlistItems($wishlistItems); + $wishlistItems = $this->getWishlistItems($wishlistItems, $wishlist); $wishlistOutput = $this->updateProductsInWishlist->execute($wishlist, $wishlistItems); if (count($wishlistOutput->getErrors()) !== count($wishlistItems)) { @@ -126,14 +126,27 @@ function (Error $error) { * Get DTO wishlist items * * @param array $wishlistItemsData + * @param Wishlist $wishlist * * @return array */ - private function getWishlistItems(array $wishlistItemsData): array + private function getWishlistItems(array $wishlistItemsData, Wishlist $wishlist): array { $wishlistItems = []; foreach ($wishlistItemsData as $wishlistItemData) { + if (!isset($wishlistItemData['quantity'])) { + $wishlistItem = $wishlist->getItem($wishlistItemData['wishlist_item_id']); + if ($wishlistItem !== null) { + $wishlistItemData['quantity'] = (float) $wishlistItem->getQty(); + } + } + if (!isset($wishlistItemData['description'])) { + $wishlistItem = $wishlist->getItem($wishlistItemData['wishlist_item_id']); + if ($wishlistItem !== null) { + $wishlistItemData['description'] = $wishlistItem->getDescription(); + } + } $wishlistItems[] = (new WishlistItemFactory())->create($wishlistItemData); } diff --git a/app/code/Magento/WishlistGraphQl/etc/graphql/di.xml b/app/code/Magento/WishlistGraphQl/etc/graphql/di.xml new file mode 100644 index 0000000000000..4d4ce9458fb6c --- /dev/null +++ b/app/code/Magento/WishlistGraphQl/etc/graphql/di.xml @@ -0,0 +1,17 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd"> + <type name="Magento\StoreGraphQl\Model\Resolver\Store\StoreConfigDataProvider"> + <arguments> + <argument name="extendedConfigData" xsi:type="array"> + <item name="magento_wishlist_general_is_enabled" xsi:type="string">wishlist/general/active</item> + </argument> + </arguments> + </type> +</config> diff --git a/app/code/Magento/WishlistGraphQl/etc/schema.graphqls b/app/code/Magento/WishlistGraphQl/etc/schema.graphqls index 69bc45462d4c8..7812176db60d0 100644 --- a/app/code/Magento/WishlistGraphQl/etc/schema.graphqls +++ b/app/code/Magento/WishlistGraphQl/etc/schema.graphqls @@ -55,8 +55,8 @@ type Mutation { } input WishlistItemInput @doc(description: "Defines the items to add to a wish list") { - sku: String @doc(description: "The SKU of the product to add. For complex product types, specify the child product SKU") - quantity: Float @doc(description: "The amount or number of items to add") + sku: String! @doc(description: "The SKU of the product to add. For complex product types, specify the child product SKU") + quantity: Float! @doc(description: "The amount or number of items to add") parent_sku: String @doc(description: "For complex product types, the SKU of the parent product") selected_options: [ID!] @doc(description: "An array of strings corresponding to options the customer selected") entered_options: [EnteredOptionInput!] @doc(description: "An array of options that the customer entered") @@ -73,9 +73,9 @@ type RemoveProductsFromWishlistOutput @doc(description: "Contains the customer's } input WishlistItemUpdateInput @doc(description: "Defines updates to items in a wish list") { - wishlist_item_id: ID @doc(description: "The ID of the wishlist item to update") + wishlist_item_id: ID! @doc(description: "The ID of the wishlist item to update") quantity: Float @doc(description: "The new amount or number of this item") - description: String @doc(description: "Describes the update") + description: String @doc(description: "Customer-entered comments about the item") selected_options: [ID!] @doc(description: "An array of strings corresponding to options the customer selected") entered_options: [EnteredOptionInput!] @doc(description: "An array of options that the customer entered") } @@ -94,3 +94,7 @@ enum WishListUserInputErrorType { PRODUCT_NOT_FOUND UNDEFINED } + +type StoreConfig { + magento_wishlist_general_is_enabled: String @doc(description: "Indicates whether wishlists are enabled (1) or disabled (0)") +} diff --git a/app/design/adminhtml/Magento/backend/Magento_AdvancedCheckout/web/css/source/_module.less b/app/design/adminhtml/Magento/backend/Magento_AdvancedCheckout/web/css/source/_module.less index fbc429d3afa50..1a54ba92dc66a 100644 --- a/app/design/adminhtml/Magento/backend/Magento_AdvancedCheckout/web/css/source/_module.less +++ b/app/design/adminhtml/Magento/backend/Magento_AdvancedCheckout/web/css/source/_module.less @@ -28,10 +28,8 @@ } .order-discounts { - .action-secondary { - + .action-secondary { - margin-right: @indent__s; - } + .action-secondary:not(:first-of-type) { + margin-right: @indent__s; } } diff --git a/app/design/adminhtml/Magento/backend/Magento_Sales/web/css/source/module/order/_payment-shipping.less b/app/design/adminhtml/Magento/backend/Magento_Sales/web/css/source/module/order/_payment-shipping.less index 2c55d243ebe07..121de6e7b6d8a 100644 --- a/app/design/adminhtml/Magento/backend/Magento_Sales/web/css/source/module/order/_payment-shipping.less +++ b/app/design/adminhtml/Magento/backend/Magento_Sales/web/css/source/module/order/_payment-shipping.less @@ -9,13 +9,15 @@ .admin__payment-method-wrapper { margin: 0; - width: calc(50% - @indent__l); + width: ~'calc(50% - @{indent__l})'; + .admin__field { margin-left: 0; &:first-child { margin-top: 1.5rem; } } + .admin__payment-methods { margin: 0; } @@ -62,6 +64,7 @@ position: absolute; right: 0; top: 0; + span { background-color: @color-white; display: block; @@ -71,6 +74,7 @@ position: absolute; top: 43px; } + .order-shipping-address & { span { top: 0; @@ -102,6 +106,7 @@ + .order-payment-currency { margin-top: @indent__s; } + .admin__table-secondary { margin-top: @indent__s; &:extend(.abs-admin__table-secondary-edit-order all); diff --git a/app/design/adminhtml/Magento/backend/web/css/source/forms/_fields.less b/app/design/adminhtml/Magento/backend/web/css/source/forms/_fields.less index 256ac453578df..b86b0005e88fb 100644 --- a/app/design/adminhtml/Magento/backend/web/css/source/forms/_fields.less +++ b/app/design/adminhtml/Magento/backend/web/css/source/forms/_fields.less @@ -163,6 +163,7 @@ .admin__field-control { padding-top: 7px; } + .admin__field-option { padding-top: 0; } @@ -361,6 +362,7 @@ cursor: inherit; opacity: 1; outline: inherit; + .admin__action-multiselect-wrap { .admin__action-multiselect { .__form-control-pattern__disabled(); @@ -433,7 +435,7 @@ font-size: 1.7rem; font-weight: @font-weight__bold; padding: 1.7rem 0; - width: calc(100% - @indent__l); + width: ~'calc(100% - @{indent__l})'; } .admin__field-option { @@ -704,6 +706,7 @@ width: 100%; } } + & > .admin__field-label { text-align: left; } @@ -819,4 +822,3 @@ overflow: hidden; } } - diff --git a/app/design/adminhtml/Magento/backend/web/js/theme.js b/app/design/adminhtml/Magento/backend/web/js/theme.js index 05d73ac20fcbd..069970deae681 100644 --- a/app/design/adminhtml/Magento/backend/web/js/theme.js +++ b/app/design/adminhtml/Magento/backend/web/js/theme.js @@ -312,8 +312,9 @@ define('globalNavigation', [ define('globalSearch', [ 'jquery', - 'jquery/ui' -], function ($) { + 'Magento_Ui/js/lib/key-codes', + 'jquery-ui-modules/widget' +], function ($, keyCodes) { 'use strict'; $.widget('mage.globalSearch', { @@ -345,6 +346,25 @@ define('globalSearch', [ this.input.on('focus.activateGlobalSearchForm', function () { self.field.addClass(self.options.fieldActiveClass); }); + + $(document).on('keydown.activateGlobalSearchForm', function (event) { + var inputs = [ + 'input', + 'select', + 'textarea' + ]; + + if (keyCodes[event.which] !== 'forwardSlashKey' || + inputs.indexOf(event.target.tagName.toLowerCase()) !== -1 || + event.target.isContentEditable + ) { + return; + } + + event.preventDefault(); + + self.input.focus(); + }); } }); diff --git a/app/design/frontend/Magento/blank/Magento_B2b/web/css/source/actions/_actions-select.less b/app/design/frontend/Magento/blank/Magento_B2b/web/css/source/actions/_actions-select.less new file mode 100644 index 0000000000000..efbf25e3a0df2 --- /dev/null +++ b/app/design/frontend/Magento/blank/Magento_B2b/web/css/source/actions/_actions-select.less @@ -0,0 +1,158 @@ +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +// +// Actions -> Action select +// _____________________________________________ + +// +// Variables +// _____________________________________________ + +@color-blue-pure: #007bdb; + +@action__height: 3.2rem; +@action__width: 3.2rem; + +// Triangle marker +@button-marker-triangle__height: .5rem; +@button-marker-triangle__width: .8rem; + +@button__border-color: @color-gray68; + +@action__border-color: @color-gray68; +@action__active__border-color: @action__border-color; +@action__hover__border-color: @action__border-color; + +@action-select__disabled__color: @text__color; +@action-select__z-index: @z-index-5; + +@field-control__box-shadow: 0 0 3px 1px @color-blue3; +@field-control__active__border-color: @color-blue-pure; +@field-control__hover__border-color: @action__hover__border-color; + +@_dropdown__padding-right: @action__height; +@_triangle__height: @button-marker-triangle__height; +@_triangle__width: @button-marker-triangle__width; + +// Action select have the same visual styles and functionality as native <select> +.admin__action-group-wrap { + @_action-select__border-color: @button__border-color; + @_action-select__active__border-color: @action__active__border-color; + @_action-select-toggle__size: @action__height; + + display: inline-block; + position: relative; + + .action-select { + .lib-text-overflow(); + + background-color: @color-white; + font-weight: @font-weight__regular; + text-align: left; + + &:hover { + border-color: @color-gray68; + + &:before { + border-color: @field-control__hover__border-color; + } + } + + // Toggle action + &:extend(.abs-icon-add all); + + &:before { + align-items: center; + content: @icon-down; + display: flex; + font-size: 24px; + justify-content: space-around; + line-height: 1; + position: absolute; + right: 0; + width: @action__width; + } + + &._active { + &:before { + content: @icon-up; + } + } + + &[disabled] { + color: @action-select__disabled__color; + } + + &._mage-error { + border: 1px solid @form-element-validation__border-error; + } + } + + &._focus { + .action-select { + &._mage-error { + border: 1px solid @form-element-validation__border-error; + } + } + } + + &._active { + z-index: @action-select__z-index; + + .action-select { + &:before { + content: @icon-up; + } + } + + .action-menu { + .lib-css(box-shadow, @field-control__box-shadow); + } + } + + .action-menu { + border: 1px solid @action__border-color; + display: none; + max-height: 45rem; + overflow-y: auto; + + &._active { + display: block; + } + + ._disabled { + &:hover { + background: @color-white; + } + + .action-menu-item { + cursor: default; + opacity: .5; + } + } + } + + .action-menu-items { + left: 0; + position: absolute; + right: 0; + top: 100%; + + > .action-menu { + min-width: 100%; + position: static; + + .action-submenu { + position: absolute; + right: -100%; + } + } + } + + .validate-select-field { + .lib-visually-hidden(); + } +} diff --git a/app/design/frontend/Magento/blank/Magento_Catalog/web/css/source/_module.less b/app/design/frontend/Magento/blank/Magento_Catalog/web/css/source/_module.less index 4b48bbe99ced2..f7be4a9edb13c 100644 --- a/app/design/frontend/Magento/blank/Magento_Catalog/web/css/source/_module.less +++ b/app/design/frontend/Magento/blank/Magento_Catalog/web/css/source/_module.less @@ -117,13 +117,12 @@ .product-image-photo { bottom: 0; display: block; - height: auto; left: 0; margin: auto; - max-width: 100%; position: absolute; right: 0; top: 0; + width: auto; } // diff --git a/app/design/frontend/Magento/luma/Magento_Customer/layout/customer_account.xml b/app/design/frontend/Magento/blank/Magento_Customer/layout/customer_account.xml similarity index 100% rename from app/design/frontend/Magento/luma/Magento_Customer/layout/customer_account.xml rename to app/design/frontend/Magento/blank/Magento_Customer/layout/customer_account.xml diff --git a/app/design/frontend/Magento/blank/Magento_Review/web/css/source/_module.less b/app/design/frontend/Magento/blank/Magento_Review/web/css/source/_module.less index 69ec01d71e104..bf77ab46712b2 100644 --- a/app/design/frontend/Magento/blank/Magento_Review/web/css/source/_module.less +++ b/app/design/frontend/Magento/blank/Magento_Review/web/css/source/_module.less @@ -15,9 +15,21 @@ // _____________________________________________ & when (@media-common = true) { + .data.switch .counter { + .lib-css(color, @text__color__muted); + + &:before { + content: '('; + } + + &:after { + content: ')'; + } + } + .rating-summary { .lib-rating-summary(); - + .rating-result { margin-left: -5px; } @@ -359,3 +371,4 @@ } } } + diff --git a/app/design/frontend/Magento/blank/Magento_Swatches/web/css/source/_module.less b/app/design/frontend/Magento/blank/Magento_Swatches/web/css/source/_module.less index 07317e1670a0b..ce1b009c24d42 100644 --- a/app/design/frontend/Magento/blank/Magento_Swatches/web/css/source/_module.less +++ b/app/design/frontend/Magento/blank/Magento_Swatches/web/css/source/_module.less @@ -65,7 +65,6 @@ // _____________________________________________ & when (@media-common = true) { - .swatch { &-attribute { &-label { @@ -155,7 +154,7 @@ padding: 4px 8px; &.selected { - .lib-css(background-color, @swatch-option-text__selected__background-color) !important; + .lib-css(background-color, @swatch-option-text__selected__background-color); } } @@ -201,6 +200,7 @@ top: 0; } } + &-disabled { border: 0; cursor: default; @@ -208,6 +208,7 @@ &:after { .lib-rotate(-30deg); + .lib-css(background, @swatch-option__disabled__background); content: ''; height: 2px; left: -4px; @@ -215,7 +216,6 @@ top: 10px; width: 42px; z-index: 995; - .lib-css(background, @swatch-option__disabled__background); } } @@ -226,6 +226,7 @@ &-tooltip { .lib-css(border, @swatch-option-tooltip__border); .lib-css(color, @swatch-option-tooltip__color); + .lib-css(background, @swatch-option-tooltip__background); display: none; max-height: 100%; min-height: 20px; @@ -234,7 +235,6 @@ position: absolute; text-align: center; z-index: 999; - .lib-css(background, @swatch-option-tooltip__background); &, &-layered { @@ -278,9 +278,9 @@ } &-layered { + .lib-css(background, @swatch-option-tooltip-layered__background); .lib-css(border, @swatch-option-tooltip-layered__border); .lib-css(color, @swatch-option-tooltip-layered__color); - .lib-css(background, @swatch-option-tooltip-layered__background); display: none; left: -47px; position: absolute; @@ -326,7 +326,6 @@ margin: 2px 0; padding: 2px; position: static; - z-index: 1; } &-visual-tooltip-layered { diff --git a/app/design/frontend/Magento/blank/Magento_Theme/web/css/source/_module.less b/app/design/frontend/Magento/blank/Magento_Theme/web/css/source/_module.less index 8518b5bf76735..8f99550271967 100644 --- a/app/design/frontend/Magento/blank/Magento_Theme/web/css/source/_module.less +++ b/app/design/frontend/Magento/blank/Magento_Theme/web/css/source/_module.less @@ -3,6 +3,8 @@ // * See COPYING.txt for license details. // */ +@import 'module/_collapsible_navigation.less'; + // // Theme variables // _____________________________________________ diff --git a/app/design/frontend/Magento/luma/Magento_Theme/web/css/source/module/_collapsible_navigation.less b/app/design/frontend/Magento/blank/Magento_Theme/web/css/source/module/_collapsible_navigation.less similarity index 100% rename from app/design/frontend/Magento/luma/Magento_Theme/web/css/source/module/_collapsible_navigation.less rename to app/design/frontend/Magento/blank/Magento_Theme/web/css/source/module/_collapsible_navigation.less diff --git a/app/design/frontend/Magento/blank/Magento_Theme/web/js/theme.js b/app/design/frontend/Magento/blank/Magento_Theme/web/js/theme.js index e4edd3bd8662c..87aceb0b00036 100644 --- a/app/design/frontend/Magento/blank/Magento_Theme/web/js/theme.js +++ b/app/design/frontend/Magento/blank/Magento_Theme/web/js/theme.js @@ -16,5 +16,7 @@ define([ container: '#maincontent' }); + $('.panel.header > .header.links').clone().appendTo('#store\\.links'); + keyboardHandler.apply(); }); diff --git a/app/design/frontend/Magento/luma/Magento_Catalog/web/css/source/_module.less b/app/design/frontend/Magento/luma/Magento_Catalog/web/css/source/_module.less index e205b20efd17c..43694a8197963 100644 --- a/app/design/frontend/Magento/luma/Magento_Catalog/web/css/source/_module.less +++ b/app/design/frontend/Magento/luma/Magento_Catalog/web/css/source/_module.less @@ -98,13 +98,12 @@ .product-image-photo { bottom: 0; display: block; - height: auto; left: 0; margin: auto; - max-width: 100%; position: absolute; right: 0; top: 0; + width: auto; } // diff --git a/app/design/frontend/Magento/luma/Magento_Checkout/web/css/source/module/_cart.less b/app/design/frontend/Magento/luma/Magento_Checkout/web/css/source/module/_cart.less index 5d9746317af55..2c8c52bdb7af2 100644 --- a/app/design/frontend/Magento/luma/Magento_Checkout/web/css/source/module/_cart.less +++ b/app/design/frontend/Magento/luma/Magento_Checkout/web/css/source/module/_cart.less @@ -267,7 +267,7 @@ .lib-icon-font-symbol( @_icon-font-content: @icon-trash ); - + &:hover { .lib-css(text-decoration, @link__text-decoration); } @@ -574,7 +574,7 @@ .widget { float: left; - + &.block { margin-bottom: @indent__base; } @@ -727,9 +727,14 @@ position: static; } } + &.discount { width: auto; } + + .actions-toolbar { + width: auto; + } } } diff --git a/app/design/frontend/Magento/luma/Magento_Checkout/web/css/source/module/checkout/_payments.less b/app/design/frontend/Magento/luma/Magento_Checkout/web/css/source/module/checkout/_payments.less index 494483ff60dda..8fbe67abe2960 100644 --- a/app/design/frontend/Magento/luma/Magento_Checkout/web/css/source/module/checkout/_payments.less +++ b/app/design/frontend/Magento/luma/Magento_Checkout/web/css/source/module/checkout/_payments.less @@ -49,6 +49,10 @@ form { &.form-purchase-order { margin-bottom: 15px; + + .input-text { + width: 40%; + } } } } @@ -119,7 +123,7 @@ margin: 0 0 @indent__base; .primary { - .action-update { + .action-update { margin-bottom: 20px; margin-right: 0; } @@ -133,7 +137,7 @@ .lib-css(line-height, @checkout-billing-address-details__line-height); .lib-css(padding, @checkout-billing-address-details__padding); } - + input[type="checkbox"] { vertical-align: top; } diff --git a/app/design/frontend/Magento/luma/Magento_Customer/web/css/source/_module.less b/app/design/frontend/Magento/luma/Magento_Customer/web/css/source/_module.less index a0a36f55574fe..28ab32d13c88b 100644 --- a/app/design/frontend/Magento/luma/Magento_Customer/web/css/source/_module.less +++ b/app/design/frontend/Magento/luma/Magento_Customer/web/css/source/_module.less @@ -83,6 +83,11 @@ .fieldset.password { display: none; } + fieldset { + &.additional_info { + clear: both; + } + } } .form-create-account { @@ -349,9 +354,9 @@ } } - .additional-addresses { + .additional-addresses { table > thead > tr > th { - white-space: nowrap; + white-space: nowrap; } } } diff --git a/app/design/frontend/Magento/luma/Magento_Sales/web/css/source/_module.less b/app/design/frontend/Magento/luma/Magento_Sales/web/css/source/_module.less index bab8a2abb9b93..f8ab8ddb088ec 100644 --- a/app/design/frontend/Magento/luma/Magento_Sales/web/css/source/_module.less +++ b/app/design/frontend/Magento/luma/Magento_Sales/web/css/source/_module.less @@ -210,6 +210,7 @@ .items-qty { &:extend(.abs-reset-list all); + .item { white-space: nowrap; } @@ -347,13 +348,15 @@ .product-item-name { float: left; - width: calc(100% - 20px); + width: calc(~'100% - 20px'); } + .product-item::after { clear: both; content: ''; display: table; } + .product-item { .label { &:extend(.abs-visually-hidden all); @@ -491,6 +494,7 @@ .data.table .col.options { padding: 0 10px 15px; + &:before { display: none; } diff --git a/app/design/frontend/Magento/luma/web/css/critical.css b/app/design/frontend/Magento/luma/web/css/critical.css index 922a1eefa89e8..c2b4b16f9aadd 100644 --- a/app/design/frontend/Magento/luma/web/css/critical.css +++ b/app/design/frontend/Magento/luma/web/css/critical.css @@ -1 +1 @@ -body{margin:0}.page-main{flex-grow:1}.product-image-wrapper{display:block;height:0;overflow:hidden;position:relative;z-index:1}.product-image-wrapper .product-image-photo{bottom:0;display:block;height:auto;left:0;margin:auto;max-width:100%;position:absolute;right:0;top:0}.product-image-container{display:inline-block}.modal-popup{position:fixed}.page-wrapper{display:flex;flex-direction:column;min-height:100vh}.action.skip:not(:focus),.block.newsletter .label,.minicart-wrapper .action.showcart .counter-label,.minicart-wrapper .action.showcart .text,.page-header .switcher .label,.product-item-actions .actions-secondary>.action span{border:0;clip:rect(0,0,0,0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.alink,a{color:#006bb4;text-decoration:none}.page-header .panel.wrapper{background-color:#6e716e;color:#fff}.header.panel>.header.links{list-style:none none;float:right;font-size:0;margin-right:20px}.header.panel>.header.links>li{font-size:14px;margin:0 0 0 15px}.block-search .action.search,.block-search .block-title,.block-search .nested,.block.newsletter .title,.breadcrumbs .item,.nav-toggle,.no-display,.page-footer .switcher .options ul.dropdown,.page-header .switcher .options ul.dropdown{display:none}.block-search .label>span{height:1px;overflow:hidden;position:absolute}.logo{float:left;margin:0 0 10px 40px}.minicart-wrapper{float:right}.page-footer{margin-top:25px}.footer.content{border-top:1px solid #cecece;padding-top:20px}.block.newsletter .actions{display:table-cell;vertical-align:top;width:1%}.block-banners .banner-items,.block-banners-inline .banner-items,.block-event .slider-panel .slider,.footer.content ul,.product-items{margin:0;padding:0;list-style:none none}.copyright{background-color:#6e716e;color:#fff;box-sizing:border-box;display:block;padding:10px;text-align:center}.modal-popup,.modal-slide{visibility:hidden;opacity:0}input[type=email],input[type=number],input[type=password],input[type=search],input[type=text],input[type=url]{background:#fff;background-clip:padding-box;border:1px solid #c2c2c2;border-radius:1px;font-size:14px;height:32px;line-height:1.42857143;padding:0 9px;vertical-align:baseline;width:100%;box-sizing:border-box}.action.primary{background:#1979c3;border:1px solid #1979c3;color:#fff;font-weight:600;padding:7px 15px}.block.newsletter .form.subscribe{display:table}.footer.content .links a{color:#575757}.load.indicator{background-color:rgba(255,255,255,.7);z-index:9999;bottom:0;left:0;position:fixed;right:0;top:0;position:absolute}.load.indicator:before{background:transparent url(../images/loader-2.gif) no-repeat 50% 50%;border-radius:5px;height:160px;width:160px;bottom:0;box-sizing:border-box;content:'';left:0;margin:auto;position:absolute;right:0;top:0}.load.indicator>span{display:none}.loading-mask{bottom:0;left:0;margin:auto;position:fixed;right:0;top:0;z-index:100;background:rgba(255,255,255,.5)}.loading-mask .loader>img{bottom:0;left:0;margin:auto;position:fixed;right:0;top:0;z-index:100}.loading-mask .loader>p{display:none}body>.loading-mask{z-index:9999}._block-content-loading{position:relative}@media (min-width:768px),print{body,html{height:100%}.page-header{border:0;margin-bottom:0}.nav-sections-item-title,.section-item-content .switcher-currency,ul.header.links li.customer-welcome,ul.level0.submenu{display:none}.abs-add-clearfix-desktop:after,.abs-add-clearfix-desktop:before,.account .column.main .block.block-order-details-view:after,.account .column.main .block.block-order-details-view:before,.account .column.main .block:not(.widget) .block-content:after,.account .column.main .block:not(.widget) .block-content:before,.account .page-title-wrapper:after,.account .page-title-wrapper:before,.block-addresses-list .items.addresses:after,.block-addresses-list .items.addresses:before,.block-cart-failed .block-content:after,.block-cart-failed .block-content:before,.block-giftregistry-shared .item-options:after,.block-giftregistry-shared .item-options:before,.block-wishlist-management:after,.block-wishlist-management:before,.cart-container:after,.cart-container:before,.data.table .gift-wrapping .content:after,.data.table .gift-wrapping .content:before,.data.table .gift-wrapping .nested:after,.data.table .gift-wrapping .nested:before,.header.content:after,.header.content:before,.login-container:after,.login-container:before,.magento-rma-guest-returns .column.main .block.block-order-details-view:after,.magento-rma-guest-returns .column.main .block.block-order-details-view:before,.order-links:after,.order-links:before,.order-review-form:after,.order-review-form:before,.page-header .header.panel:after,.page-header .header.panel:before,.paypal-review .block-content:after,.paypal-review .block-content:before,.paypal-review-discount:after,.paypal-review-discount:before,.sales-guest-view .column.main .block.block-order-details-view:after,.sales-guest-view .column.main .block.block-order-details-view:before,[class^=sales-guest-] .column.main .block.block-order-details-view:after,[class^=sales-guest-] .column.main .block.block-order-details-view:before{content:'';display:table}.abs-add-clearfix-desktop:after,.account .column.main .block.block-order-details-view:after,.account .column.main .block:not(.widget) .block-content:after,.account .page-title-wrapper:after,.block-addresses-list .items.addresses:after,.block-cart-failed .block-content:after,.block-giftregistry-shared .item-options:after,.block-wishlist-management:after,.cart-container:after,.data.table .gift-wrapping .content:after,.data.table .gift-wrapping .nested:after,.header.content:after,.login-container:after,.magento-rma-guest-returns .column.main .block.block-order-details-view:after,.order-links:after,.order-review-form:after,.page-header .header.panel:after,.paypal-review .block-content:after,.paypal-review-discount:after,.sales-guest-view .column.main .block.block-order-details-view:after,[class^=sales-guest-] .column.main .block.block-order-details-view:after{clear:both}.block.category.event,.breadcrumbs,.footer.content,.header.content,.navigation,.page-header .header.panel,.page-main,.page-wrapper>.page-bottom,.page-wrapper>.widget,.top-container{box-sizing:border-box;margin-left:auto;margin-right:auto;max-width:1280px;padding-left:20px;padding-right:20px;width:auto}.panel.header{padding:10px 20px}.page-header .switcher{float:right;margin-left:15px;margin-right:-6px}.header.panel>.header.links>li>a{color:#fff}.header.content{padding:30px 20px 0}.logo{margin:-8px auto 25px 0}.minicart-wrapper{margin-left:13px}.compare.wrapper{list-style:none none}.nav-sections{margin-bottom:25px}.nav-sections-item-content>.navigation{display:block}.navigation{background:#f0f0f0;font-weight:700;height:inherit;left:auto;overflow:inherit;padding:0;position:relative;top:0;width:100%;z-index:3}.navigation ul{margin-top:0;margin-bottom:0;padding:0 8px;position:relative}.navigation .level0{margin:0 10px 0 0;display:inline-block}.navigation .level0>.level-top{color:#575757;line-height:47px;padding:0 12px}.page-main{width:100%}.page-footer{background:#f4f4f4;padding-bottom:25px}.footer.content .links{display:inline-block;padding-right:50px;vertical-align:top}.footer.content ul{padding-right:50px}.footer.content .links li{border:none;font-size:14px;margin:0 0 8px;padding:0}.footer.content .block{float:right}.block.newsletter{width:34%}}@media only screen and (max-width:767px){.compare.wrapper,.panel.wrapper,[class*=block-compare]{display:none}.footer.content .links>li{background:#f4f4f4;font-size:1.6rem;border-top:1px solid #cecece;margin:0 -15px;padding:0 15px}.page-header .header.panel,.page-main{padding-left:15px;padding-right:15px}.header.content{padding-top:10px}.nav-sections-items:after,.nav-sections-items:before{content:'';display:table}.nav-sections-items:after{clear:both}.nav-sections{width:100vw;position:fixed;left:-100vw}} +body{margin:0}.page-main{flex-grow:1}.product-image-wrapper{display:block;height:0;overflow:hidden;position:relative;z-index:1}.product-image-wrapper .product-image-photo{bottom:0;display:block;height:auto;left:0;margin:auto;max-width:100%;position:absolute;right:0;top:0;width:auto}.product-image-container{display:inline-block}.modal-popup{position:fixed}.page-wrapper{display:flex;flex-direction:column;min-height:100vh}.action.skip:not(:focus),.block.newsletter .label,.minicart-wrapper .action.showcart .counter-label,.minicart-wrapper .action.showcart .text,.page-header .switcher .label,.product-item-actions .actions-secondary>.action span{border:0;clip:rect(0,0,0,0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.alink,a{color:#006bb4;text-decoration:none}.page-header .panel.wrapper{background-color:#6e716e;color:#fff}.header.panel>.header.links{list-style:none none;float:right;font-size:0;margin-right:20px}.header.panel>.header.links>li{font-size:14px;margin:0 0 0 15px}.block-search .action.search,.block-search .block-title,.block-search .nested,.block.newsletter .title,.breadcrumbs .item,.nav-toggle,.no-display,.page-footer .switcher .options ul.dropdown,.page-header .switcher .options ul.dropdown{display:none}.block-search .label>span{height:1px;overflow:hidden;position:absolute}.logo{float:left;margin:0 0 10px 40px}.minicart-wrapper{float:right}.page-footer{margin-top:25px}.footer.content{border-top:1px solid #cecece;padding-top:20px}.block.newsletter .actions{display:table-cell;vertical-align:top;width:1%}.block-banners .banner-items,.block-banners-inline .banner-items,.block-event .slider-panel .slider,.footer.content ul,.product-items{margin:0;padding:0;list-style:none none}.copyright{background-color:#6e716e;color:#fff;box-sizing:border-box;display:block;padding:10px;text-align:center}.modal-popup,.modal-slide{visibility:hidden;opacity:0}input[type=email],input[type=number],input[type=password],input[type=search],input[type=text],input[type=url]{background:#fff;background-clip:padding-box;border:1px solid #c2c2c2;border-radius:1px;font-size:14px;height:32px;line-height:1.42857143;padding:0 9px;vertical-align:baseline;width:100%;box-sizing:border-box}.action.primary{background:#1979c3;border:1px solid #1979c3;color:#fff;font-weight:600;padding:7px 15px}.block.newsletter .form.subscribe{display:table}.footer.content .links a{color:#575757}.load.indicator{background-color:rgba(255,255,255,.7);z-index:9999;bottom:0;left:0;position:fixed;right:0;top:0;position:absolute}.load.indicator:before{background:transparent url(../images/loader-2.gif) no-repeat 50% 50%;border-radius:5px;height:160px;width:160px;bottom:0;box-sizing:border-box;content:'';left:0;margin:auto;position:absolute;right:0;top:0}.load.indicator>span{display:none}.loading-mask{bottom:0;left:0;margin:auto;position:fixed;right:0;top:0;z-index:100;background:rgba(255,255,255,.5)}.loading-mask .loader>img{bottom:0;left:0;margin:auto;position:fixed;right:0;top:0;z-index:100}.loading-mask .loader>p{display:none}body>.loading-mask{z-index:9999}._block-content-loading{position:relative}@media (min-width:768px),print{body,html{height:100%}.page-header{border:0;margin-bottom:0}.nav-sections-item-title,.section-item-content .switcher-currency,ul.header.links li.customer-welcome,ul.level0.submenu{display:none}.abs-add-clearfix-desktop:after,.abs-add-clearfix-desktop:before,.account .column.main .block.block-order-details-view:after,.account .column.main .block.block-order-details-view:before,.account .column.main .block:not(.widget) .block-content:after,.account .column.main .block:not(.widget) .block-content:before,.account .page-title-wrapper:after,.account .page-title-wrapper:before,.block-addresses-list .items.addresses:after,.block-addresses-list .items.addresses:before,.block-cart-failed .block-content:after,.block-cart-failed .block-content:before,.block-giftregistry-shared .item-options:after,.block-giftregistry-shared .item-options:before,.block-wishlist-management:after,.block-wishlist-management:before,.cart-container:after,.cart-container:before,.data.table .gift-wrapping .content:after,.data.table .gift-wrapping .content:before,.data.table .gift-wrapping .nested:after,.data.table .gift-wrapping .nested:before,.header.content:after,.header.content:before,.login-container:after,.login-container:before,.magento-rma-guest-returns .column.main .block.block-order-details-view:after,.magento-rma-guest-returns .column.main .block.block-order-details-view:before,.order-links:after,.order-links:before,.order-review-form:after,.order-review-form:before,.page-header .header.panel:after,.page-header .header.panel:before,.paypal-review .block-content:after,.paypal-review .block-content:before,.paypal-review-discount:after,.paypal-review-discount:before,.sales-guest-view .column.main .block.block-order-details-view:after,.sales-guest-view .column.main .block.block-order-details-view:before,[class^=sales-guest-] .column.main .block.block-order-details-view:after,[class^=sales-guest-] .column.main .block.block-order-details-view:before{content:'';display:table}.abs-add-clearfix-desktop:after,.account .column.main .block.block-order-details-view:after,.account .column.main .block:not(.widget) .block-content:after,.account .page-title-wrapper:after,.block-addresses-list .items.addresses:after,.block-cart-failed .block-content:after,.block-giftregistry-shared .item-options:after,.block-wishlist-management:after,.cart-container:after,.data.table .gift-wrapping .content:after,.data.table .gift-wrapping .nested:after,.header.content:after,.login-container:after,.magento-rma-guest-returns .column.main .block.block-order-details-view:after,.order-links:after,.order-review-form:after,.page-header .header.panel:after,.paypal-review .block-content:after,.paypal-review-discount:after,.sales-guest-view .column.main .block.block-order-details-view:after,[class^=sales-guest-] .column.main .block.block-order-details-view:after{clear:both}.block.category.event,.breadcrumbs,.footer.content,.header.content,.navigation,.page-header .header.panel,.page-main,.page-wrapper>.page-bottom,.page-wrapper>.widget,.top-container{box-sizing:border-box;margin-left:auto;margin-right:auto;max-width:1280px;padding-left:20px;padding-right:20px;width:auto}.panel.header{padding:10px 20px}.page-header .switcher{float:right;margin-left:15px;margin-right:-6px}.header.panel>.header.links>li>a{color:#fff}.header.content{padding:30px 20px 0}.logo{margin:-8px auto 25px 0}.minicart-wrapper{margin-left:13px}.compare.wrapper{list-style:none none}.nav-sections{margin-bottom:25px}.nav-sections-item-content>.navigation{display:block}.navigation{background:#f0f0f0;font-weight:700;height:inherit;left:auto;overflow:inherit;padding:0;position:relative;top:0;width:100%;z-index:3}.navigation ul{margin-top:0;margin-bottom:0;padding:0 8px;position:relative}.navigation .level0{margin:0 10px 0 0;display:inline-block}.navigation .level0>.level-top{color:#575757;line-height:47px;padding:0 12px}.page-main{width:100%}.page-footer{background:#f4f4f4;padding-bottom:25px}.footer.content .links{display:inline-block;padding-right:50px;vertical-align:top}.footer.content ul{padding-right:50px}.footer.content .links li{border:none;font-size:14px;margin:0 0 8px;padding:0}.footer.content .block{float:right}.block.newsletter{width:34%}}@media only screen and (max-width:767px){.compare.wrapper,.panel.wrapper,[class*=block-compare]{display:none}.footer.content .links>li{background:#f4f4f4;font-size:1.6rem;border-top:1px solid #cecece;margin:0 -15px;padding:0 15px}.page-header .header.panel,.page-main{padding-left:15px;padding-right:15px}.header.content{padding-top:10px}.nav-sections-items:after,.nav-sections-items:before{content:'';display:table}.nav-sections-items:after{clear:both}.nav-sections{width:100vw;position:fixed;left:-100vw}} diff --git a/app/etc/di.xml b/app/etc/di.xml index 0b4cef35b2898..b1d81ed70f6b4 100644 --- a/app/etc/di.xml +++ b/app/etc/di.xml @@ -7,7 +7,7 @@ --> <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd"> <preference for="DateTimeInterface" type="DateTime" /> - <preference for="Psr\Log\LoggerInterface" type="Magento\Framework\Logger\Monolog" /> + <preference for="Psr\Log\LoggerInterface" type="Magento\Framework\Logger\LoggerProxy" /> <preference for="Magento\Framework\EntityManager\EntityMetadataInterface" type="Magento\Framework\EntityManager\EntityMetadata" /> <preference for="Magento\Framework\EntityManager\HydratorInterface" type="Magento\Framework\EntityManager\Hydrator" /> <preference for="Magento\Framework\View\Template\Html\MinifierInterface" type="Magento\Framework\View\Template\Html\Minifier" /> @@ -331,6 +331,11 @@ <argument name="defaultScope" xsi:type="string">global</argument> </arguments> </virtualType> + <virtualType name="adminhtmlConfigScope" type="Magento\Framework\Config\Scope"> + <arguments> + <argument name="defaultScope" xsi:type="string">adminhtml</argument> + </arguments> + </virtualType> <type name="Magento\Framework\App\State"> <arguments> <argument name="mode" xsi:type="init_parameter">Magento\Framework\App\State::PARAM_MODE</argument> @@ -1844,6 +1849,61 @@ </argument> </arguments> </type> + <virtualType name="DefaultWYSIWYGValidator" type="Magento\Framework\Validator\HTML\ConfigurableWYSIWYGValidator"> + <arguments> + <argument name="allowedTags" xsi:type="array"> + <item name="div" xsi:type="string">div</item> + <item name="a" xsi:type="string">a</item> + <item name="p" xsi:type="string">p</item> + <item name="span" xsi:type="string">span</item> + <item name="em" xsi:type="string">em</item> + <item name="strong" xsi:type="string">strong</item> + <item name="ul" xsi:type="string">ul</item> + <item name="li" xsi:type="string">li</item> + <item name="ol" xsi:type="string">ol</item> + <item name="h5" xsi:type="string">h5</item> + <item name="h4" xsi:type="string">h4</item> + <item name="h3" xsi:type="string">h3</item> + <item name="h2" xsi:type="string">h2</item> + <item name="h1" xsi:type="string">h1</item> + <item name="table" xsi:type="string">table</item> + <item name="tbody" xsi:type="string">tbody</item> + <item name="tr" xsi:type="string">tr</item> + <item name="td" xsi:type="string">td</item> + <item name="th" xsi:type="string">th</item> + <item name="tfoot" xsi:type="string">tfoot</item> + <item name="img" xsi:type="string">img</item> + <item name="hr" xsi:type="string">hr</item> + <item name="figure" xsi:type="string">figure</item> + <item name="button" xsi:type="string">button</item> + </argument> + <argument name="allowedAttributes" xsi:type="array"> + <item name="class" xsi:type="string">class</item> + <item name="width" xsi:type="string">width</item> + <item name="height" xsi:type="string">height</item> + <item name="style" xsi:type="string">style</item> + <item name="alt" xsi:type="string">alt</item> + <item name="title" xsi:type="string">title</item> + <item name="border" xsi:type="string">border</item> + <item name="id" xsi:type="string">id</item> + </argument> + <argument name="attributesAllowedByTags" xsi:type="array"> + <item name="a" xsi:type="array"> + <item name="href" xsi:type="string">href</item> + </item> + <item name="img" xsi:type="array"> + <item name="src" xsi:type="string">src</item> + </item> + <item name="button" xsi:type="array"> + <item name="type" xsi:type="string">type</item> + </item> + </argument> + <argument name="attributeValidators" xsi:type="array"> + <item name="style" xsi:type="object">Magento\Framework\Validator\HTML\StyleAttributeValidator</item> + </argument> + </arguments> + </virtualType> + <preference for="Magento\Framework\Validator\HTML\WYSIWYGValidatorInterface" type="DefaultWYSIWYGValidator" /> <type name="Magento\Framework\View\TemplateEngine\Php"> <arguments> <argument name="blockVariables" xsi:type="array"> diff --git a/composer.json b/composer.json index 57fbfaaa35c2b..b5a484d3828b8 100644 --- a/composer.json +++ b/composer.json @@ -80,7 +80,10 @@ "tedivm/jshrink": "~1.3.0", "tubalmartin/cssmin": "4.1.1", "webonyx/graphql-php": "^0.13.8", - "wikimedia/less.php": "~1.8.0" + "wikimedia/less.php": "~1.8.0", + "league/flysystem": "^1.0", + "league/flysystem-aws-s3-v3": "^1.0", + "league/flysystem-cached-adapter": "^1.0" }, "require-dev": { "allure-framework/allure-phpunit": "~1.2.0", @@ -323,7 +326,9 @@ "twbs/bootstrap": "3.1.0", "tinymce/tinymce": "3.4.7", "magento/module-tinymce-3": "*", - "magento/module-csp": "*" + "magento/module-csp": "*", + "magento/module-aws-s-3": "*", + "magento/module-remote-storage": "*" }, "conflict": { "gene/bluefoot": "*" diff --git a/composer.lock b/composer.lock index 8a5d82536cee4..f4ece70a22e62 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,93 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a03edc1c8ee05f82886eebd6ed288df8", + "content-hash": "50fd3418a729ef9b577d214fe6c9b0b1", "packages": [ + { + "name": "aws/aws-sdk-php", + "version": "3.158.19", + "source": { + "type": "git", + "url": "https://github.com/aws/aws-sdk-php.git", + "reference": "b1c3c763e227e518768f0416cbd2b29c11f79561" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/b1c3c763e227e518768f0416cbd2b29c11f79561", + "reference": "b1c3c763e227e518768f0416cbd2b29c11f79561", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-pcre": "*", + "ext-simplexml": "*", + "guzzlehttp/guzzle": "^5.3.3|^6.2.1|^7.0", + "guzzlehttp/promises": "^1.0", + "guzzlehttp/psr7": "^1.4.1", + "mtdowling/jmespath.php": "^2.5", + "php": ">=5.5" + }, + "require-dev": { + "andrewsville/php-token-reflection": "^1.4", + "aws/aws-php-sns-message-validator": "~1.0", + "behat/behat": "~3.0", + "doctrine/cache": "~1.4", + "ext-dom": "*", + "ext-openssl": "*", + "ext-pcntl": "*", + "ext-sockets": "*", + "nette/neon": "^2.3", + "paragonie/random_compat": ">= 2", + "phpunit/phpunit": "^4.8.35|^5.4.3", + "psr/cache": "^1.0", + "psr/simple-cache": "^1.0", + "sebastian/comparator": "^1.2.3" + }, + "suggest": { + "aws/aws-php-sns-message-validator": "To validate incoming SNS notifications", + "doctrine/cache": "To use the DoctrineCacheAdapter", + "ext-curl": "To send requests using cURL", + "ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages", + "ext-sockets": "To use client-side monitoring" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Aws\\": "src/" + }, + "files": [ + "src/functions.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Amazon Web Services", + "homepage": "http://aws.amazon.com" + } + ], + "description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project", + "homepage": "http://aws.amazon.com/sdkforphp", + "keywords": [ + "amazon", + "aws", + "cloud", + "dynamodb", + "ec2", + "glacier", + "s3", + "sdk" + ], + "time": "2020-11-02T19:49:21+00:00" + }, { "name": "colinmollenhour/cache-backend-file", "version": "v1.4.5", @@ -117,21 +202,21 @@ }, { "name": "colinmollenhour/php-redis-session-abstract", - "version": "v1.4.2", + "version": "v1.4.3", "source": { "type": "git", "url": "https://github.com/colinmollenhour/php-redis-session-abstract.git", - "reference": "669521218794f125c7b668252f4f576eda65e1e4" + "reference": "39ca38da5e0a981bc1a7e39a86693c128784a513" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/colinmollenhour/php-redis-session-abstract/zipball/669521218794f125c7b668252f4f576eda65e1e4", - "reference": "669521218794f125c7b668252f4f576eda65e1e4", + "url": "https://api.github.com/repos/colinmollenhour/php-redis-session-abstract/zipball/39ca38da5e0a981bc1a7e39a86693c128784a513", + "reference": "39ca38da5e0a981bc1a7e39a86693c128784a513", "shasum": "" }, "require": { "colinmollenhour/credis": "~1.6", - "php": "^5.5 || ^7.0" + "php": "^5.5 || ^7.0|| ^7.1 || ^7.2" }, "type": "library", "autoload": { @@ -150,20 +235,20 @@ ], "description": "A Redis-based session handler with optimistic locking", "homepage": "https://github.com/colinmollenhour/php-redis-session-abstract", - "time": "2020-01-08T17:41:01+00:00" + "time": "2020-10-07T09:47:22+00:00" }, { "name": "composer/ca-bundle", - "version": "1.2.7", + "version": "1.2.8", "source": { "type": "git", "url": "https://github.com/composer/ca-bundle.git", - "reference": "95c63ab2117a72f48f5a55da9740a3273d45b7fd" + "reference": "8a7ecad675253e4654ea05505233285377405215" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/ca-bundle/zipball/95c63ab2117a72f48f5a55da9740a3273d45b7fd", - "reference": "95c63ab2117a72f48f5a55da9740a3273d45b7fd", + "url": "https://api.github.com/repos/composer/ca-bundle/zipball/8a7ecad675253e4654ea05505233285377405215", + "reference": "8a7ecad675253e4654ea05505233285377405215", "shasum": "" }, "require": { @@ -211,25 +296,29 @@ "url": "https://packagist.com", "type": "custom" }, + { + "url": "https://github.com/composer", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/composer/composer", "type": "tidelift" } ], - "time": "2020-04-08T08:27:21+00:00" + "time": "2020-08-23T12:54:47+00:00" }, { "name": "composer/composer", - "version": "1.10.9", + "version": "1.10.17", "source": { "type": "git", "url": "https://github.com/composer/composer.git", - "reference": "83c3250093d5491600a822e176b107a945baf95a" + "reference": "09d42e18394d8594be24e37923031c4b7442a1cb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/composer/zipball/83c3250093d5491600a822e176b107a945baf95a", - "reference": "83c3250093d5491600a822e176b107a945baf95a", + "url": "https://api.github.com/repos/composer/composer/zipball/09d42e18394d8594be24e37923031c4b7442a1cb", + "reference": "09d42e18394d8594be24e37923031c4b7442a1cb", "shasum": "" }, "require": { @@ -310,20 +399,20 @@ "type": "tidelift" } ], - "time": "2020-07-16T10:57:00+00:00" + "time": "2020-10-30T21:31:58+00:00" }, { "name": "composer/semver", - "version": "1.5.1", + "version": "1.7.1", "source": { "type": "git", "url": "https://github.com/composer/semver.git", - "reference": "c6bea70230ef4dd483e6bbcab6005f682ed3a8de" + "reference": "38276325bd896f90dfcfe30029aa5db40df387a7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/semver/zipball/c6bea70230ef4dd483e6bbcab6005f682ed3a8de", - "reference": "c6bea70230ef4dd483e6bbcab6005f682ed3a8de", + "url": "https://api.github.com/repos/composer/semver/zipball/38276325bd896f90dfcfe30029aa5db40df387a7", + "reference": "38276325bd896f90dfcfe30029aa5db40df387a7", "shasum": "" }, "require": { @@ -371,7 +460,21 @@ "validation", "versioning" ], - "time": "2020-01-13T12:06:48+00:00" + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2020-09-27T13:13:07+00:00" }, { "name": "composer/spdx-licenses", @@ -449,16 +552,16 @@ }, { "name": "composer/xdebug-handler", - "version": "1.4.2", + "version": "1.4.4", "source": { "type": "git", "url": "https://github.com/composer/xdebug-handler.git", - "reference": "fa2aaf99e2087f013a14f7432c1cd2dd7d8f1f51" + "reference": "6e076a124f7ee146f2487554a94b6a19a74887ba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/fa2aaf99e2087f013a14f7432c1cd2dd7d8f1f51", - "reference": "fa2aaf99e2087f013a14f7432c1cd2dd7d8f1f51", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6e076a124f7ee146f2487554a94b6a19a74887ba", + "reference": "6e076a124f7ee146f2487554a94b6a19a74887ba", "shasum": "" }, "require": { @@ -503,7 +606,7 @@ "type": "tidelift" } ], - "time": "2020-06-04T11:16:35+00:00" + "time": "2020-10-24T12:39:10+00:00" }, { "name": "container-interop/container-interop", @@ -770,23 +873,23 @@ }, { "name": "guzzlehttp/promises", - "version": "v1.3.1", + "version": "1.4.0", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "a59da6cf61d80060647ff4d3eb2c03a2bc694646" + "reference": "60d379c243457e073cff02bc323a2a86cb355631" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/a59da6cf61d80060647ff4d3eb2c03a2bc694646", - "reference": "a59da6cf61d80060647ff4d3eb2c03a2bc694646", + "url": "https://api.github.com/repos/guzzle/promises/zipball/60d379c243457e073cff02bc323a2a86cb355631", + "reference": "60d379c243457e073cff02bc323a2a86cb355631", "shasum": "" }, "require": { - "php": ">=5.5.0" + "php": ">=5.5" }, "require-dev": { - "phpunit/phpunit": "^4.0" + "symfony/phpunit-bridge": "^4.4 || ^5.1" }, "type": "library", "extra": { @@ -817,20 +920,20 @@ "keywords": [ "promise" ], - "time": "2016-12-20T10:07:11+00:00" + "time": "2020-09-30T07:37:28+00:00" }, { "name": "guzzlehttp/psr7", - "version": "1.6.1", + "version": "1.7.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "239400de7a173fe9901b9ac7c06497751f00727a" + "reference": "53330f47520498c0ae1f61f7e2c90f55690c06a3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/239400de7a173fe9901b9ac7c06497751f00727a", - "reference": "239400de7a173fe9901b9ac7c06497751f00727a", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/53330f47520498c0ae1f61f7e2c90f55690c06a3", + "reference": "53330f47520498c0ae1f61f7e2c90f55690c06a3", "shasum": "" }, "require": { @@ -843,15 +946,15 @@ }, "require-dev": { "ext-zlib": "*", - "phpunit/phpunit": "~4.8.36 || ^5.7.27 || ^6.5.8" + "phpunit/phpunit": "~4.8.36 || ^5.7.27 || ^6.5.14 || ^7.5.20 || ^8.5.8 || ^9.3.10" }, "suggest": { - "zendframework/zend-httphandlerrunner": "Emit PSR-7 responses" + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.6-dev" + "dev-master": "1.7-dev" } }, "autoload": { @@ -888,7 +991,7 @@ "uri", "url" ], - "time": "2019-07-01T23:21:34+00:00" + "time": "2020-09-30T07:37:11+00:00" }, { "name": "justinrainbow/json-schema", @@ -1356,6 +1459,12 @@ "BSD-3-Clause" ], "description": "Replace zendframework and zfcampus packages with their Laminas Project equivalents.", + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], "time": "2020-05-20T13:45:39+00:00" }, { @@ -1535,41 +1644,41 @@ }, { "name": "laminas/laminas-eventmanager", - "version": "3.2.1", + "version": "3.3.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-eventmanager.git", - "reference": "ce4dc0bdf3b14b7f9815775af9dfee80a63b4748" + "reference": "1940ccf30e058b2fd66f5a9d696f1b5e0027b082" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-eventmanager/zipball/ce4dc0bdf3b14b7f9815775af9dfee80a63b4748", - "reference": "ce4dc0bdf3b14b7f9815775af9dfee80a63b4748", + "url": "https://api.github.com/repos/laminas/laminas-eventmanager/zipball/1940ccf30e058b2fd66f5a9d696f1b5e0027b082", + "reference": "1940ccf30e058b2fd66f5a9d696f1b5e0027b082", "shasum": "" }, "require": { "laminas/laminas-zendframework-bridge": "^1.0", - "php": "^5.6 || ^7.0" + "php": "^7.3 || ^8.0" }, "replace": { - "zendframework/zend-eventmanager": "self.version" + "zendframework/zend-eventmanager": "^3.2.1" }, "require-dev": { - "athletic/athletic": "^0.1", - "container-interop/container-interop": "^1.1.0", + "container-interop/container-interop": "^1.1", "laminas/laminas-coding-standard": "~1.0.0", "laminas/laminas-stdlib": "^2.7.3 || ^3.0", - "phpunit/phpunit": "^5.7.27 || ^6.5.8 || ^7.1.2" + "phpbench/phpbench": "^0.17.1", + "phpunit/phpunit": "^8.5.8" }, "suggest": { - "container-interop/container-interop": "^1.1.0, to use the lazy listeners feature", + "container-interop/container-interop": "^1.1, to use the lazy listeners feature", "laminas/laminas-stdlib": "^2.7.3 || ^3.0, to use the FilterChain feature" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.2-dev", - "dev-develop": "3.3-dev" + "dev-master": "3.3.x-dev", + "dev-develop": "3.4.x-dev" } }, "autoload": { @@ -1589,20 +1698,26 @@ "events", "laminas" ], - "time": "2019-12-31T16:44:52+00:00" + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2020-08-25T11:10:44+00:00" }, { "name": "laminas/laminas-feed", - "version": "2.12.2", + "version": "2.12.3", "source": { "type": "git", "url": "https://github.com/laminas/laminas-feed.git", - "reference": "8a193ac96ebcb3e16b6ee754ac2a889eefacb654" + "reference": "3c91415633cb1be6f9d78683d69b7dcbfe6b4012" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-feed/zipball/8a193ac96ebcb3e16b6ee754ac2a889eefacb654", - "reference": "8a193ac96ebcb3e16b6ee754ac2a889eefacb654", + "url": "https://api.github.com/repos/laminas/laminas-feed/zipball/3c91415633cb1be6f9d78683d69b7dcbfe6b4012", + "reference": "3c91415633cb1be6f9d78683d69b7dcbfe6b4012", "shasum": "" }, "require": { @@ -1656,7 +1771,13 @@ "feed", "laminas" ], - "time": "2020-03-29T12:36:29+00:00" + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2020-08-18T13:45:04+00:00" }, { "name": "laminas/laminas-filter", @@ -1817,16 +1938,16 @@ }, { "name": "laminas/laminas-http", - "version": "2.12.0", + "version": "2.13.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-http.git", - "reference": "48bd06ffa3a6875e2b77d6852405eb7b1589d575" + "reference": "33b7942f51ce905ce9bfc8bf28badc501d3904b5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-http/zipball/48bd06ffa3a6875e2b77d6852405eb7b1589d575", - "reference": "48bd06ffa3a6875e2b77d6852405eb7b1589d575", + "url": "https://api.github.com/repos/laminas/laminas-http/zipball/33b7942f51ce905ce9bfc8bf28badc501d3904b5", + "reference": "33b7942f51ce905ce9bfc8bf28badc501d3904b5", "shasum": "" }, "require": { @@ -1849,12 +1970,6 @@ "paragonie/certainty": "For automated management of cacert.pem" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.12.x-dev", - "dev-develop": "2.13.x-dev" - } - }, "autoload": { "psr-4": { "Laminas\\Http\\": "src/" @@ -1877,7 +1992,7 @@ "type": "community_bridge" } ], - "time": "2020-06-23T15:14:37+00:00" + "time": "2020-08-18T17:11:58+00:00" }, { "name": "laminas/laminas-hydrator", @@ -1945,23 +2060,23 @@ }, { "name": "laminas/laminas-i18n", - "version": "2.10.3", + "version": "2.11.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-i18n.git", - "reference": "94ff957a1366f5be94f3d3a9b89b50386649e3ae" + "reference": "85678f444b6dcb48e8a04591779e11c24e5bb901" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-i18n/zipball/94ff957a1366f5be94f3d3a9b89b50386649e3ae", - "reference": "94ff957a1366f5be94f3d3a9b89b50386649e3ae", + "url": "https://api.github.com/repos/laminas/laminas-i18n/zipball/85678f444b6dcb48e8a04591779e11c24e5bb901", + "reference": "85678f444b6dcb48e8a04591779e11c24e5bb901", "shasum": "" }, "require": { "ext-intl": "*", "laminas/laminas-stdlib": "^2.7 || ^3.0", "laminas/laminas-zendframework-bridge": "^1.0", - "php": "^5.6 || ^7.0" + "php": "^7.3 || ~8.0.0" }, "conflict": { "phpspec/prophecy": "<1.9.0" @@ -1975,10 +2090,10 @@ "laminas/laminas-config": "^2.6", "laminas/laminas-eventmanager": "^2.6.2 || ^3.0", "laminas/laminas-filter": "^2.6.1", - "laminas/laminas-servicemanager": "^2.7.5 || ^3.0.3", + "laminas/laminas-servicemanager": "^3.2.1", "laminas/laminas-validator": "^2.6", "laminas/laminas-view": "^2.6.3", - "phpunit/phpunit": "^5.7.27 || ^6.5.14 || ^7.5.16" + "phpunit/phpunit": "^9.3" }, "suggest": { "laminas/laminas-cache": "Laminas\\Cache component", @@ -1992,10 +2107,6 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-master": "2.10.x-dev", - "dev-develop": "2.11.x-dev" - }, "laminas": { "component": "Laminas\\I18n", "config-provider": "Laminas\\I18n\\ConfigProvider" @@ -2016,7 +2127,13 @@ "i18n", "laminas" ], - "time": "2020-03-29T12:51:08+00:00" + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2020-10-24T13:14:32+00:00" }, { "name": "laminas/laminas-inputfilter", @@ -2263,16 +2380,16 @@ }, { "name": "laminas/laminas-mail", - "version": "2.11.0", + "version": "2.12.3", "source": { "type": "git", "url": "https://github.com/laminas/laminas-mail.git", - "reference": "4c5545637eea3dc745668ddff1028692ed004c4b" + "reference": "c154a733b122539ac2c894561996c770db289f70" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-mail/zipball/4c5545637eea3dc745668ddff1028692ed004c4b", - "reference": "4c5545637eea3dc745668ddff1028692ed004c4b", + "url": "https://api.github.com/repos/laminas/laminas-mail/zipball/c154a733b122539ac2c894561996c770db289f70", + "reference": "c154a733b122539ac2c894561996c770db289f70", "shasum": "" }, "require": { @@ -2282,7 +2399,7 @@ "laminas/laminas-stdlib": "^2.7 || ^3.0", "laminas/laminas-validator": "^2.10.2", "laminas/laminas-zendframework-bridge": "^1.0", - "php": "^5.6 || ^7.0", + "php": "^7.1", "true/punycode": "^2.1" }, "replace": { @@ -2292,8 +2409,8 @@ "laminas/laminas-coding-standard": "~1.0.0", "laminas/laminas-config": "^2.6", "laminas/laminas-crypt": "^2.6 || ^3.0", - "laminas/laminas-servicemanager": "^2.7.10 || ^3.3.1", - "phpunit/phpunit": "^5.7.25 || ^6.4.4 || ^7.1.4" + "laminas/laminas-servicemanager": "^3.2.1", + "phpunit/phpunit": "^7.5.20" }, "suggest": { "laminas/laminas-crypt": "Crammd5 support in SMTP Auth", @@ -2301,10 +2418,6 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-master": "2.11.x-dev", - "dev-develop": "2.12.x-dev" - }, "laminas": { "component": "Laminas\\Mail", "config-provider": "Laminas\\Mail\\ConfigProvider" @@ -2331,7 +2444,7 @@ "type": "community_bridge" } ], - "time": "2020-06-30T20:17:23+00:00" + "time": "2020-08-12T14:51:33+00:00" }, { "name": "laminas/laminas-math", @@ -2443,16 +2556,16 @@ }, { "name": "laminas/laminas-modulemanager", - "version": "2.8.4", + "version": "2.9.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-modulemanager.git", - "reference": "92b1cde1aab5aef687b863face6dd5d9c6751c78" + "reference": "789bbd4ab391da9221f265f6bb2d594f8f11855b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-modulemanager/zipball/92b1cde1aab5aef687b863face6dd5d9c6751c78", - "reference": "92b1cde1aab5aef687b863face6dd5d9c6751c78", + "url": "https://api.github.com/repos/laminas/laminas-modulemanager/zipball/789bbd4ab391da9221f265f6bb2d594f8f11855b", + "reference": "789bbd4ab391da9221f265f6bb2d594f8f11855b", "shasum": "" }, "require": { @@ -2460,10 +2573,11 @@ "laminas/laminas-eventmanager": "^3.2 || ^2.6.3", "laminas/laminas-stdlib": "^3.1 || ^2.7", "laminas/laminas-zendframework-bridge": "^1.0", - "php": "^5.6 || ^7.0" + "php": "^5.6 || ^7.0", + "webimpress/safe-writer": "^1.0.2 || ^2.1" }, "replace": { - "zendframework/zend-modulemanager": "self.version" + "zendframework/zend-modulemanager": "^2.8.4" }, "require-dev": { "laminas/laminas-coding-standard": "~1.0.0", @@ -2483,8 +2597,8 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "2.8.x-dev", - "dev-develop": "2.9.x-dev" + "dev-master": "2.9.x-dev", + "dev-develop": "2.10.x-dev" } }, "autoload": { @@ -2502,7 +2616,13 @@ "laminas", "modulemanager" ], - "time": "2019-12-31T17:26:56+00:00" + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2020-08-25T09:29:22+00:00" }, { "name": "laminas/laminas-mvc", @@ -2824,23 +2944,23 @@ }, { "name": "laminas/laminas-session", - "version": "2.9.3", + "version": "2.10.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-session.git", - "reference": "519e8966146536cd97c1cc3d59a21b095fb814d7" + "reference": "921e6a9f807ee243a9a4f8a8a297929d0c2b50cd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-session/zipball/519e8966146536cd97c1cc3d59a21b095fb814d7", - "reference": "519e8966146536cd97c1cc3d59a21b095fb814d7", + "url": "https://api.github.com/repos/laminas/laminas-session/zipball/921e6a9f807ee243a9a4f8a8a297929d0c2b50cd", + "reference": "921e6a9f807ee243a9a4f8a8a297929d0c2b50cd", "shasum": "" }, "require": { - "laminas/laminas-eventmanager": "^2.6.2 || ^3.0", + "laminas/laminas-eventmanager": "^3.0", "laminas/laminas-stdlib": "^3.2.1", "laminas/laminas-zendframework-bridge": "^1.0", - "php": "^5.6 || ^7.0" + "php": "^7.3 || ~8.0.0" }, "replace": { "zendframework/zend-session": "^2.9.1" @@ -2851,11 +2971,12 @@ "laminas/laminas-coding-standard": "~1.0.0", "laminas/laminas-db": "^2.7", "laminas/laminas-http": "^2.5.4", - "laminas/laminas-servicemanager": "^2.7.5 || ^3.0.3", + "laminas/laminas-servicemanager": "^3.0.3", "laminas/laminas-validator": "^2.6", "mongodb/mongodb": "^1.0.1", "php-mock/php-mock-phpunit": "^1.1.2 || ^2.0", - "phpunit/phpunit": "^5.7.27 || ^6.5.14 || ^7.5.20" + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.3" }, "suggest": { "laminas/laminas-cache": "Laminas\\Cache component", @@ -2867,10 +2988,6 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-master": "2.9.x-dev", - "dev-develop": "2.10.x-dev" - }, "laminas": { "component": "Laminas\\Session", "config-provider": "Laminas\\Session\\ConfigProvider" @@ -2891,7 +3008,13 @@ "laminas", "session" ], - "time": "2020-03-29T13:26:04+00:00" + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2020-10-31T15:33:31+00:00" }, { "name": "laminas/laminas-soap", @@ -2952,35 +3075,35 @@ }, { "name": "laminas/laminas-stdlib", - "version": "3.2.1", + "version": "3.3.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-stdlib.git", - "reference": "2b18347625a2f06a1a485acfbc870f699dbe51c6" + "reference": "b9d84eaa39fde733356ea948cdef36c631f202b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-stdlib/zipball/2b18347625a2f06a1a485acfbc870f699dbe51c6", - "reference": "2b18347625a2f06a1a485acfbc870f699dbe51c6", + "url": "https://api.github.com/repos/laminas/laminas-stdlib/zipball/b9d84eaa39fde733356ea948cdef36c631f202b6", + "reference": "b9d84eaa39fde733356ea948cdef36c631f202b6", "shasum": "" }, "require": { "laminas/laminas-zendframework-bridge": "^1.0", - "php": "^5.6 || ^7.0" + "php": "^7.3 || ^8.0" }, "replace": { - "zendframework/zend-stdlib": "self.version" + "zendframework/zend-stdlib": "^3.2.1" }, "require-dev": { "laminas/laminas-coding-standard": "~1.0.0", - "phpbench/phpbench": "^0.13", - "phpunit/phpunit": "^5.7.27 || ^6.5.8 || ^7.1.2" + "phpbench/phpbench": "^0.17.1", + "phpunit/phpunit": "^9.3.7" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.2.x-dev", - "dev-develop": "3.3.x-dev" + "dev-master": "3.3.x-dev", + "dev-develop": "3.4.x-dev" } }, "autoload": { @@ -2998,7 +3121,13 @@ "laminas", "stdlib" ], - "time": "2019-12-31T17:51:15+00:00" + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2020-08-25T09:08:16+00:00" }, { "name": "laminas/laminas-text", @@ -3054,38 +3183,32 @@ }, { "name": "laminas/laminas-uri", - "version": "2.7.1", + "version": "2.8.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-uri.git", - "reference": "6be8ce19622f359b048ce4faebf1aa1bca73a7ff" + "reference": "8651611b6285529f25a4cb9a466c686d9b31468e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-uri/zipball/6be8ce19622f359b048ce4faebf1aa1bca73a7ff", - "reference": "6be8ce19622f359b048ce4faebf1aa1bca73a7ff", + "url": "https://api.github.com/repos/laminas/laminas-uri/zipball/8651611b6285529f25a4cb9a466c686d9b31468e", + "reference": "8651611b6285529f25a4cb9a466c686d9b31468e", "shasum": "" }, "require": { "laminas/laminas-escaper": "^2.5", "laminas/laminas-validator": "^2.10", "laminas/laminas-zendframework-bridge": "^1.0", - "php": "^5.6 || ^7.0" + "php": "^7.3 || ~8.0.0" }, "replace": { - "zendframework/zend-uri": "self.version" + "zendframework/zend-uri": "^2.7.1" }, "require-dev": { - "laminas/laminas-coding-standard": "~1.0.0", - "phpunit/phpunit": "^5.7.27 || ^6.5.8 || ^7.1.4" + "laminas/laminas-coding-standard": "^2.1", + "phpunit/phpunit": "^9.3" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.7.x-dev", - "dev-develop": "2.8.x-dev" - } - }, "autoload": { "psr-4": { "Laminas\\Uri\\": "src/" @@ -3101,7 +3224,13 @@ "laminas", "uri" ], - "time": "2019-12-31T17:56:00+00:00" + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2020-10-31T20:20:07+00:00" }, { "name": "laminas/laminas-validator", @@ -3275,31 +3404,27 @@ }, { "name": "laminas/laminas-zendframework-bridge", - "version": "1.0.4", + "version": "1.1.1", "source": { "type": "git", "url": "https://github.com/laminas/laminas-zendframework-bridge.git", - "reference": "fcd87520e4943d968557803919523772475e8ea3" + "reference": "6ede70583e101030bcace4dcddd648f760ddf642" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-zendframework-bridge/zipball/fcd87520e4943d968557803919523772475e8ea3", - "reference": "fcd87520e4943d968557803919523772475e8ea3", + "url": "https://api.github.com/repos/laminas/laminas-zendframework-bridge/zipball/6ede70583e101030bcace4dcddd648f760ddf642", + "reference": "6ede70583e101030bcace4dcddd648f760ddf642", "shasum": "" }, "require": { - "php": "^5.6 || ^7.0" + "php": "^5.6 || ^7.0 || ^8.0" }, "require-dev": { - "phpunit/phpunit": "^5.7 || ^6.5 || ^7.5 || ^8.1", + "phpunit/phpunit": "^5.7 || ^6.5 || ^7.5 || ^8.1 || ^9.3", "squizlabs/php_codesniffer": "^3.5" }, "type": "library", "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev", - "dev-develop": "1.1.x-dev" - }, "laminas": { "module": "Laminas\\ZendFrameworkBridge" } @@ -3323,29 +3448,271 @@ "laminas", "zf" ], - "time": "2020-05-20T16:45:56+00:00" + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2020-09-14T14:23:00+00:00" }, { - "name": "magento/composer", - "version": "1.6.0", + "name": "league/flysystem", + "version": "1.1.3", "source": { "type": "git", - "url": "https://github.com/magento/composer.git", - "reference": "fcc66f535d631788f2ba160ff547357086d9b2c9" + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "9be3b16c877d477357c015cec057548cf9b2a14a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/magento/composer/zipball/fcc66f535d631788f2ba160ff547357086d9b2c9", - "reference": "fcc66f535d631788f2ba160ff547357086d9b2c9", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/9be3b16c877d477357c015cec057548cf9b2a14a", + "reference": "9be3b16c877d477357c015cec057548cf9b2a14a", "shasum": "" }, "require": { - "composer/composer": "^1.9", - "php": "~7.3.0||~7.4.0", - "symfony/console": "~4.4.0" + "ext-fileinfo": "*", + "league/mime-type-detection": "^1.3", + "php": "^7.2.5 || ^8.0" + }, + "conflict": { + "league/flysystem-sftp": "<1.0.6" }, "require-dev": { - "phpunit/phpunit": "^9" + "phpspec/prophecy": "^1.11.1", + "phpunit/phpunit": "^8.5.8" + }, + "suggest": { + "ext-fileinfo": "Required for MimeType", + "ext-ftp": "Allows you to use FTP server storage", + "ext-openssl": "Allows you to use FTPS server storage", + "league/flysystem-aws-s3-v2": "Allows you to use S3 storage with AWS SDK v2", + "league/flysystem-aws-s3-v3": "Allows you to use S3 storage with AWS SDK v3", + "league/flysystem-azure": "Allows you to use Windows Azure Blob storage", + "league/flysystem-cached-adapter": "Flysystem adapter decorator for metadata caching", + "league/flysystem-eventable-filesystem": "Allows you to use EventableFilesystem", + "league/flysystem-rackspace": "Allows you to use Rackspace Cloud Files", + "league/flysystem-sftp": "Allows you to use SFTP server storage via phpseclib", + "league/flysystem-webdav": "Allows you to use WebDAV storage", + "league/flysystem-ziparchive": "Allows you to use ZipArchive adapter", + "spatie/flysystem-dropbox": "Allows you to use Dropbox storage", + "srmklive/flysystem-dropbox-v2": "Allows you to use Dropbox storage for PHP 5 applications" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Flysystem\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frenky.net" + } + ], + "description": "Filesystem abstraction: Many filesystems, one API.", + "keywords": [ + "Cloud Files", + "WebDAV", + "abstraction", + "aws", + "cloud", + "copy.com", + "dropbox", + "file systems", + "files", + "filesystem", + "filesystems", + "ftp", + "rackspace", + "remote", + "s3", + "sftp", + "storage" + ], + "funding": [ + { + "url": "https://offset.earth/frankdejonge", + "type": "other" + } + ], + "time": "2020-08-23T07:39:11+00:00" + }, + { + "name": "league/flysystem-aws-s3-v3", + "version": "1.0.29", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git", + "reference": "4e25cc0582a36a786c31115e419c6e40498f6972" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/4e25cc0582a36a786c31115e419c6e40498f6972", + "reference": "4e25cc0582a36a786c31115e419c6e40498f6972", + "shasum": "" + }, + "require": { + "aws/aws-sdk-php": "^3.20.0", + "league/flysystem": "^1.0.40", + "php": ">=5.5.0" + }, + "require-dev": { + "henrikbjorn/phpspec-code-coverage": "~1.0.1", + "phpspec/phpspec": "^2.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Flysystem\\AwsS3v3\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frenky.net" + } + ], + "description": "Flysystem adapter for the AWS S3 SDK v3.x", + "time": "2020-10-08T18:58:37+00:00" + }, + { + "name": "league/flysystem-cached-adapter", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-cached-adapter.git", + "reference": "d1925efb2207ac4be3ad0c40b8277175f99ffaff" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-cached-adapter/zipball/d1925efb2207ac4be3ad0c40b8277175f99ffaff", + "reference": "d1925efb2207ac4be3ad0c40b8277175f99ffaff", + "shasum": "" + }, + "require": { + "league/flysystem": "~1.0", + "psr/cache": "^1.0.0" + }, + "require-dev": { + "mockery/mockery": "~0.9", + "phpspec/phpspec": "^3.4", + "phpunit/phpunit": "^5.7", + "predis/predis": "~1.0", + "tedivm/stash": "~0.12" + }, + "suggest": { + "ext-phpredis": "Pure C implemented extension for PHP" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\Cached\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "frankdejonge", + "email": "info@frenky.net" + } + ], + "description": "An adapter decorator to enable meta-data caching.", + "time": "2020-07-25T15:56:04+00:00" + }, + { + "name": "league/mime-type-detection", + "version": "1.5.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/mime-type-detection.git", + "reference": "353f66d7555d8a90781f6f5e7091932f9a4250aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/353f66d7555d8a90781f6f5e7091932f9a4250aa", + "reference": "353f66d7555d8a90781f6f5e7091932f9a4250aa", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.36", + "phpunit/phpunit": "^8.5.8" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\MimeTypeDetection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Mime-type detection for Flysystem", + "funding": [ + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2020-10-18T11:50:25+00:00" + }, + { + "name": "magento/composer", + "version": "1.6.0", + "source": { + "type": "git", + "url": "https://github.com/magento/composer.git", + "reference": "fcc66f535d631788f2ba160ff547357086d9b2c9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/magento/composer/zipball/fcc66f535d631788f2ba160ff547357086d9b2c9", + "reference": "fcc66f535d631788f2ba160ff547357086d9b2c9", + "shasum": "" + }, + "require": { + "composer/composer": "^1.9", + "php": "~7.3.0||~7.4.0", + "symfony/console": "~4.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9" }, "type": "library", "autoload": { @@ -3489,16 +3856,16 @@ }, { "name": "monolog/monolog", - "version": "1.25.4", + "version": "1.25.5", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "3022efff205e2448b560c833c6fbbf91c3139168" + "reference": "1817faadd1846cd08be9a49e905dc68823bc38c0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/3022efff205e2448b560c833c6fbbf91c3139168", - "reference": "3022efff205e2448b560c833c6fbbf91c3139168", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/1817faadd1846cd08be9a49e905dc68823bc38c0", + "reference": "1817faadd1846cd08be9a49e905dc68823bc38c0", "shasum": "" }, "require": { @@ -3562,7 +3929,74 @@ "logging", "psr-3" ], - "time": "2020-05-22T07:31:27+00:00" + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2020-07-23T08:35:51+00:00" + }, + { + "name": "mtdowling/jmespath.php", + "version": "2.6.0", + "source": { + "type": "git", + "url": "https://github.com/jmespath/jmespath.php.git", + "reference": "42dae2cbd13154083ca6d70099692fef8ca84bfb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/42dae2cbd13154083ca6d70099692fef8ca84bfb", + "reference": "42dae2cbd13154083ca6d70099692fef8ca84bfb", + "shasum": "" + }, + "require": { + "php": "^5.4 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.17" + }, + "require-dev": { + "composer/xdebug-handler": "^1.4", + "phpunit/phpunit": "^4.8.36 || ^7.5.15" + }, + "bin": [ + "bin/jp.php" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.6-dev" + } + }, + "autoload": { + "psr-4": { + "JmesPath\\": "src/" + }, + "files": [ + "src/JmesPath.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "Declaratively specify how to extract elements from a JSON document", + "keywords": [ + "json", + "jsonpath" + ], + "time": "2020-07-31T21:01:56+00:00" }, { "name": "paragonie/random_compat", @@ -3889,16 +4323,16 @@ }, { "name": "phpseclib/phpseclib", - "version": "2.0.28", + "version": "2.0.29", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "d1ca58cf33cb21046d702ae3a7b14fdacd9f3260" + "reference": "497856a8d997f640b4a516062f84228a772a48a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/d1ca58cf33cb21046d702ae3a7b14fdacd9f3260", - "reference": "d1ca58cf33cb21046d702ae3a7b14fdacd9f3260", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/497856a8d997f640b4a516062f84228a772a48a8", + "reference": "497856a8d997f640b4a516062f84228a772a48a8", "shasum": "" }, "require": { @@ -3907,7 +4341,6 @@ "require-dev": { "phing/phing": "~2.7", "phpunit/phpunit": "^4.8.35|^5.7|^6.0", - "sami/sami": "~2.0", "squizlabs/php_codesniffer": "~2.0" }, "suggest": { @@ -3991,7 +4424,53 @@ "type": "tidelift" } ], - "time": "2020-07-08T09:08:33+00:00" + "time": "2020-09-08T04:24:43+00:00" + }, + { + "name": "psr/cache", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "d11b50ad223250cf17b86e38383413f5a6764bf8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/d11b50ad223250cf17b86e38383413f5a6764bf8", + "reference": "d11b50ad223250cf17b86e38383413f5a6764bf8", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "time": "2016-08-06T20:24:11+00:00" }, { "name": "psr/container", @@ -4309,16 +4788,16 @@ }, { "name": "seld/jsonlint", - "version": "1.8.0", + "version": "1.8.2", "source": { "type": "git", "url": "https://github.com/Seldaek/jsonlint.git", - "reference": "ff2aa5420bfbc296cf6a0bc785fa5b35736de7c1" + "reference": "590cfec960b77fd55e39b7d9246659e95dd6d337" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/jsonlint/zipball/ff2aa5420bfbc296cf6a0bc785fa5b35736de7c1", - "reference": "ff2aa5420bfbc296cf6a0bc785fa5b35736de7c1", + "url": "https://api.github.com/repos/Seldaek/jsonlint/zipball/590cfec960b77fd55e39b7d9246659e95dd6d337", + "reference": "590cfec960b77fd55e39b7d9246659e95dd6d337", "shasum": "" }, "require": { @@ -4354,7 +4833,17 @@ "parser", "validator" ], - "time": "2020-04-30T19:05:18+00:00" + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/seld/jsonlint", + "type": "tidelift" + } + ], + "time": "2020-08-25T06:56:57+00:00" }, { "name": "seld/phar-utils", @@ -4402,16 +4891,16 @@ }, { "name": "symfony/console", - "version": "v4.4.10", + "version": "v4.4.16", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "326b064d804043005526f5a0494cfb49edb59bb0" + "reference": "20f73dd143a5815d475e0838ff867bce1eebd9d5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/326b064d804043005526f5a0494cfb49edb59bb0", - "reference": "326b064d804043005526f5a0494cfb49edb59bb0", + "url": "https://api.github.com/repos/symfony/console/zipball/20f73dd143a5815d475e0838ff867bce1eebd9d5", + "reference": "20f73dd143a5815d475e0838ff867bce1eebd9d5", "shasum": "" }, "require": { @@ -4446,11 +4935,6 @@ "symfony/process": "" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.4-dev" - } - }, "autoload": { "psr-4": { "Symfony\\Component\\Console\\": "" @@ -4489,31 +4973,26 @@ "type": "tidelift" } ], - "time": "2020-05-30T20:06:45+00:00" + "time": "2020-10-24T11:50:19+00:00" }, { "name": "symfony/css-selector", - "version": "v5.1.2", + "version": "v5.1.8", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "e544e24472d4c97b2d11ade7caacd446727c6bf9" + "reference": "6cbebda22ffc0d4bb8fea0c1311c2ca54c4c8fa0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/e544e24472d4c97b2d11ade7caacd446727c6bf9", - "reference": "e544e24472d4c97b2d11ade7caacd446727c6bf9", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/6cbebda22ffc0d4bb8fea0c1311c2ca54c4c8fa0", + "reference": "6cbebda22ffc0d4bb8fea0c1311c2ca54c4c8fa0", "shasum": "" }, "require": { "php": ">=7.2.5" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "5.1-dev" - } - }, "autoload": { "psr-4": { "Symfony\\Component\\CssSelector\\": "" @@ -4556,20 +5035,20 @@ "type": "tidelift" } ], - "time": "2020-05-20T17:43:50+00:00" + "time": "2020-10-24T12:01:57+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v4.4.10", + "version": "v4.4.16", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "a5370aaa7807c7a439b21386661ffccf3dff2866" + "reference": "4204f13d2d0b7ad09454f221bb2195fccdf1fe98" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/a5370aaa7807c7a439b21386661ffccf3dff2866", - "reference": "a5370aaa7807c7a439b21386661ffccf3dff2866", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/4204f13d2d0b7ad09454f221bb2195fccdf1fe98", + "reference": "4204f13d2d0b7ad09454f221bb2195fccdf1fe98", "shasum": "" }, "require": { @@ -4587,6 +5066,7 @@ "psr/log": "~1.0", "symfony/config": "^3.4|^4.0|^5.0", "symfony/dependency-injection": "^3.4|^4.0|^5.0", + "symfony/error-handler": "~3.4|~4.4", "symfony/expression-language": "^3.4|^4.0|^5.0", "symfony/http-foundation": "^3.4|^4.0|^5.0", "symfony/service-contracts": "^1.1|^2", @@ -4597,11 +5077,6 @@ "symfony/http-kernel": "" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.4-dev" - } - }, "autoload": { "psr-4": { "Symfony\\Component\\EventDispatcher\\": "" @@ -4640,7 +5115,7 @@ "type": "tidelift" } ], - "time": "2020-05-20T08:37:50+00:00" + "time": "2020-10-24T11:50:19+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -4720,16 +5195,16 @@ }, { "name": "symfony/filesystem", - "version": "v5.1.2", + "version": "v5.1.8", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "6e4320f06d5f2cce0d96530162491f4465179157" + "reference": "df08650ea7aee2d925380069c131a66124d79177" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/6e4320f06d5f2cce0d96530162491f4465179157", - "reference": "6e4320f06d5f2cce0d96530162491f4465179157", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/df08650ea7aee2d925380069c131a66124d79177", + "reference": "df08650ea7aee2d925380069c131a66124d79177", "shasum": "" }, "require": { @@ -4737,11 +5212,6 @@ "symfony/polyfill-ctype": "~1.8" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "5.1-dev" - } - }, "autoload": { "psr-4": { "Symfony\\Component\\Filesystem\\": "" @@ -4780,31 +5250,26 @@ "type": "tidelift" } ], - "time": "2020-05-30T20:35:19+00:00" + "time": "2020-10-24T12:01:57+00:00" }, { "name": "symfony/finder", - "version": "v5.1.2", + "version": "v5.1.8", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "4298870062bfc667cb78d2b379be4bf5dec5f187" + "reference": "e70eb5a69c2ff61ea135a13d2266e8914a67b3a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/4298870062bfc667cb78d2b379be4bf5dec5f187", - "reference": "4298870062bfc667cb78d2b379be4bf5dec5f187", + "url": "https://api.github.com/repos/symfony/finder/zipball/e70eb5a69c2ff61ea135a13d2266e8914a67b3a0", + "reference": "e70eb5a69c2ff61ea135a13d2266e8914a67b3a0", "shasum": "" }, "require": { "php": ">=7.2.5" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "5.1-dev" - } - }, "autoload": { "psr-4": { "Symfony\\Component\\Finder\\": "" @@ -4843,24 +5308,24 @@ "type": "tidelift" } ], - "time": "2020-05-20T17:43:50+00:00" + "time": "2020-10-24T12:01:57+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.18.0", + "version": "v1.20.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "1c302646f6efc070cd46856e600e5e0684d6b454" + "reference": "f4ba089a5b6366e453971d3aad5fe8e897b37f41" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/1c302646f6efc070cd46856e600e5e0684d6b454", - "reference": "1c302646f6efc070cd46856e600e5e0684d6b454", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/f4ba089a5b6366e453971d3aad5fe8e897b37f41", + "reference": "f4ba089a5b6366e453971d3aad5fe8e897b37f41", "shasum": "" }, "require": { - "php": ">=5.3.3" + "php": ">=7.1" }, "suggest": { "ext-ctype": "For best performance" @@ -4868,7 +5333,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.18-dev" + "dev-main": "1.20-dev" }, "thanks": { "name": "symfony/polyfill", @@ -4919,26 +5384,25 @@ "type": "tidelift" } ], - "time": "2020-07-14T12:35:20+00:00" + "time": "2020-10-23T14:02:19+00:00" }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.18.0", + "version": "v1.20.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "bc6549d068d0160e0f10f7a5a23c7d1406b95ebe" + "reference": "3b75acd829741c768bc8b1f84eb33265e7cc5117" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/bc6549d068d0160e0f10f7a5a23c7d1406b95ebe", - "reference": "bc6549d068d0160e0f10f7a5a23c7d1406b95ebe", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/3b75acd829741c768bc8b1f84eb33265e7cc5117", + "reference": "3b75acd829741c768bc8b1f84eb33265e7cc5117", "shasum": "" }, "require": { - "php": ">=5.3.3", + "php": ">=7.1", "symfony/polyfill-intl-normalizer": "^1.10", - "symfony/polyfill-php70": "^1.10", "symfony/polyfill-php72": "^1.10" }, "suggest": { @@ -4947,7 +5411,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.18-dev" + "dev-main": "1.20-dev" }, "thanks": { "name": "symfony/polyfill", @@ -5004,24 +5468,24 @@ "type": "tidelift" } ], - "time": "2020-07-14T12:35:20+00:00" + "time": "2020-10-23T14:02:19+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.18.0", + "version": "v1.20.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "37078a8dd4a2a1e9ab0231af7c6cb671b2ed5a7e" + "reference": "727d1096295d807c309fb01a851577302394c897" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/37078a8dd4a2a1e9ab0231af7c6cb671b2ed5a7e", - "reference": "37078a8dd4a2a1e9ab0231af7c6cb671b2ed5a7e", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/727d1096295d807c309fb01a851577302394c897", + "reference": "727d1096295d807c309fb01a851577302394c897", "shasum": "" }, "require": { - "php": ">=5.3.3" + "php": ">=7.1" }, "suggest": { "ext-intl": "For best performance" @@ -5029,7 +5493,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.18-dev" + "dev-main": "1.20-dev" }, "thanks": { "name": "symfony/polyfill", @@ -5085,24 +5549,24 @@ "type": "tidelift" } ], - "time": "2020-07-14T12:35:20+00:00" + "time": "2020-10-23T14:02:19+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.18.0", + "version": "v1.20.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "a6977d63bf9a0ad4c65cd352709e230876f9904a" + "reference": "39d483bdf39be819deabf04ec872eb0b2410b531" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/a6977d63bf9a0ad4c65cd352709e230876f9904a", - "reference": "a6977d63bf9a0ad4c65cd352709e230876f9904a", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/39d483bdf39be819deabf04ec872eb0b2410b531", + "reference": "39d483bdf39be819deabf04ec872eb0b2410b531", "shasum": "" }, "require": { - "php": ">=5.3.3" + "php": ">=7.1" }, "suggest": { "ext-mbstring": "For best performance" @@ -5110,7 +5574,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.18-dev" + "dev-main": "1.20-dev" }, "thanks": { "name": "symfony/polyfill", @@ -5162,106 +5626,29 @@ "type": "tidelift" } ], - "time": "2020-07-14T12:35:20+00:00" - }, - { - "name": "symfony/polyfill-php70", - "version": "v1.18.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php70.git", - "reference": "0dd93f2c578bdc9c72697eaa5f1dd25644e618d3" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php70/zipball/0dd93f2c578bdc9c72697eaa5f1dd25644e618d3", - "reference": "0dd93f2c578bdc9c72697eaa5f1dd25644e618d3", - "shasum": "" - }, - "require": { - "paragonie/random_compat": "~1.0|~2.0|~9.99", - "php": ">=5.3.3" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.18-dev" - }, - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" - } - }, - "autoload": { - "psr-4": { - "Symfony\\Polyfill\\Php70\\": "" - }, - "files": [ - "bootstrap.php" - ], - "classmap": [ - "Resources/stubs" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill backporting some PHP 7.0+ features to lower PHP versions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2020-07-14T12:35:20+00:00" + "time": "2020-10-23T14:02:19+00:00" }, { "name": "symfony/polyfill-php72", - "version": "v1.18.0", + "version": "v1.20.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php72.git", - "reference": "639447d008615574653fb3bc60d1986d7172eaae" + "reference": "cede45fcdfabdd6043b3592e83678e42ec69e930" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/639447d008615574653fb3bc60d1986d7172eaae", - "reference": "639447d008615574653fb3bc60d1986d7172eaae", + "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/cede45fcdfabdd6043b3592e83678e42ec69e930", + "reference": "cede45fcdfabdd6043b3592e83678e42ec69e930", "shasum": "" }, "require": { - "php": ">=5.3.3" + "php": ">=7.1" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.18-dev" + "dev-main": "1.20-dev" }, "thanks": { "name": "symfony/polyfill", @@ -5312,29 +5699,29 @@ "type": "tidelift" } ], - "time": "2020-07-14T12:35:20+00:00" + "time": "2020-10-23T14:02:19+00:00" }, { "name": "symfony/polyfill-php73", - "version": "v1.18.0", + "version": "v1.20.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php73.git", - "reference": "fffa1a52a023e782cdcc221d781fe1ec8f87fcca" + "reference": "8ff431c517be11c78c48a39a66d37431e26a6bed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/fffa1a52a023e782cdcc221d781fe1ec8f87fcca", - "reference": "fffa1a52a023e782cdcc221d781fe1ec8f87fcca", + "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/8ff431c517be11c78c48a39a66d37431e26a6bed", + "reference": "8ff431c517be11c78c48a39a66d37431e26a6bed", "shasum": "" }, "require": { - "php": ">=5.3.3" + "php": ">=7.1" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.18-dev" + "dev-main": "1.20-dev" }, "thanks": { "name": "symfony/polyfill", @@ -5388,29 +5775,29 @@ "type": "tidelift" } ], - "time": "2020-07-14T12:35:20+00:00" + "time": "2020-10-23T14:02:19+00:00" }, { "name": "symfony/polyfill-php80", - "version": "v1.18.0", + "version": "v1.20.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "d87d5766cbf48d72388a9f6b85f280c8ad51f981" + "reference": "e70aa8b064c5b72d3df2abd5ab1e90464ad009de" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/d87d5766cbf48d72388a9f6b85f280c8ad51f981", - "reference": "d87d5766cbf48d72388a9f6b85f280c8ad51f981", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/e70aa8b064c5b72d3df2abd5ab1e90464ad009de", + "reference": "e70aa8b064c5b72d3df2abd5ab1e90464ad009de", "shasum": "" }, "require": { - "php": ">=7.0.8" + "php": ">=7.1" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.18-dev" + "dev-main": "1.20-dev" }, "thanks": { "name": "symfony/polyfill", @@ -5468,31 +5855,26 @@ "type": "tidelift" } ], - "time": "2020-07-14T12:35:20+00:00" + "time": "2020-10-23T14:02:19+00:00" }, { "name": "symfony/process", - "version": "v4.4.10", + "version": "v4.4.16", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "c714958428a85c86ab97e3a0c96db4c4f381b7f5" + "reference": "2f4b049fb80ca5e9874615a2a85dc2a502090f05" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/c714958428a85c86ab97e3a0c96db4c4f381b7f5", - "reference": "c714958428a85c86ab97e3a0c96db4c4f381b7f5", + "url": "https://api.github.com/repos/symfony/process/zipball/2f4b049fb80ca5e9874615a2a85dc2a502090f05", + "reference": "2f4b049fb80ca5e9874615a2a85dc2a502090f05", "shasum": "" }, "require": { - "php": "^7.1.3" + "php": ">=7.1.3" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.4-dev" - } - }, "autoload": { "psr-4": { "Symfony\\Component\\Process\\": "" @@ -5531,20 +5913,20 @@ "type": "tidelift" } ], - "time": "2020-05-30T20:06:45+00:00" + "time": "2020-10-24T11:50:19+00:00" }, { "name": "symfony/service-contracts", - "version": "v2.1.3", + "version": "v2.2.0", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "58c7475e5457c5492c26cc740cc0ad7464be9442" + "reference": "d15da7ba4957ffb8f1747218be9e1a121fd298a1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/58c7475e5457c5492c26cc740cc0ad7464be9442", - "reference": "58c7475e5457c5492c26cc740cc0ad7464be9442", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d15da7ba4957ffb8f1747218be9e1a121fd298a1", + "reference": "d15da7ba4957ffb8f1747218be9e1a121fd298a1", "shasum": "" }, "require": { @@ -5557,7 +5939,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "2.1-dev" + "dev-master": "2.2-dev" }, "thanks": { "name": "symfony/contracts", @@ -5607,7 +5989,7 @@ "type": "tidelift" } ], - "time": "2020-07-06T13:23:11+00:00" + "time": "2020-09-07T11:33:47+00:00" }, { "name": "tedivm/jshrink", @@ -5723,36 +6105,91 @@ "cogpowered/finediff": "0.3.*", "phpunit/phpunit": "4.8.*" }, - "bin": [ - "cssmin" - ], + "bin": [ + "cssmin" + ], + "type": "library", + "autoload": { + "psr-4": { + "tubalmartin\\CssMin\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Túbal Martín", + "homepage": "http://tubalmartin.me/" + } + ], + "description": "A PHP port of the YUI CSS compressor", + "homepage": "https://github.com/tubalmartin/YUI-CSS-compressor-PHP-port", + "keywords": [ + "compress", + "compressor", + "css", + "cssmin", + "minify", + "yui" + ], + "time": "2018-01-15T15:26:51+00:00" + }, + { + "name": "webimpress/safe-writer", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/webimpress/safe-writer.git", + "reference": "5cfafdec5873c389036f14bf832a5efc9390dcdd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webimpress/safe-writer/zipball/5cfafdec5873c389036f14bf832a5efc9390dcdd", + "reference": "5cfafdec5873c389036f14bf832a5efc9390dcdd", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.8 || ^9.3.7", + "vimeo/psalm": "^3.14.2", + "webimpress/coding-standard": "^1.1.5" + }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1.x-dev", + "dev-develop": "2.2.x-dev", + "dev-release-1.0": "1.0.x-dev" + } + }, "autoload": { "psr-4": { - "tubalmartin\\CssMin\\": "src" + "Webimpress\\SafeWriter\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "BSD-2-Clause" ], - "authors": [ + "description": "Tool to write files safely, to avoid race conditions", + "keywords": [ + "concurrent write", + "file writer", + "race condition", + "safe writer", + "webimpress" + ], + "funding": [ { - "name": "Túbal Martín", - "homepage": "http://tubalmartin.me/" + "url": "https://github.com/michalbundyra", + "type": "github" } ], - "description": "A PHP port of the YUI CSS compressor", - "homepage": "https://github.com/tubalmartin/YUI-CSS-compressor-PHP-port", - "keywords": [ - "compress", - "compressor", - "css", - "cssmin", - "minify", - "yui" - ], - "time": "2018-01-15T15:26:51+00:00" + "time": "2020-08-25T07:21:11+00:00" }, { "name": "webonyx/graphql-php", @@ -5877,16 +6314,16 @@ "packages-dev": [ { "name": "allure-framework/allure-codeception", - "version": "1.4.3", + "version": "1.4.4", "source": { "type": "git", "url": "https://github.com/allure-framework/allure-codeception.git", - "reference": "9e0e25f8960fa5ac17c65c932ea8153ce6700713" + "reference": "a69800eeef83007ced9502a3349ff72f5fb6b4e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/allure-framework/allure-codeception/zipball/9e0e25f8960fa5ac17c65c932ea8153ce6700713", - "reference": "9e0e25f8960fa5ac17c65c932ea8153ce6700713", + "url": "https://api.github.com/repos/allure-framework/allure-codeception/zipball/a69800eeef83007ced9502a3349ff72f5fb6b4e2", + "reference": "a69800eeef83007ced9502a3349ff72f5fb6b4e2", "shasum": "" }, "require": { @@ -5924,7 +6361,7 @@ "steps", "testing" ], - "time": "2020-03-13T11:07:13+00:00" + "time": "2020-09-09T10:51:33+00:00" }, { "name": "allure-framework/allure-php-api", @@ -6029,91 +6466,6 @@ ], "time": "2018-10-25T12:03:54+00:00" }, - { - "name": "aws/aws-sdk-php", - "version": "3.147.1", - "source": { - "type": "git", - "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "8a561a4a1645ccdd06413a4f2defe55d35e0eecc" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/8a561a4a1645ccdd06413a4f2defe55d35e0eecc", - "reference": "8a561a4a1645ccdd06413a4f2defe55d35e0eecc", - "shasum": "" - }, - "require": { - "ext-json": "*", - "ext-pcre": "*", - "ext-simplexml": "*", - "guzzlehttp/guzzle": "^5.3.3|^6.2.1|^7.0", - "guzzlehttp/promises": "^1.0", - "guzzlehttp/psr7": "^1.4.1", - "mtdowling/jmespath.php": "^2.5", - "php": ">=5.5" - }, - "require-dev": { - "andrewsville/php-token-reflection": "^1.4", - "aws/aws-php-sns-message-validator": "~1.0", - "behat/behat": "~3.0", - "doctrine/cache": "~1.4", - "ext-dom": "*", - "ext-openssl": "*", - "ext-pcntl": "*", - "ext-sockets": "*", - "nette/neon": "^2.3", - "paragonie/random_compat": ">= 2", - "phpunit/phpunit": "^4.8.35|^5.4.3", - "psr/cache": "^1.0", - "psr/simple-cache": "^1.0", - "sebastian/comparator": "^1.2.3" - }, - "suggest": { - "aws/aws-php-sns-message-validator": "To validate incoming SNS notifications", - "doctrine/cache": "To use the DoctrineCacheAdapter", - "ext-curl": "To send requests using cURL", - "ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages", - "ext-sockets": "To use client-side monitoring" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.0-dev" - } - }, - "autoload": { - "psr-4": { - "Aws\\": "src/" - }, - "files": [ - "src/functions.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "Apache-2.0" - ], - "authors": [ - { - "name": "Amazon Web Services", - "homepage": "http://aws.amazon.com" - } - ], - "description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project", - "homepage": "http://aws.amazon.com/sdkforphp", - "keywords": [ - "amazon", - "aws", - "cloud", - "dynamodb", - "ec2", - "glacier", - "s3", - "sdk" - ], - "time": "2020-07-20T18:18:31+00:00" - }, { "name": "beberlei/assert", "version": "v3.2.7", @@ -6330,16 +6682,16 @@ }, { "name": "codeception/codeception", - "version": "4.1.6", + "version": "4.1.11", "source": { "type": "git", "url": "https://github.com/Codeception/Codeception.git", - "reference": "5515b6a6c6f1e1c909aaff2e5f3a15c177dfd1a9" + "reference": "bf2b548a358750a5ecb3d1aa2b32ebfb82a46061" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeception/Codeception/zipball/5515b6a6c6f1e1c909aaff2e5f3a15c177dfd1a9", - "reference": "5515b6a6c6f1e1c909aaff2e5f3a15c177dfd1a9", + "url": "https://api.github.com/repos/Codeception/Codeception/zipball/bf2b548a358750a5ecb3d1aa2b32ebfb82a46061", + "reference": "bf2b548a358750a5ecb3d1aa2b32ebfb82a46061", "shasum": "" }, "require": { @@ -6351,7 +6703,7 @@ "ext-json": "*", "ext-mbstring": "*", "guzzlehttp/psr7": "~1.4", - "php": ">=5.6.0 <8.0", + "php": ">=5.6.0 <9.0", "symfony/console": ">=2.7 <6.0", "symfony/css-selector": ">=2.7 <6.0", "symfony/event-dispatcher": ">=2.7 <6.0", @@ -6369,7 +6721,7 @@ "monolog/monolog": "~1.8", "squizlabs/php_codesniffer": "~2.0", "symfony/process": ">=2.7 <6.0", - "vlucas/phpdotenv": "^2.0 | ^3.0 | ^4.0" + "vlucas/phpdotenv": "^2.0 | ^3.0 | ^4.0 | ^5.0" }, "suggest": { "codeception/specify": "BDD-style code blocks", @@ -6417,25 +6769,26 @@ "type": "open_collective" } ], - "time": "2020-06-07T16:31:51+00:00" + "time": "2020-11-03T17:34:51+00:00" }, { "name": "codeception/lib-asserts", - "version": "1.12.0", + "version": "1.13.2", "source": { "type": "git", "url": "https://github.com/Codeception/lib-asserts.git", - "reference": "acd0dc8b394595a74b58dcc889f72569ff7d8e71" + "reference": "184231d5eab66bc69afd6b9429344d80c67a33b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeception/lib-asserts/zipball/acd0dc8b394595a74b58dcc889f72569ff7d8e71", - "reference": "acd0dc8b394595a74b58dcc889f72569ff7d8e71", + "url": "https://api.github.com/repos/Codeception/lib-asserts/zipball/184231d5eab66bc69afd6b9429344d80c67a33b6", + "reference": "184231d5eab66bc69afd6b9429344d80c67a33b6", "shasum": "" }, "require": { "codeception/phpunit-wrapper": ">6.0.15 <6.1.0 | ^6.6.1 | ^7.7.1 | ^8.0.3 | ^9.0", - "php": ">=5.6.0 <8.0" + "ext-dom": "*", + "php": ">=5.6.0 <9.0" }, "type": "library", "autoload": { @@ -6455,40 +6808,41 @@ }, { "name": "Gintautas Miselis" + }, + { + "name": "Gustavo Nieves", + "homepage": "https://medium.com/@ganieves" } ], "description": "Assertion methods used by Codeception core and Asserts module", - "homepage": "http://codeception.com/", + "homepage": "https://codeception.com/", "keywords": [ "codeception" ], - "time": "2020-04-17T18:20:46+00:00" + "time": "2020-10-21T16:26:20+00:00" }, { "name": "codeception/module-asserts", - "version": "1.2.1", + "version": "1.3.1", "source": { "type": "git", "url": "https://github.com/Codeception/module-asserts.git", - "reference": "79f13d05b63f2fceba4d0e78044bab668c9b2a6b" + "reference": "59374f2fef0cabb9e8ddb53277e85cdca74328de" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeception/module-asserts/zipball/79f13d05b63f2fceba4d0e78044bab668c9b2a6b", - "reference": "79f13d05b63f2fceba4d0e78044bab668c9b2a6b", + "url": "https://api.github.com/repos/Codeception/module-asserts/zipball/59374f2fef0cabb9e8ddb53277e85cdca74328de", + "reference": "59374f2fef0cabb9e8ddb53277e85cdca74328de", "shasum": "" }, "require": { "codeception/codeception": "*@dev", - "codeception/lib-asserts": "^1.12.0", - "php": ">=5.6.0 <8.0" + "codeception/lib-asserts": "^1.13.1", + "php": ">=5.6.0 <9.0" }, "conflict": { "codeception/codeception": "<4.0" }, - "require-dev": { - "codeception/util-robohelpers": "dev-master" - }, "type": "library", "autoload": { "classmap": [ @@ -6505,37 +6859,38 @@ }, { "name": "Gintautas Miselis" + }, + { + "name": "Gustavo Nieves", + "homepage": "https://medium.com/@ganieves" } ], "description": "Codeception module containing various assertions", - "homepage": "http://codeception.com/", + "homepage": "https://codeception.com/", "keywords": [ "assertions", "asserts", "codeception" ], - "time": "2020-04-20T07:26:11+00:00" + "time": "2020-10-21T16:48:15+00:00" }, { "name": "codeception/module-sequence", - "version": "1.0.0", + "version": "1.0.1", "source": { "type": "git", "url": "https://github.com/Codeception/module-sequence.git", - "reference": "70563527b768194d6ab22e1ff943a5e69741c5dd" + "reference": "b75be26681ae90824cde8f8df785981f293667e1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeception/module-sequence/zipball/70563527b768194d6ab22e1ff943a5e69741c5dd", - "reference": "70563527b768194d6ab22e1ff943a5e69741c5dd", + "url": "https://api.github.com/repos/Codeception/module-sequence/zipball/b75be26681ae90824cde8f8df785981f293667e1", + "reference": "b75be26681ae90824cde8f8df785981f293667e1", "shasum": "" }, "require": { - "codeception/codeception": "4.0.x-dev | ^4.0", - "php": ">=5.6.0 <8.0" - }, - "require-dev": { - "codeception/util-robohelpers": "dev-master" + "codeception/codeception": "^4.0", + "php": ">=5.6.0 <9.0" }, "type": "library", "autoload": { @@ -6557,30 +6912,27 @@ "keywords": [ "codeception" ], - "time": "2019-10-10T12:08:50+00:00" + "time": "2020-10-31T18:36:26+00:00" }, { "name": "codeception/module-webdriver", - "version": "1.1.0", + "version": "1.1.3", "source": { "type": "git", "url": "https://github.com/Codeception/module-webdriver.git", - "reference": "09c167817393090ce3dbce96027d94656b1963ce" + "reference": "b7dc227f91730e7abb520439decc9ad0677b8a55" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeception/module-webdriver/zipball/09c167817393090ce3dbce96027d94656b1963ce", - "reference": "09c167817393090ce3dbce96027d94656b1963ce", + "url": "https://api.github.com/repos/Codeception/module-webdriver/zipball/b7dc227f91730e7abb520439decc9ad0677b8a55", + "reference": "b7dc227f91730e7abb520439decc9ad0677b8a55", "shasum": "" }, "require": { "codeception/codeception": "^4.0", - "php": ">=5.6.0 <8.0", + "php": ">=5.6.0 <9.0", "php-webdriver/webdriver": "^1.6.0" }, - "require-dev": { - "codeception/util-robohelpers": "dev-master" - }, "suggest": { "codeception/phpbuiltinserver": "Start and stop PHP built-in web server for your tests" }, @@ -6612,20 +6964,20 @@ "browser-testing", "codeception" ], - "time": "2020-05-31T08:47:24+00:00" + "time": "2020-10-24T15:41:19+00:00" }, { "name": "codeception/phpunit-wrapper", - "version": "9.0.2", + "version": "9.0.5", "source": { "type": "git", "url": "https://github.com/Codeception/phpunit-wrapper.git", - "reference": "eb27243d8edde68593bf8d9ef5e9074734777931" + "reference": "72bac7770866799e23a7dda1ac6bec2f8baccf45" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeception/phpunit-wrapper/zipball/eb27243d8edde68593bf8d9ef5e9074734777931", - "reference": "eb27243d8edde68593bf8d9ef5e9074734777931", + "url": "https://api.github.com/repos/Codeception/phpunit-wrapper/zipball/72bac7770866799e23a7dda1ac6bec2f8baccf45", + "reference": "72bac7770866799e23a7dda1ac6bec2f8baccf45", "shasum": "" }, "require": { @@ -6656,7 +7008,7 @@ } ], "description": "PHPUnit classes used by Codeception", - "time": "2020-04-17T18:16:31+00:00" + "time": "2020-10-11T18:14:42+00:00" }, { "name": "codeception/stub", @@ -6842,16 +7194,16 @@ }, { "name": "doctrine/annotations", - "version": "1.10.3", + "version": "1.11.1", "source": { "type": "git", "url": "https://github.com/doctrine/annotations.git", - "reference": "5db60a4969eba0e0c197a19c077780aadbc43c5d" + "reference": "ce77a7ba1770462cd705a91a151b6c3746f9c6ad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/annotations/zipball/5db60a4969eba0e0c197a19c077780aadbc43c5d", - "reference": "5db60a4969eba0e0c197a19c077780aadbc43c5d", + "url": "https://api.github.com/repos/doctrine/annotations/zipball/ce77a7ba1770462cd705a91a151b6c3746f9c6ad", + "reference": "ce77a7ba1770462cd705a91a151b6c3746f9c6ad", "shasum": "" }, "require": { @@ -6861,12 +7213,14 @@ }, "require-dev": { "doctrine/cache": "1.*", - "phpunit/phpunit": "^7.5" + "doctrine/coding-standard": "^6.0 || ^8.1", + "phpstan/phpstan": "^0.12.20", + "phpunit/phpunit": "^7.5 || ^9.1.5" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.9.x-dev" + "dev-master": "1.11.x-dev" } }, "autoload": { @@ -6901,13 +7255,13 @@ } ], "description": "Docblock Annotations Parser", - "homepage": "http://www.doctrine-project.org", + "homepage": "https://www.doctrine-project.org/projects/annotations.html", "keywords": [ "annotations", "docblock", "parser" ], - "time": "2020-05-25T17:24:27+00:00" + "time": "2020-10-26T10:28:16+00:00" }, { "name": "doctrine/cache", @@ -7220,27 +7574,27 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v2.16.4", + "version": "v2.16.7", "source": { "type": "git", "url": "https://github.com/FriendsOfPHP/PHP-CS-Fixer.git", - "reference": "1023c3458137ab052f6ff1e09621a721bfdeca13" + "reference": "4e35806a6d7d8510d6842ae932e8832363d22c87" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/FriendsOfPHP/PHP-CS-Fixer/zipball/1023c3458137ab052f6ff1e09621a721bfdeca13", - "reference": "1023c3458137ab052f6ff1e09621a721bfdeca13", + "url": "https://api.github.com/repos/FriendsOfPHP/PHP-CS-Fixer/zipball/4e35806a6d7d8510d6842ae932e8832363d22c87", + "reference": "4e35806a6d7d8510d6842ae932e8832363d22c87", "shasum": "" }, "require": { - "composer/semver": "^1.4", + "composer/semver": "^1.4 || ^2.0 || ^3.0", "composer/xdebug-handler": "^1.2", "doctrine/annotations": "^1.2", "ext-json": "*", "ext-tokenizer": "*", - "php": "^5.6 || ^7.0", + "php": "^7.1", "php-cs-fixer/diff": "^1.3", - "symfony/console": "^3.4.17 || ^4.1.6 || ^5.0", + "symfony/console": "^3.4.43 || ^4.1.6 || ^5.0", "symfony/event-dispatcher": "^3.0 || ^4.0 || ^5.0", "symfony/filesystem": "^3.0 || ^4.0 || ^5.0", "symfony/finder": "^3.0 || ^4.0 || ^5.0", @@ -7253,14 +7607,14 @@ "require-dev": { "johnkary/phpunit-speedtrap": "^1.1 || ^2.0 || ^3.0", "justinrainbow/json-schema": "^5.0", - "keradus/cli-executor": "^1.2", + "keradus/cli-executor": "^1.4", "mikey179/vfsstream": "^1.6", - "php-coveralls/php-coveralls": "^2.1", + "php-coveralls/php-coveralls": "^2.4.1", "php-cs-fixer/accessible-object": "^1.0", - "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.1", - "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.1", + "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.2", + "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.2.1", "phpunit/phpunit": "^5.7.27 || ^6.5.14 || ^7.1", - "phpunitgoodpractices/traits": "^1.8", + "phpunitgoodpractices/traits": "^1.9.1", "symfony/phpunit-bridge": "^5.1", "symfony/yaml": "^3.0 || ^4.0 || ^5.0" }, @@ -7313,7 +7667,7 @@ "type": "github" } ], - "time": "2020-06-27T23:57:46+00:00" + "time": "2020-10-27T22:44:27+00:00" }, { "name": "hoa/consistency", @@ -8038,90 +8392,6 @@ ], "time": "2020-02-22T20:59:37+00:00" }, - { - "name": "league/flysystem", - "version": "1.0.69", - "source": { - "type": "git", - "url": "https://github.com/thephpleague/flysystem.git", - "reference": "7106f78428a344bc4f643c233a94e48795f10967" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/7106f78428a344bc4f643c233a94e48795f10967", - "reference": "7106f78428a344bc4f643c233a94e48795f10967", - "shasum": "" - }, - "require": { - "ext-fileinfo": "*", - "php": ">=5.5.9" - }, - "conflict": { - "league/flysystem-sftp": "<1.0.6" - }, - "require-dev": { - "phpspec/phpspec": "^3.4", - "phpunit/phpunit": "^5.7.26" - }, - "suggest": { - "ext-fileinfo": "Required for MimeType", - "ext-ftp": "Allows you to use FTP server storage", - "ext-openssl": "Allows you to use FTPS server storage", - "league/flysystem-aws-s3-v2": "Allows you to use S3 storage with AWS SDK v2", - "league/flysystem-aws-s3-v3": "Allows you to use S3 storage with AWS SDK v3", - "league/flysystem-azure": "Allows you to use Windows Azure Blob storage", - "league/flysystem-cached-adapter": "Flysystem adapter decorator for metadata caching", - "league/flysystem-eventable-filesystem": "Allows you to use EventableFilesystem", - "league/flysystem-rackspace": "Allows you to use Rackspace Cloud Files", - "league/flysystem-sftp": "Allows you to use SFTP server storage via phpseclib", - "league/flysystem-webdav": "Allows you to use WebDAV storage", - "league/flysystem-ziparchive": "Allows you to use ZipArchive adapter", - "spatie/flysystem-dropbox": "Allows you to use Dropbox storage", - "srmklive/flysystem-dropbox-v2": "Allows you to use Dropbox storage for PHP 5 applications" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.1-dev" - } - }, - "autoload": { - "psr-4": { - "League\\Flysystem\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Frank de Jonge", - "email": "info@frenky.net" - } - ], - "description": "Filesystem abstraction: Many filesystems, one API.", - "keywords": [ - "Cloud Files", - "WebDAV", - "abstraction", - "aws", - "cloud", - "copy.com", - "dropbox", - "file systems", - "files", - "filesystem", - "filesystems", - "ftp", - "rackspace", - "remote", - "s3", - "sftp", - "storage" - ], - "time": "2020-05-18T15:13:39+00:00" - }, { "name": "lusitanian/oauth", "version": "v0.8.11", @@ -8230,16 +8500,16 @@ }, { "name": "magento/magento2-functional-testing-framework", - "version": "3.1.0", + "version": "3.2.0", "source": { "type": "git", "url": "https://github.com/magento/magento2-functional-testing-framework.git", - "reference": "8a106ea029f222f4354854636861273c7577bee9" + "reference": "0ec0c87335af996cbf3c0aace375d4e659e7a6dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/magento/magento2-functional-testing-framework/zipball/8a106ea029f222f4354854636861273c7577bee9", - "reference": "8a106ea029f222f4354854636861273c7577bee9", + "url": "https://api.github.com/repos/magento/magento2-functional-testing-framework/zipball/0ec0c87335af996cbf3c0aace375d4e659e7a6dc", + "reference": "0ec0c87335af996cbf3c0aace375d4e659e7a6dc", "shasum": "" }, "require": { @@ -8317,7 +8587,7 @@ "magento", "testing" ], - "time": "2020-08-19T19:57:27+00:00" + "time": "2020-11-05T15:57:52+00:00" }, { "name": "mikey179/vfsstream", @@ -8330,97 +8600,40 @@ "dist": { "type": "zip", "url": "https://api.github.com/repos/bovigo/vfsStream/zipball/231c73783ebb7dd9ec77916c10037eff5a2b6efe", - "reference": "231c73783ebb7dd9ec77916c10037eff5a2b6efe", - "shasum": "" - }, - "require": { - "php": ">=5.3.0" - }, - "require-dev": { - "phpunit/phpunit": "^4.5|^5.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.6.x-dev" - } - }, - "autoload": { - "psr-0": { - "org\\bovigo\\vfs\\": "src/main/php" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Frank Kleine", - "homepage": "http://frankkleine.de/", - "role": "Developer" - } - ], - "description": "Virtual file system to mock the real file system in unit tests.", - "homepage": "http://vfs.bovigo.org/", - "time": "2019-10-30T15:31:00+00:00" - }, - { - "name": "mtdowling/jmespath.php", - "version": "2.5.0", - "source": { - "type": "git", - "url": "https://github.com/jmespath/jmespath.php.git", - "reference": "52168cb9472de06979613d365c7f1ab8798be895" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/52168cb9472de06979613d365c7f1ab8798be895", - "reference": "52168cb9472de06979613d365c7f1ab8798be895", + "reference": "231c73783ebb7dd9ec77916c10037eff5a2b6efe", "shasum": "" }, "require": { - "php": ">=5.4.0", - "symfony/polyfill-mbstring": "^1.4" + "php": ">=5.3.0" }, "require-dev": { - "composer/xdebug-handler": "^1.2", - "phpunit/phpunit": "^4.8.36|^7.5.15" + "phpunit/phpunit": "^4.5|^5.0" }, - "bin": [ - "bin/jp.php" - ], "type": "library", "extra": { "branch-alias": { - "dev-master": "2.5-dev" + "dev-master": "1.6.x-dev" } }, "autoload": { - "psr-4": { - "JmesPath\\": "src/" - }, - "files": [ - "src/JmesPath.php" - ] + "psr-0": { + "org\\bovigo\\vfs\\": "src/main/php" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Michael Dowling", - "email": "mtdowling@gmail.com", - "homepage": "https://github.com/mtdowling" + "name": "Frank Kleine", + "homepage": "http://frankkleine.de/", + "role": "Developer" } ], - "description": "Declaratively specify how to extract elements from a JSON document", - "keywords": [ - "json", - "jsonpath" - ], - "time": "2019-12-30T18:03:34+00:00" + "description": "Virtual file system to mock the real file system in unit tests.", + "homepage": "http://vfs.bovigo.org/", + "time": "2019-10-30T15:31:00+00:00" }, { "name": "mustache/mustache", @@ -8735,23 +8948,23 @@ }, { "name": "php-cs-fixer/diff", - "version": "v1.3.0", + "version": "v1.3.1", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/diff.git", - "reference": "78bb099e9c16361126c86ce82ec4405ebab8e756" + "reference": "dbd31aeb251639ac0b9e7e29405c1441907f5759" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/diff/zipball/78bb099e9c16361126c86ce82ec4405ebab8e756", - "reference": "78bb099e9c16361126c86ce82ec4405ebab8e756", + "url": "https://api.github.com/repos/PHP-CS-Fixer/diff/zipball/dbd31aeb251639ac0b9e7e29405c1441907f5759", + "reference": "dbd31aeb251639ac0b9e7e29405c1441907f5759", "shasum": "" }, "require": { - "php": "^5.6 || ^7.0" + "php": "^5.6 || ^7.0 || ^8.0" }, "require-dev": { - "phpunit/phpunit": "^5.7.23 || ^6.4.3", + "phpunit/phpunit": "^5.7.23 || ^6.4.3 || ^7.0", "symfony/process": "^3.3" }, "type": "library", @@ -8765,14 +8978,14 @@ "BSD-3-Clause" ], "authors": [ - { - "name": "Kore Nordmann", - "email": "mail@kore-nordmann.de" - }, { "name": "Sebastian Bergmann", "email": "sebastian@phpunit.de" }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + }, { "name": "SpacePossum" } @@ -8782,7 +8995,7 @@ "keywords": [ "diff" ], - "time": "2018-02-15T16:58:55+00:00" + "time": "2020-10-14T08:39:05+00:00" }, { "name": "php-webdriver/webdriver", @@ -9006,16 +9219,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "5.2.0", + "version": "5.2.2", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "3170448f5769fe19f456173d833734e0ff1b84df" + "reference": "069a785b2141f5bcf49f3e353548dc1cce6df556" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/3170448f5769fe19f456173d833734e0ff1b84df", - "reference": "3170448f5769fe19f456173d833734e0ff1b84df", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/069a785b2141f5bcf49f3e353548dc1cce6df556", + "reference": "069a785b2141f5bcf49f3e353548dc1cce6df556", "shasum": "" }, "require": { @@ -9054,20 +9267,20 @@ } ], "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", - "time": "2020-07-20T20:05:34+00:00" + "time": "2020-09-03T19:13:55+00:00" }, { "name": "phpdocumentor/type-resolver", - "version": "1.3.0", + "version": "1.4.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "e878a14a65245fbe78f8080eba03b47c3b705651" + "reference": "6a467b8989322d92aa1c8bf2bebcc6e5c2ba55c0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/e878a14a65245fbe78f8080eba03b47c3b705651", - "reference": "e878a14a65245fbe78f8080eba03b47c3b705651", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/6a467b8989322d92aa1c8bf2bebcc6e5c2ba55c0", + "reference": "6a467b8989322d92aa1c8bf2bebcc6e5c2ba55c0", "shasum": "" }, "require": { @@ -9099,20 +9312,20 @@ } ], "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", - "time": "2020-06-27T10:12:23+00:00" + "time": "2020-09-17T18:55:26+00:00" }, { "name": "phpmd/phpmd", - "version": "2.8.2", + "version": "2.9.1", "source": { "type": "git", "url": "https://github.com/phpmd/phpmd.git", - "reference": "714629ed782537f638fe23c4346637659b779a77" + "reference": "ce10831d4ddc2686c1348a98069771dd314534a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpmd/phpmd/zipball/714629ed782537f638fe23c4346637659b779a77", - "reference": "714629ed782537f638fe23c4346637659b779a77", + "url": "https://api.github.com/repos/phpmd/phpmd/zipball/ce10831d4ddc2686c1348a98069771dd314534a8", + "reference": "ce10831d4ddc2686c1348a98069771dd314534a8", "shasum": "" }, "require": { @@ -9123,6 +9336,8 @@ }, "require-dev": { "easy-doc/easy-doc": "0.0.0 || ^1.3.2", + "ext-json": "*", + "ext-simplexml": "*", "gregwar/rst": "^1.0", "mikey179/vfsstream": "^1.6.4", "phpunit/phpunit": "^4.8.36 || ^5.7.27", @@ -9169,7 +9384,13 @@ "phpmd", "pmd" ], - "time": "2020-02-16T20:15:50+00:00" + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/phpmd/phpmd", + "type": "tidelift" + } + ], + "time": "2020-09-23T22:06:32+00:00" }, { "name": "phpoption/phpoption", @@ -9238,28 +9459,28 @@ }, { "name": "phpspec/prophecy", - "version": "1.11.1", + "version": "1.12.1", "source": { "type": "git", "url": "https://github.com/phpspec/prophecy.git", - "reference": "b20034be5efcdab4fb60ca3a29cba2949aead160" + "reference": "8ce87516be71aae9b956f81906aaf0338e0d8a2d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpspec/prophecy/zipball/b20034be5efcdab4fb60ca3a29cba2949aead160", - "reference": "b20034be5efcdab4fb60ca3a29cba2949aead160", + "url": "https://api.github.com/repos/phpspec/prophecy/zipball/8ce87516be71aae9b956f81906aaf0338e0d8a2d", + "reference": "8ce87516be71aae9b956f81906aaf0338e0d8a2d", "shasum": "" }, "require": { "doctrine/instantiator": "^1.2", - "php": "^7.2", - "phpdocumentor/reflection-docblock": "^5.0", + "php": "^7.2 || ~8.0, <8.1", + "phpdocumentor/reflection-docblock": "^5.2", "sebastian/comparator": "^3.0 || ^4.0", "sebastian/recursion-context": "^3.0 || ^4.0" }, "require-dev": { "phpspec/phpspec": "^6.0", - "phpunit/phpunit": "^8.0" + "phpunit/phpunit": "^8.0 || ^9.0 <9.3" }, "type": "library", "extra": { @@ -9297,7 +9518,7 @@ "spy", "stub" ], - "time": "2020-07-08T12:44:21+00:00" + "time": "2020-09-29T09:10:42+00:00" }, { "name": "phpstan/phpstan", @@ -9339,6 +9560,20 @@ "MIT" ], "description": "PHPStan - PHP Static Analysis Tool", + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpstan", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpstan/phpstan", + "type": "tidelift" + } + ], "time": "2020-05-05T12:55:44+00:00" }, { @@ -9413,23 +9648,23 @@ }, { "name": "phpunit/php-file-iterator", - "version": "3.0.4", + "version": "3.0.5", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "25fefc5b19835ca653877fe081644a3f8c1d915e" + "reference": "aa4be8575f26070b100fccb67faabb28f21f66f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/25fefc5b19835ca653877fe081644a3f8c1d915e", - "reference": "25fefc5b19835ca653877fe081644a3f8c1d915e", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/aa4be8575f26070b100fccb67faabb28f21f66f8", + "reference": "aa4be8575f26070b100fccb67faabb28f21f66f8", "shasum": "" }, "require": { - "php": "^7.3 || ^8.0" + "php": ">=7.3" }, "require-dev": { - "phpunit/phpunit": "^9.0" + "phpunit/phpunit": "^9.3" }, "type": "library", "extra": { @@ -9465,28 +9700,28 @@ "type": "github" } ], - "time": "2020-07-11T05:18:21+00:00" + "time": "2020-09-28T05:57:25+00:00" }, { "name": "phpunit/php-invoker", - "version": "3.0.2", + "version": "3.1.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-invoker.git", - "reference": "f6eedfed1085dd1f4c599629459a0277d25f9a66" + "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/f6eedfed1085dd1f4c599629459a0277d25f9a66", - "reference": "f6eedfed1085dd1f4c599629459a0277d25f9a66", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67", "shasum": "" }, "require": { - "php": "^7.3 || ^8.0" + "php": ">=7.3" }, "require-dev": { "ext-pcntl": "*", - "phpunit/phpunit": "^9.0" + "phpunit/phpunit": "^9.3" }, "suggest": { "ext-pcntl": "*" @@ -9494,7 +9729,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0-dev" + "dev-master": "3.1-dev" } }, "autoload": { @@ -9524,27 +9759,27 @@ "type": "github" } ], - "time": "2020-06-26T11:53:53+00:00" + "time": "2020-09-28T05:58:55+00:00" }, { "name": "phpunit/php-text-template", - "version": "2.0.2", + "version": "2.0.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "6ff9c8ea4d3212b88fcf74e25e516e2c51c99324" + "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/6ff9c8ea4d3212b88fcf74e25e516e2c51c99324", - "reference": "6ff9c8ea4d3212b88fcf74e25e516e2c51c99324", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", "shasum": "" }, "require": { - "php": "^7.3 || ^8.0" + "php": ">=7.3" }, "require-dev": { - "phpunit/phpunit": "^9.0" + "phpunit/phpunit": "^9.3" }, "type": "library", "extra": { @@ -9579,7 +9814,7 @@ "type": "github" } ], - "time": "2020-06-26T11:55:37+00:00" + "time": "2020-10-26T05:33:50+00:00" }, { "name": "phpunit/php-timer", @@ -9628,20 +9863,26 @@ "keywords": [ "timer" ], + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], "time": "2020-04-20T06:00:37+00:00" }, { "name": "phpunit/php-token-stream", - "version": "4.0.3", + "version": "4.0.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-token-stream.git", - "reference": "5672711b6b07b14d5ab694e700c62eeb82fcf374" + "reference": "a853a0e183b9db7eed023d7933a858fa1c8d25a3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/5672711b6b07b14d5ab694e700c62eeb82fcf374", - "reference": "5672711b6b07b14d5ab694e700c62eeb82fcf374", + "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/a853a0e183b9db7eed023d7933a858fa1c8d25a3", + "reference": "a853a0e183b9db7eed023d7933a858fa1c8d25a3", "shasum": "" }, "require": { @@ -9684,7 +9925,7 @@ } ], "abandoned": true, - "time": "2020-06-27T06:36:25+00:00" + "time": "2020-08-04T08:28:15+00:00" }, { "name": "phpunit/phpunit", @@ -9772,53 +10013,17 @@ "testing", "xunit" ], - "time": "2020-05-22T13:54:05+00:00" - }, - { - "name": "psr/cache", - "version": "1.0.1", - "source": { - "type": "git", - "url": "https://github.com/php-fig/cache.git", - "reference": "d11b50ad223250cf17b86e38383413f5a6764bf8" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/cache/zipball/d11b50ad223250cf17b86e38383413f5a6764bf8", - "reference": "d11b50ad223250cf17b86e38383413f5a6764bf8", - "shasum": "" - }, - "require": { - "php": ">=5.3.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Cache\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ + "funding": [ { - "name": "PHP-FIG", - "homepage": "http://www.php-fig.org/" + "url": "https://phpunit.de/donate.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" } ], - "description": "Common interface for caching libraries", - "keywords": [ - "cache", - "psr", - "psr-6" - ], - "time": "2016-08-06T20:24:11+00:00" + "time": "2020-05-22T13:54:05+00:00" }, { "name": "psr/simple-cache", @@ -9870,23 +10075,23 @@ }, { "name": "sebastian/code-unit", - "version": "1.0.5", + "version": "1.0.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/code-unit.git", - "reference": "c1e2df332c905079980b119c4db103117e5e5c90" + "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/c1e2df332c905079980b119c4db103117e5e5c90", - "reference": "c1e2df332c905079980b119c4db103117e5e5c90", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120", + "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120", "shasum": "" }, "require": { - "php": "^7.3 || ^8.0" + "php": ">=7.3" }, "require-dev": { - "phpunit/phpunit": "^9.0" + "phpunit/phpunit": "^9.3" }, "type": "library", "extra": { @@ -9918,27 +10123,27 @@ "type": "github" } ], - "time": "2020-06-26T12:50:45+00:00" + "time": "2020-10-26T13:08:54+00:00" }, { "name": "sebastian/code-unit-reverse-lookup", - "version": "2.0.2", + "version": "2.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", - "reference": "ee51f9bb0c6d8a43337055db3120829fa14da819" + "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ee51f9bb0c6d8a43337055db3120829fa14da819", - "reference": "ee51f9bb0c6d8a43337055db3120829fa14da819", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", "shasum": "" }, "require": { - "php": "^7.3 || ^8.0" + "php": ">=7.3" }, "require-dev": { - "phpunit/phpunit": "^9.0" + "phpunit/phpunit": "^9.3" }, "type": "library", "extra": { @@ -9969,29 +10174,29 @@ "type": "github" } ], - "time": "2020-06-26T12:04:00+00:00" + "time": "2020-09-28T05:30:19+00:00" }, { "name": "sebastian/comparator", - "version": "4.0.3", + "version": "4.0.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "dcc580eadfaa4e7f9d2cf9ae1922134ea962e14f" + "reference": "55f4261989e546dc112258c7a75935a81a7ce382" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/dcc580eadfaa4e7f9d2cf9ae1922134ea962e14f", - "reference": "dcc580eadfaa4e7f9d2cf9ae1922134ea962e14f", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/55f4261989e546dc112258c7a75935a81a7ce382", + "reference": "55f4261989e546dc112258c7a75935a81a7ce382", "shasum": "" }, "require": { - "php": "^7.3 || ^8.0", + "php": ">=7.3", "sebastian/diff": "^4.0", "sebastian/exporter": "^4.0" }, "require-dev": { - "phpunit/phpunit": "^9.0" + "phpunit/phpunit": "^9.3" }, "type": "library", "extra": { @@ -10039,27 +10244,27 @@ "type": "github" } ], - "time": "2020-06-26T12:05:46+00:00" + "time": "2020-10-26T15:49:45+00:00" }, { "name": "sebastian/diff", - "version": "4.0.2", + "version": "4.0.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "1e90b4cf905a7d06c420b1d2e9d11a4dc8a13113" + "reference": "3461e3fccc7cfdfc2720be910d3bd73c69be590d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/1e90b4cf905a7d06c420b1d2e9d11a4dc8a13113", - "reference": "1e90b4cf905a7d06c420b1d2e9d11a4dc8a13113", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/3461e3fccc7cfdfc2720be910d3bd73c69be590d", + "reference": "3461e3fccc7cfdfc2720be910d3bd73c69be590d", "shasum": "" }, "require": { - "php": "^7.3 || ^8.0" + "php": ">=7.3" }, "require-dev": { - "phpunit/phpunit": "^9.0", + "phpunit/phpunit": "^9.3", "symfony/process": "^4.2 || ^5" }, "type": "library", @@ -10101,27 +10306,27 @@ "type": "github" } ], - "time": "2020-06-30T04:46:02+00:00" + "time": "2020-10-26T13:10:38+00:00" }, { "name": "sebastian/environment", - "version": "5.1.2", + "version": "5.1.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "0a757cab9d5b7ef49a619f1143e6c9c1bc0fe9d2" + "reference": "388b6ced16caa751030f6a69e588299fa09200ac" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/0a757cab9d5b7ef49a619f1143e6c9c1bc0fe9d2", - "reference": "0a757cab9d5b7ef49a619f1143e6c9c1bc0fe9d2", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/388b6ced16caa751030f6a69e588299fa09200ac", + "reference": "388b6ced16caa751030f6a69e588299fa09200ac", "shasum": "" }, "require": { - "php": "^7.3 || ^8.0" + "php": ">=7.3" }, "require-dev": { - "phpunit/phpunit": "^9.0" + "phpunit/phpunit": "^9.3" }, "suggest": { "ext-posix": "*" @@ -10129,7 +10334,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-master": "5.1-dev" } }, "autoload": { @@ -10160,29 +10365,29 @@ "type": "github" } ], - "time": "2020-06-26T12:07:24+00:00" + "time": "2020-09-28T05:52:38+00:00" }, { "name": "sebastian/exporter", - "version": "4.0.2", + "version": "4.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "571d721db4aec847a0e59690b954af33ebf9f023" + "reference": "d89cc98761b8cb5a1a235a6b703ae50d34080e65" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/571d721db4aec847a0e59690b954af33ebf9f023", - "reference": "571d721db4aec847a0e59690b954af33ebf9f023", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/d89cc98761b8cb5a1a235a6b703ae50d34080e65", + "reference": "d89cc98761b8cb5a1a235a6b703ae50d34080e65", "shasum": "" }, "require": { - "php": "^7.3 || ^8.0", + "php": ">=7.3", "sebastian/recursion-context": "^4.0" }, "require-dev": { "ext-mbstring": "*", - "phpunit/phpunit": "^9.2" + "phpunit/phpunit": "^9.3" }, "type": "library", "extra": { @@ -10233,7 +10438,7 @@ "type": "github" } ], - "time": "2020-06-26T12:08:55+00:00" + "time": "2020-09-28T05:24:23+00:00" }, { "name": "sebastian/finder-facade", @@ -10338,25 +10543,25 @@ }, { "name": "sebastian/object-enumerator", - "version": "4.0.2", + "version": "4.0.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-enumerator.git", - "reference": "074fed2d0a6d08e1677dd8ce9d32aecb384917b8" + "reference": "5c9eeac41b290a3712d88851518825ad78f45c71" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/074fed2d0a6d08e1677dd8ce9d32aecb384917b8", - "reference": "074fed2d0a6d08e1677dd8ce9d32aecb384917b8", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71", + "reference": "5c9eeac41b290a3712d88851518825ad78f45c71", "shasum": "" }, "require": { - "php": "^7.3 || ^8.0", + "php": ">=7.3", "sebastian/object-reflector": "^2.0", "sebastian/recursion-context": "^4.0" }, "require-dev": { - "phpunit/phpunit": "^9.0" + "phpunit/phpunit": "^9.3" }, "type": "library", "extra": { @@ -10387,27 +10592,27 @@ "type": "github" } ], - "time": "2020-06-26T12:11:32+00:00" + "time": "2020-10-26T13:12:34+00:00" }, { "name": "sebastian/object-reflector", - "version": "2.0.2", + "version": "2.0.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "127a46f6b057441b201253526f81d5406d6c7840" + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/127a46f6b057441b201253526f81d5406d6c7840", - "reference": "127a46f6b057441b201253526f81d5406d6c7840", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", "shasum": "" }, "require": { - "php": "^7.3 || ^8.0" + "php": ">=7.3" }, "require-dev": { - "phpunit/phpunit": "^9.0" + "phpunit/phpunit": "^9.3" }, "type": "library", "extra": { @@ -10438,7 +10643,7 @@ "type": "github" } ], - "time": "2020-06-26T12:12:55+00:00" + "time": "2020-10-26T13:14:26+00:00" }, { "name": "sebastian/phpcpd", @@ -10493,23 +10698,23 @@ }, { "name": "sebastian/recursion-context", - "version": "4.0.2", + "version": "4.0.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "062231bf61d2b9448c4fa5a7643b5e1829c11d63" + "reference": "cd9d8cf3c5804de4341c283ed787f099f5506172" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/062231bf61d2b9448c4fa5a7643b5e1829c11d63", - "reference": "062231bf61d2b9448c4fa5a7643b5e1829c11d63", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/cd9d8cf3c5804de4341c283ed787f099f5506172", + "reference": "cd9d8cf3c5804de4341c283ed787f099f5506172", "shasum": "" }, "require": { - "php": "^7.3 || ^8.0" + "php": ">=7.3" }, "require-dev": { - "phpunit/phpunit": "^9.0" + "phpunit/phpunit": "^9.3" }, "type": "library", "extra": { @@ -10548,24 +10753,24 @@ "type": "github" } ], - "time": "2020-06-26T12:14:17+00:00" + "time": "2020-10-26T13:17:30+00:00" }, { "name": "sebastian/resource-operations", - "version": "3.0.2", + "version": "3.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/resource-operations.git", - "reference": "0653718a5a629b065e91f774595267f8dc32e213" + "reference": "0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/0653718a5a629b065e91f774595267f8dc32e213", - "reference": "0653718a5a629b065e91f774595267f8dc32e213", + "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8", + "reference": "0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8", "shasum": "" }, "require": { - "php": "^7.3 || ^8.0" + "php": ">=7.3" }, "require-dev": { "phpunit/phpunit": "^9.0" @@ -10599,32 +10804,32 @@ "type": "github" } ], - "time": "2020-06-26T12:16:22+00:00" + "time": "2020-09-28T06:45:17+00:00" }, { "name": "sebastian/type", - "version": "2.2.1", + "version": "2.3.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "86991e2b33446cd96e648c18bcdb1e95afb2c05a" + "reference": "81cd61ab7bbf2de744aba0ea61fae32f721df3d2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/86991e2b33446cd96e648c18bcdb1e95afb2c05a", - "reference": "86991e2b33446cd96e648c18bcdb1e95afb2c05a", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/81cd61ab7bbf2de744aba0ea61fae32f721df3d2", + "reference": "81cd61ab7bbf2de744aba0ea61fae32f721df3d2", "shasum": "" }, "require": { - "php": "^7.3 || ^8.0" + "php": ">=7.3" }, "require-dev": { - "phpunit/phpunit": "^9.2" + "phpunit/phpunit": "^9.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.2-dev" + "dev-master": "2.3-dev" } }, "autoload": { @@ -10651,24 +10856,24 @@ "type": "github" } ], - "time": "2020-07-05T08:31:53+00:00" + "time": "2020-10-26T13:18:59+00:00" }, { "name": "sebastian/version", - "version": "3.0.1", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/version.git", - "reference": "626586115d0ed31cb71483be55beb759b5af5a3c" + "reference": "c6c1022351a901512170118436c764e473f6de8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/626586115d0ed31cb71483be55beb759b5af5a3c", - "reference": "626586115d0ed31cb71483be55beb759b5af5a3c", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c", + "reference": "c6c1022351a901512170118436c764e473f6de8c", "shasum": "" }, "require": { - "php": "^7.3 || ^8.0" + "php": ">=7.3" }, "type": "library", "extra": { @@ -10700,7 +10905,7 @@ "type": "github" } ], - "time": "2020-06-26T12:18:43+00:00" + "time": "2020-09-28T06:39:44+00:00" }, { "name": "spomky-labs/otphp", @@ -10775,16 +10980,16 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "3.5.5", + "version": "3.5.8", "source": { "type": "git", "url": "https://github.com/squizlabs/PHP_CodeSniffer.git", - "reference": "73e2e7f57d958e7228fce50dc0c61f58f017f9f6" + "reference": "9d583721a7157ee997f235f327de038e7ea6dac4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/73e2e7f57d958e7228fce50dc0c61f58f017f9f6", - "reference": "73e2e7f57d958e7228fce50dc0c61f58f017f9f6", + "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/9d583721a7157ee997f235f327de038e7ea6dac4", + "reference": "9d583721a7157ee997f235f327de038e7ea6dac4", "shasum": "" }, "require": { @@ -10822,20 +11027,20 @@ "phpcs", "standards" ], - "time": "2020-04-17T01:09:41+00:00" + "time": "2020-10-23T02:01:07+00:00" }, { "name": "symfony/config", - "version": "v5.1.2", + "version": "v5.1.8", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "b8623ef3d99fe62a34baf7a111b576216965f880" + "reference": "11baeefa4c179d6908655a7b6be728f62367c193" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/b8623ef3d99fe62a34baf7a111b576216965f880", - "reference": "b8623ef3d99fe62a34baf7a111b576216965f880", + "url": "https://api.github.com/repos/symfony/config/zipball/11baeefa4c179d6908655a7b6be728f62367c193", + "reference": "11baeefa4c179d6908655a7b6be728f62367c193", "shasum": "" }, "require": { @@ -10859,11 +11064,6 @@ "symfony/yaml": "To use the yaml reference dumper" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "5.1-dev" - } - }, "autoload": { "psr-4": { "Symfony\\Component\\Config\\": "" @@ -10902,20 +11102,20 @@ "type": "tidelift" } ], - "time": "2020-05-23T13:08:13+00:00" + "time": "2020-10-24T12:01:57+00:00" }, { "name": "symfony/dependency-injection", - "version": "v5.1.2", + "version": "v5.1.8", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "6508423eded583fc07e88a0172803e1a62f0310c" + "reference": "829ca6bceaf68036a123a13a979f3c89289eae78" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/6508423eded583fc07e88a0172803e1a62f0310c", - "reference": "6508423eded583fc07e88a0172803e1a62f0310c", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/829ca6bceaf68036a123a13a979f3c89289eae78", + "reference": "829ca6bceaf68036a123a13a979f3c89289eae78", "shasum": "" }, "require": { @@ -10948,11 +11148,6 @@ "symfony/yaml": "" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "5.1-dev" - } - }, "autoload": { "psr-4": { "Symfony\\Component\\DependencyInjection\\": "" @@ -10991,20 +11186,20 @@ "type": "tidelift" } ], - "time": "2020-06-12T08:11:32+00:00" + "time": "2020-10-27T10:11:13+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v2.1.3", + "version": "v2.2.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "5e20b83385a77593259c9f8beb2c43cd03b2ac14" + "reference": "5fa56b4074d1ae755beb55617ddafe6f5d78f665" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/5e20b83385a77593259c9f8beb2c43cd03b2ac14", - "reference": "5e20b83385a77593259c9f8beb2c43cd03b2ac14", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/5fa56b4074d1ae755beb55617ddafe6f5d78f665", + "reference": "5fa56b4074d1ae755beb55617ddafe6f5d78f665", "shasum": "" }, "require": { @@ -11013,7 +11208,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "2.1-dev" + "dev-master": "2.2-dev" }, "thanks": { "name": "symfony/contracts", @@ -11055,20 +11250,20 @@ "type": "tidelift" } ], - "time": "2020-06-06T08:49:21+00:00" + "time": "2020-09-07T11:33:47+00:00" }, { "name": "symfony/http-foundation", - "version": "v5.1.2", + "version": "v5.1.8", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "f93055171b847915225bd5b0a5792888419d8d75" + "reference": "a2860ec970404b0233ab1e59e0568d3277d32b6f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/f93055171b847915225bd5b0a5792888419d8d75", - "reference": "f93055171b847915225bd5b0a5792888419d8d75", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/a2860ec970404b0233ab1e59e0568d3277d32b6f", + "reference": "a2860ec970404b0233ab1e59e0568d3277d32b6f", "shasum": "" }, "require": { @@ -11087,11 +11282,6 @@ "symfony/mime": "To use the file extension guesser" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "5.1-dev" - } - }, "autoload": { "psr-4": { "Symfony\\Component\\HttpFoundation\\": "" @@ -11130,20 +11320,20 @@ "type": "tidelift" } ], - "time": "2020-06-15T06:52:54+00:00" + "time": "2020-10-24T12:01:57+00:00" }, { "name": "symfony/mime", - "version": "v5.1.2", + "version": "v5.1.8", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "c0c418f05e727606e85b482a8591519c4712cf45" + "reference": "f5485a92c24d4bcfc2f3fc648744fb398482ff1b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/c0c418f05e727606e85b482a8591519c4712cf45", - "reference": "c0c418f05e727606e85b482a8591519c4712cf45", + "url": "https://api.github.com/repos/symfony/mime/zipball/f5485a92c24d4bcfc2f3fc648744fb398482ff1b", + "reference": "f5485a92c24d4bcfc2f3fc648744fb398482ff1b", "shasum": "" }, "require": { @@ -11160,11 +11350,6 @@ "symfony/dependency-injection": "^4.4|^5.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "5.1-dev" - } - }, "autoload": { "psr-4": { "Symfony\\Component\\Mime\\": "" @@ -11207,20 +11392,20 @@ "type": "tidelift" } ], - "time": "2020-06-09T15:07:35+00:00" + "time": "2020-10-24T12:01:57+00:00" }, { "name": "symfony/options-resolver", - "version": "v5.1.2", + "version": "v5.1.8", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "663f5dd5e14057d1954fe721f9709d35837f2447" + "reference": "c6a02905e4ffc7a1498e8ee019db2b477cd1cc02" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/663f5dd5e14057d1954fe721f9709d35837f2447", - "reference": "663f5dd5e14057d1954fe721f9709d35837f2447", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/c6a02905e4ffc7a1498e8ee019db2b477cd1cc02", + "reference": "c6a02905e4ffc7a1498e8ee019db2b477cd1cc02", "shasum": "" }, "require": { @@ -11229,11 +11414,6 @@ "symfony/polyfill-php80": "^1.15" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "5.1-dev" - } - }, "autoload": { "psr-4": { "Symfony\\Component\\OptionsResolver\\": "" @@ -11277,20 +11457,85 @@ "type": "tidelift" } ], - "time": "2020-05-23T13:08:13+00:00" + "time": "2020-10-24T12:01:57+00:00" + }, + { + "name": "symfony/polyfill-php70", + "version": "v1.20.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php70.git", + "reference": "5f03a781d984aae42cebd18e7912fa80f02ee644" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php70/zipball/5f03a781d984aae42cebd18e7912fa80f02ee644", + "reference": "5f03a781d984aae42cebd18e7912fa80f02ee644", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "metapackage", + "extra": { + "branch-alias": { + "dev-main": "1.20-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 7.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-10-23T14:02:19+00:00" }, { "name": "symfony/stopwatch", - "version": "v5.1.2", + "version": "v5.1.8", "source": { "type": "git", "url": "https://github.com/symfony/stopwatch.git", - "reference": "0f7c58cf81dbb5dd67d423a89d577524a2ec0323" + "reference": "3d9f57c89011f0266e6b1d469e5c0110513859d5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/0f7c58cf81dbb5dd67d423a89d577524a2ec0323", - "reference": "0f7c58cf81dbb5dd67d423a89d577524a2ec0323", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/3d9f57c89011f0266e6b1d469e5c0110513859d5", + "reference": "3d9f57c89011f0266e6b1d469e5c0110513859d5", "shasum": "" }, "require": { @@ -11298,11 +11543,6 @@ "symfony/service-contracts": "^1.0|^2" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "5.1-dev" - } - }, "autoload": { "psr-4": { "Symfony\\Component\\Stopwatch\\": "" @@ -11341,20 +11581,20 @@ "type": "tidelift" } ], - "time": "2020-05-20T17:43:50+00:00" + "time": "2020-10-24T12:01:57+00:00" }, { "name": "symfony/yaml", - "version": "v5.1.2", + "version": "v5.1.8", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "ea342353a3ef4f453809acc4ebc55382231d4d23" + "reference": "f284e032c3cefefb9943792132251b79a6127ca6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/ea342353a3ef4f453809acc4ebc55382231d4d23", - "reference": "ea342353a3ef4f453809acc4ebc55382231d4d23", + "url": "https://api.github.com/repos/symfony/yaml/zipball/f284e032c3cefefb9943792132251b79a6127ca6", + "reference": "f284e032c3cefefb9943792132251b79a6127ca6", "shasum": "" }, "require": { @@ -11375,11 +11615,6 @@ "Resources/bin/yaml-lint" ], "type": "library", - "extra": { - "branch-alias": { - "dev-master": "5.1-dev" - } - }, "autoload": { "psr-4": { "Symfony\\Component\\Yaml\\": "" @@ -11418,20 +11653,20 @@ "type": "tidelift" } ], - "time": "2020-05-20T17:43:50+00:00" + "time": "2020-10-24T12:03:25+00:00" }, { "name": "thecodingmachine/safe", - "version": "v1.1.3", + "version": "v1.3.3", "source": { "type": "git", "url": "https://github.com/thecodingmachine/safe.git", - "reference": "9f277171e296a3c8629c04ac93ec95ff0f208ccb" + "reference": "a8ab0876305a4cdaef31b2350fcb9811b5608dbc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/9f277171e296a3c8629c04ac93ec95ff0f208ccb", - "reference": "9f277171e296a3c8629c04ac93ec95ff0f208ccb", + "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/a8ab0876305a4cdaef31b2350fcb9811b5608dbc", + "reference": "a8ab0876305a4cdaef31b2350fcb9811b5608dbc", "shasum": "" }, "require": { @@ -11452,15 +11687,21 @@ "psr-4": { "Safe\\": [ "lib/", + "deprecated/", "generated/" ] }, "files": [ + "deprecated/apc.php", + "deprecated/libevent.php", + "deprecated/mssql.php", + "deprecated/stats.php", + "lib/special_cases.php", "generated/apache.php", - "generated/apc.php", "generated/apcu.php", "generated/array.php", "generated/bzip2.php", + "generated/calendar.php", "generated/classobj.php", "generated/com.php", "generated/cubrid.php", @@ -11489,14 +11730,12 @@ "generated/inotify.php", "generated/json.php", "generated/ldap.php", - "generated/libevent.php", "generated/libxml.php", "generated/lzf.php", "generated/mailparse.php", "generated/mbstring.php", "generated/misc.php", "generated/msql.php", - "generated/mssql.php", "generated/mysql.php", "generated/mysqli.php", "generated/mysqlndMs.php", @@ -11528,7 +11767,6 @@ "generated/sqlsrv.php", "generated/ssdeep.php", "generated/ssh2.php", - "generated/stats.php", "generated/stream.php", "generated/strings.php", "generated/swoole.php", @@ -11542,8 +11780,7 @@ "generated/yaml.php", "generated/yaz.php", "generated/zip.php", - "generated/zlib.php", - "lib/special_cases.php" + "generated/zlib.php" ] }, "notification-url": "https://packagist.org/downloads/", @@ -11551,7 +11788,7 @@ "MIT" ], "description": "PHP core functions that throw exceptions instead of returning FALSE on error", - "time": "2020-07-10T09:34:29+00:00" + "time": "2020-10-28T17:51:34+00:00" }, { "name": "theseer/fdomdocument", @@ -11822,6 +12059,5 @@ "ext-zip": "*", "lib-libxml": "*" }, - "platform-dev": [], - "plugin-api-version": "1.1.0" + "platform-dev": [] } diff --git a/dev/tests/acceptance/tests/_data/magento-logo_2.png b/dev/tests/acceptance/tests/_data/magento-logo_2.png new file mode 100644 index 0000000000000..24640e7e37e3d Binary files /dev/null and b/dev/tests/acceptance/tests/_data/magento-logo_2.png differ diff --git a/dev/tests/api-functional/_files/Magento/TestModuleDefaultHydrator/etc/extension_attributes.xml b/dev/tests/api-functional/_files/Magento/TestModuleDefaultHydrator/etc/extension_attributes.xml index 96dd60809754a..e1154f407e857 100644 --- a/dev/tests/api-functional/_files/Magento/TestModuleDefaultHydrator/etc/extension_attributes.xml +++ b/dev/tests/api-functional/_files/Magento/TestModuleDefaultHydrator/etc/extension_attributes.xml @@ -5,7 +5,7 @@ * See COPYING.txt for license details. */ --> -<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="../../../../../lib/internal/Magento/Framework/Api/etc/extension_attributes.xsd"> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Api/etc/extension_attributes.xsd"> <extension_attributes for="Magento\Customer\Api\Data\CustomerInterface"> <attribute code="extension_attribute" type="Magento\TestModuleDefaultHydrator\Api\Data\ExtensionAttributeInterface" /> </extension_attributes> diff --git a/dev/tests/api-functional/_files/Magento/TestModuleDefaultHydrator/etc/module.xml b/dev/tests/api-functional/_files/Magento/TestModuleDefaultHydrator/etc/module.xml index ca4ded8ff3190..a8acf9c4acc77 100644 --- a/dev/tests/api-functional/_files/Magento/TestModuleDefaultHydrator/etc/module.xml +++ b/dev/tests/api-functional/_files/Magento/TestModuleDefaultHydrator/etc/module.xml @@ -5,7 +5,7 @@ * See COPYING.txt for license details. */ --> -<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="../../../../../lib/internal/Magento/Framework/Module/etc/module.xsd"> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd"> <module name="Magento_TestModuleDefaultHydrator"> </module> </config> diff --git a/dev/tests/api-functional/framework/Magento/TestFramework/TestCase/Webapi/Adapter/Rest/DocumentationGenerator.php b/dev/tests/api-functional/framework/Magento/TestFramework/TestCase/Webapi/Adapter/Rest/DocumentationGenerator.php index 32f7f4aa3a8a6..945dc935d440c 100644 --- a/dev/tests/api-functional/framework/Magento/TestFramework/TestCase/Webapi/Adapter/Rest/DocumentationGenerator.php +++ b/dev/tests/api-functional/framework/Magento/TestFramework/TestCase/Webapi/Adapter/Rest/DocumentationGenerator.php @@ -23,7 +23,7 @@ class DocumentationGenerator public function generateDocumentation($httpMethod, $resourcePath, $arguments, $response) { $content = $this->generateHtmlContent($httpMethod, $resourcePath, $arguments, $response); - $filePath = $this->generateFileName($resourcePath); + $filePath = $this->generateFileName(); if ($filePath === null) { return; } diff --git a/dev/tests/api-functional/testsuite/Magento/Catalog/Api/CategoryLinkManagementTest.php b/dev/tests/api-functional/testsuite/Magento/Catalog/Api/CategoryLinkManagementTest.php index 629cc077a63ea..85509dabdf415 100644 --- a/dev/tests/api-functional/testsuite/Magento/Catalog/Api/CategoryLinkManagementTest.php +++ b/dev/tests/api-functional/testsuite/Magento/Catalog/Api/CategoryLinkManagementTest.php @@ -4,10 +4,15 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); + namespace Magento\Catalog\Api; use Magento\TestFramework\TestCase\WebapiAbstract; +/** + * Represents CategoryLinkManagementTest Class + */ class CategoryLinkManagementTest extends WebapiAbstract { const SERVICE_WRITE_NAME = 'catalogCategoryLinkManagementV1'; @@ -43,11 +48,21 @@ public function testInfoNoSuchEntityException() } } + /** + * @magentoApiDataFixture Magento/Catalog/_files/categories.php + */ + public function testDuplicatedProductsInChildCategories() + { + $result = $this->getAssignedProducts(3, 'all'); + $this->assertCount(3, $result); + } + /** * @param int $id category id - * @return string + * @param string|null $storeCode + * @return array|string */ - protected function getAssignedProducts($id) + private function getAssignedProducts(int $id, ?string $storeCode = null) { $serviceInfo = [ 'rest' => [ @@ -60,6 +75,6 @@ protected function getAssignedProducts($id) 'operation' => self::SERVICE_WRITE_NAME . 'GetAssignedProducts', ], ]; - return $this->_webApiCall($serviceInfo, ['categoryId' => $id]); + return $this->_webApiCall($serviceInfo, ['categoryId' => $id], null, $storeCode); } } diff --git a/dev/tests/api-functional/testsuite/Magento/Catalog/Api/CategoryRepositoryTest.php b/dev/tests/api-functional/testsuite/Magento/Catalog/Api/CategoryRepositoryTest.php index 461ab6c989104..5623edca62b9a 100644 --- a/dev/tests/api-functional/testsuite/Magento/Catalog/Api/CategoryRepositoryTest.php +++ b/dev/tests/api-functional/testsuite/Magento/Catalog/Api/CategoryRepositoryTest.php @@ -4,16 +4,22 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + namespace Magento\Catalog\Api; use Magento\Authorization\Model\Role; use Magento\Authorization\Model\RoleFactory; use Magento\Authorization\Model\Rules; use Magento\Authorization\Model\RulesFactory; +use Magento\Catalog\Api\Data\CategoryInterface; +use Magento\Catalog\Model\Attribute\ScopeOverriddenValue; +use Magento\Catalog\Model\Category; use Magento\CatalogUrlRewrite\Model\CategoryUrlRewriteGenerator; use Magento\Integration\Api\AdminTokenServiceInterface; +use Magento\Store\Model\Store; use Magento\TestFramework\Helper\Bootstrap; use Magento\TestFramework\TestCase\WebapiAbstract; +use Magento\UrlRewrite\Model\Storage\DbStorage; use Magento\UrlRewrite\Service\V1\Data\UrlRewrite; /** @@ -145,14 +151,14 @@ public function testCreate() */ public function testDelete() { - /** @var \Magento\UrlRewrite\Model\Storage\DbStorage $storage */ - $storage = Bootstrap::getObjectManager()->get(\Magento\UrlRewrite\Model\Storage\DbStorage::class); + /** @var DbStorage $storage */ + $storage = Bootstrap::getObjectManager()->get(DbStorage::class); $categoryId = $this->modelId; $data = [ UrlRewrite::ENTITY_ID => $categoryId, UrlRewrite::ENTITY_TYPE => CategoryUrlRewriteGenerator::ENTITY_TYPE ]; - /** @var \Magento\UrlRewrite\Service\V1\Data\UrlRewrite $urlRewrite*/ + /** @var \Magento\UrlRewrite\Service\V1\Data\UrlRewrite $urlRewrite */ $urlRewrite = $storage->findOneByData($data); // Assert that a url rewrite is auto-generated for the category created from the data fixture @@ -189,7 +195,7 @@ public function testDeleteSystemOrRoot() public function deleteSystemOrRootDataProvider() { return [ - [\Magento\Catalog\Model\Category::TREE_ROOT_ID], + [Category::TREE_ROOT_ID], [2] //Default root category ]; } @@ -212,8 +218,8 @@ public function testUpdate() ]; $result = $this->updateCategory($categoryId, $categoryData); $this->assertEquals($categoryId, $result['id']); - /** @var \Magento\Catalog\Model\Category $model */ - $model = Bootstrap::getObjectManager()->get(\Magento\Catalog\Model\Category::class); + /** @var Category $model */ + $model = Bootstrap::getObjectManager()->get(Category::class); $category = $model->load($categoryId); $this->assertFalse((bool)$category->getIsActive(), 'Category "is_active" must equal to false'); $this->assertEquals("Update Category Test", $category->getName()); @@ -240,8 +246,8 @@ public function testUpdateWithDefaultSortByAttribute() ]; $result = $this->updateCategory($categoryId, $categoryData); $this->assertEquals($categoryId, $result['id']); - /** @var \Magento\Catalog\Model\Category $model */ - $model = Bootstrap::getObjectManager()->get(\Magento\Catalog\Model\Category::class); + /** @var Category $model */ + $model = Bootstrap::getObjectManager()->get(Category::class); $category = $model->load($categoryId); $this->assertTrue((bool)$category->getIsActive(), 'Category "is_active" must equal to true'); $this->assertEquals("Update Category Test With default_sort_by Attribute", $category->getName()); @@ -249,6 +255,82 @@ public function testUpdateWithDefaultSortByAttribute() $this->createdCategories = [$categoryId]; } + /** + * @magentoApiDataFixture Magento/Catalog/_files/category.php + */ + public function testUpdateUrlKey() + { + $this->_markTestAsRestOnly('Functionality available in REST mode only.'); + + $categoryId = 333; + $categoryData = [ + 'name' => 'Update Category Test Old Name', + 'custom_attributes' => [ + [ + 'attribute_code' => 'url_key', + 'value' => "Update Category Test Old Name", + ], + ], + ]; + $result = $this->updateCategory($categoryId, $categoryData); + $this->assertEquals($categoryId, $result['id']); + + $categoryData = [ + 'name' => 'Update Category Test New Name', + 'custom_attributes' => [ + [ + 'attribute_code' => 'url_key', + 'value' => "Update Category Test New Name", + ], + [ + 'attribute_code' => 'save_rewrites_history', + 'value' => 1, + ], + ], + ]; + $result = $this->updateCategory($categoryId, $categoryData); + $this->assertEquals($categoryId, $result['id']); + /** @var Category $model */ + $model = Bootstrap::getObjectManager()->get(Category::class); + $category = $model->load($categoryId); + $this->assertEquals("Update Category Test New Name", $category->getName()); + + // check for the url rewrite for the new name + $storage = Bootstrap::getObjectManager()->get(DbStorage::class); + $data = [ + UrlRewrite::ENTITY_ID => $categoryId, + UrlRewrite::ENTITY_TYPE => CategoryUrlRewriteGenerator::ENTITY_TYPE, + UrlRewrite::REDIRECT_TYPE => 0, + ]; + + $urlRewrite = $storage->findOneByData($data); + + // Assert that a url rewrite is auto-generated for the category created from the data fixture + $this->assertNotNull($urlRewrite); + $this->assertEquals(1, $urlRewrite->getIsAutogenerated()); + $this->assertEquals($categoryId, $urlRewrite->getEntityId()); + $this->assertEquals(CategoryUrlRewriteGenerator::ENTITY_TYPE, $urlRewrite->getEntityType()); + $this->assertEquals('update-category-test-new-name.html', $urlRewrite->getRequestPath()); + + // check for the forward from the old name to the new name + $storage = Bootstrap::getObjectManager()->get(DbStorage::class); + $data = [ + UrlRewrite::ENTITY_ID => $categoryId, + UrlRewrite::ENTITY_TYPE => CategoryUrlRewriteGenerator::ENTITY_TYPE, + UrlRewrite::REDIRECT_TYPE => 301, + ]; + + $urlRewrite = $storage->findOneByData($data); + + $this->assertNotNull($urlRewrite); + $this->assertEquals(0, $urlRewrite->getIsAutogenerated()); + $this->assertEquals($categoryId, $urlRewrite->getEntityId()); + $this->assertEquals(CategoryUrlRewriteGenerator::ENTITY_TYPE, $urlRewrite->getEntityType()); + $this->assertEquals('update-category-test-old-name.html', $urlRewrite->getRequestPath()); + + $this->deleteCategory($categoryId); + } + protected function getSimpleCategoryData($categoryData = []) { return [ @@ -351,14 +433,9 @@ protected function updateCategory($id, $data, ?string $token = null) if ($token) { $serviceInfo['rest']['token'] = $serviceInfo['soap']['token'] = $token; } + $data['id'] = $id; - if (TESTS_WEB_API_ADAPTER == self::ADAPTER_SOAP) { - $data['id'] = $id; - return $this->_webApiCall($serviceInfo, ['id' => $id, 'category' => $data]); - } else { - $data['id'] = $id; - return $this->_webApiCall($serviceInfo, ['id' => $id, 'category' => $data]); - } + return $this->_webApiCall($serviceInfo, ['id' => $id, 'category' => $data]); } /** @@ -481,6 +558,82 @@ public function testSaveDesign(): void $this->createdCategories = [$result['id']]; } + /** + * Check if repository does not override default values for attributes out of request + * + * @magentoApiDataFixture Magento/Catalog/_files/category.php + */ + public function testUpdateScopeAttribute() + { + $categoryId = 333; + $categoryData = [ + 'name' => 'Scope Specific Value', + ]; + $result = $this->updateCategoryForSpecificStore($categoryId, $categoryData); + $this->assertEquals($categoryId, $result['id']); + + /** @var Category $model */ + $model = Bootstrap::getObjectManager()->get(Category::class); + $category = $model->load($categoryId); + + /** @var ScopeOverriddenValue $scopeOverriddenValue */ + $scopeOverriddenValue = Bootstrap::getObjectManager()->get(ScopeOverriddenValue::class); + self::assertTrue($scopeOverriddenValue->containsValue( + CategoryInterface::class, + $category, + 'name', + Store::DISTRO_STORE_ID + ), 'Name is not saved for specific store'); + self::assertFalse($scopeOverriddenValue->containsValue( + CategoryInterface::class, + $category, + 'is_active', + Store::DISTRO_STORE_ID + ), 'is_active is overridden for default store'); + self::assertFalse($scopeOverriddenValue->containsValue( + CategoryInterface::class, + $category, + 'url_key', + Store::DISTRO_STORE_ID + ), 'url_key is overridden for default store'); + + $this->deleteCategory($categoryId); + } + + /** + * Update given category via web API for specific store code. + * + * @param int $id + * @param array $data + * @param string|null $token + * @param string $storeCode + * @return array + */ + protected function updateCategoryForSpecificStore( + int $id, + array $data, + ?string $token = null, + string $storeCode = 'default' + ) { + $serviceInfo = [ + 'rest' => [ + 'resourcePath' => self::RESOURCE_PATH . '/' . $id, + 'httpMethod' => \Magento\Framework\Webapi\Rest\Request::HTTP_METHOD_PUT, + ], + 'soap' => [ + 'service' => self::SERVICE_NAME, + 'serviceVersion' => 'V1', + 'operation' => self::SERVICE_NAME . 'Save', + ], + ]; + if ($token) { + $serviceInfo['rest']['token'] = $serviceInfo['soap']['token'] = $token; + } + $data['id'] = $id; + + return $this->_webApiCall($serviceInfo, ['id' => $id, 'category' => $data], null, $storeCode); + } + /** * @inheritDoc * diff --git a/dev/tests/api-functional/testsuite/Magento/Catalog/Api/ProductAttributeRepositoryTest.php b/dev/tests/api-functional/testsuite/Magento/Catalog/Api/ProductAttributeRepositoryTest.php index d03152cc41e04..0ecaa272fdce9 100644 --- a/dev/tests/api-functional/testsuite/Magento/Catalog/Api/ProductAttributeRepositoryTest.php +++ b/dev/tests/api-functional/testsuite/Magento/Catalog/Api/ProductAttributeRepositoryTest.php @@ -87,12 +87,15 @@ public function testGetList() } /** + * Test create attribute + * + * @dataProvider attributeCodeDataProvider * @magentoApiDataFixture Magento/Catalog/Model/Product/Attribute/_files/create_attribute_service.php + * @param string $attributeCode * @return void */ - public function testCreate() + public function testCreate(string $attributeCode): void { - $attributeCode = uniqid('label_attr_code'); $attribute = $this->createAttribute($attributeCode); $expectedData = [ @@ -121,6 +124,17 @@ public function testCreate() $this->assertEquals('Default Red', $attribute['options'][2]['label']); } + /** + * @return array + */ + public function attributeCodeDataProvider(): array + { + return [ + [str_repeat('az_7', 15)], + [uniqid('label_attr_code')], + ]; + } + /** * @magentoApiDataFixture Magento/Catalog/_files/product_attribute.php * @return void diff --git a/dev/tests/api-functional/testsuite/Magento/Catalog/Api/ProductRepositoryAllStoreViewsTest.php b/dev/tests/api-functional/testsuite/Magento/Catalog/Api/ProductRepositoryAllStoreViewsTest.php new file mode 100644 index 0000000000000..fd815c6d2241b --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/Catalog/Api/ProductRepositoryAllStoreViewsTest.php @@ -0,0 +1,284 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Catalog\Api; + +use Magento\Catalog\Api\Data\ProductAttributeInterface; +use Magento\Catalog\Api\Data\ProductInterface; +use Magento\Catalog\Model\Product\Attribute\Source\Status; +use Magento\Catalog\Model\Product\Type; +use Magento\Catalog\Model\Product\Visibility; +use Magento\Catalog\Model\ResourceModel\Product\Website\Link; +use Magento\CatalogInventory\Api\Data\StockItemInterface; +use Magento\Eav\Model\Config; +use Magento\Framework\Exception\NoSuchEntityException; +use Magento\Framework\ObjectManagerInterface; +use Magento\Framework\Registry; +use Magento\Framework\Webapi\Rest\Request; +use Magento\Store\Model\StoreManagerInterface; +use Magento\TestFramework\Helper\Bootstrap; +use Magento\TestFramework\TestCase\WebapiAbstract; + +/** + * Tests for products creation for all store views. + * + * @magentoAppIsolation enabled + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ +class ProductRepositoryAllStoreViewsTest extends WebapiAbstract +{ + const PRODUCT_SERVICE_NAME = 'catalogProductRepositoryV1'; + const SERVICE_VERSION = 'V1'; + const PRODUCTS_RESOURCE_PATH = '/V1/products'; + + /** + * @var ObjectManagerInterface + */ + private $objectManager; + + /** + * @var ProductRepositoryInterface + */ + private $productRepository; + + /** + * @var Registry + */ + private $registry; + + /** + * @var StoreManagerInterface + */ + private $storeManager; + + /** + * @var Link + */ + private $productWebsiteLink; + + /** + * @var Config + */ + private $eavConfig; + + /** + * @var string + */ + private $productSku = 'simple'; + + /** + * @inheritdoc + */ + protected function setUp(): void + { + parent::setUp(); + $this->objectManager = Bootstrap::getObjectManager(); + $this->productRepository = $this->objectManager->get(ProductRepositoryInterface::class); + $this->productRepository->cleanCache(); + $this->eavConfig = $this->objectManager->get(Config::class); + $this->registry = $this->objectManager->get(Registry::class); + $this->storeManager = $this->objectManager->get(StoreManagerInterface::class); + $this->productWebsiteLink = $this->objectManager->get(Link::class); + } + + /** + * @inheritdoc + */ + protected function tearDown(): void + { + $this->registry->unregister('isSecureArea'); + $this->registry->register('isSecureArea', true); + try { + $this->productRepository->deleteById($this->productSku); + } catch (NoSuchEntityException $e) { + //already deleted + } + $this->registry->unregister('isSecureArea'); + $this->registry->register('isSecureArea', false); + + parent::tearDown(); + } + + /** + * @magentoApiDataFixture Magento/Catalog/_files/category.php + * @return void + */ + public function testCreateProduct(): void + { + $productData = $this->getProductData(); + $resultData = $this->saveProduct($productData); + $this->assertProductWebsites($this->productSku, $this->getAllWebsiteIds()); + $this->assertProductData($productData, $resultData, $this->getAllWebsiteIds()); + } + + /** + * @magentoApiDataFixture Magento/Catalog/_files/category.php + * @magentoApiDataFixture Magento/Store/_files/second_website_with_store_group_and_store.php + * @return void + */ + public function testCreateProductOnMultipleWebsites(): void + { + $productData = $this->getProductData(); + $resultData = $this->saveProduct($productData); + $this->assertProductWebsites($this->productSku, $this->getAllWebsiteIds()); + $this->assertProductData($productData, $resultData, $this->getAllWebsiteIds()); + } + + /** + * Saves product via API. + * + * @param array $product + * @return array + */ + private function saveProduct(array $product): array + { + $serviceInfo = [ + 'rest' => ['resourcePath' =>self::PRODUCTS_RESOURCE_PATH, 'httpMethod' => Request::HTTP_METHOD_POST], + 'soap' => [ + 'service' => self::PRODUCT_SERVICE_NAME, + 'serviceVersion' => self::SERVICE_VERSION, + 'operation' => self::PRODUCT_SERVICE_NAME . 'Save' + ] + ]; + $requestData = ['product' => $product]; + return $this->_webApiCall($serviceInfo, $requestData, null, 'all'); + } + + /** + * Returns product data. + * + * @return array + */ + private function getProductData(): array + { + $setId = (int)$this->eavConfig->getEntityType(ProductAttributeInterface::ENTITY_TYPE_CODE) + ->getDefaultAttributeSetId(); + + return [ + ProductInterface::SKU => $this->productSku, + ProductInterface::NAME => 'simple', + ProductInterface::TYPE_ID => Type::TYPE_SIMPLE, + ProductInterface::WEIGHT => 1, + ProductInterface::ATTRIBUTE_SET_ID => $setId, + ProductInterface::PRICE => 10, + ProductInterface::STATUS => Status::STATUS_ENABLED, + ProductInterface::VISIBILITY => Visibility::VISIBILITY_BOTH, + ProductInterface::EXTENSION_ATTRIBUTES_KEY => [ + 'stock_item' => [ + StockItemInterface::IS_IN_STOCK => 1, + StockItemInterface::QTY => 1000, + StockItemInterface::IS_QTY_DECIMAL => 0, + StockItemInterface::SHOW_DEFAULT_NOTIFICATION_MESSAGE => 0, + StockItemInterface::USE_CONFIG_MIN_QTY => 0, + StockItemInterface::USE_CONFIG_MIN_SALE_QTY => 0, + StockItemInterface::MIN_QTY => 1, + StockItemInterface::MIN_SALE_QTY => 1, + StockItemInterface::MAX_SALE_QTY => 100, + StockItemInterface::USE_CONFIG_MAX_SALE_QTY => 0, + StockItemInterface::USE_CONFIG_BACKORDERS => 0, + StockItemInterface::BACKORDERS => 0, + StockItemInterface::USE_CONFIG_NOTIFY_STOCK_QTY => 0, + StockItemInterface::NOTIFY_STOCK_QTY => 0, + StockItemInterface::USE_CONFIG_QTY_INCREMENTS => 0, + StockItemInterface::QTY_INCREMENTS => 0, + StockItemInterface::USE_CONFIG_ENABLE_QTY_INC => 0, + StockItemInterface::ENABLE_QTY_INCREMENTS => 0, + StockItemInterface::USE_CONFIG_MANAGE_STOCK => 1, + StockItemInterface::MANAGE_STOCK => 1, + StockItemInterface::LOW_STOCK_DATE => null, + StockItemInterface::IS_DECIMAL_DIVIDED => 0, + StockItemInterface::STOCK_STATUS_CHANGED_AUTO => 0, + ], + ], + ProductInterface::CUSTOM_ATTRIBUTES => [ + ['attribute_code' => 'url_key', 'value' => 'simple'], + ['attribute_code' => 'tax_class_id', 'value' => 2], + ['attribute_code' => 'category_ids', 'value' => [333]] + ] + ]; + } + + /** + * Asserts that product is linked to websites in 'catalog_product_website' table. + * + * @param string $sku + * @param array $websiteIds + * @return void + */ + private function assertProductWebsites(string $sku, array $websiteIds): void + { + $productId = $this->productRepository->get($sku)->getId(); + $this->assertEquals($websiteIds, $this->productWebsiteLink->getWebsiteIdsByProductId($productId)); + } + + /** + * Asserts result after product creation. + * + * @param array $productData + * @param array $resultData + * @param array $websiteIds + * @return void + */ + private function assertProductData(array $productData, array $resultData, array $websiteIds): void + { + foreach ($productData as $key => $value) { + if ($key == 'extension_attributes' || $key == 'custom_attributes') { + continue; + } + $this->assertEquals($value, $resultData[$key]); + } + foreach ($productData['custom_attributes'] as $attribute) { + $resultAttribute = $this->getCustomAttributeByCode( + $resultData['custom_attributes'], + $attribute['attribute_code'] + ); + if ($attribute['attribute_code'] == 'category_ids') { + $this->assertEquals(array_values($attribute['value']), array_values($resultAttribute['value'])); + continue; + } + $this->assertEquals($attribute['value'], $resultAttribute['value']); + } + foreach ($productData['extension_attributes']['stock_item'] as $key => $value) { + $this->assertEquals($value, $resultData['extension_attributes']['stock_item'][$key]); + } + $this->assertEquals($websiteIds, $resultData['extension_attributes']['website_ids']); + } + + /** + * Get list of all websites IDs. + * + * @return array + */ + private function getAllWebsiteIds(): array + { + $websiteIds = []; + foreach ($this->storeManager->getWebsites() as $website) { + $websiteIds[] = $website->getId(); + } + + return $websiteIds; + } + + /** + * Returns custom attribute data by given code. + * + * @param array $attributes + * @param string $attributeCode + * @return array + */ + private function getCustomAttributeByCode(array $attributes, string $attributeCode): array + { + $items = array_filter( + $attributes, + function ($attribute) use ($attributeCode) { + return $attribute['attribute_code'] == $attributeCode; + } + ); + + return reset($items); + } +} diff --git a/dev/tests/api-functional/testsuite/Magento/Catalog/Api/ProductRepositoryInterfaceTest.php b/dev/tests/api-functional/testsuite/Magento/Catalog/Api/ProductRepositoryInterfaceTest.php index 1fcfe79f39478..1b18949b0ac5b 100644 --- a/dev/tests/api-functional/testsuite/Magento/Catalog/Api/ProductRepositoryInterfaceTest.php +++ b/dev/tests/api-functional/testsuite/Magento/Catalog/Api/ProductRepositoryInterfaceTest.php @@ -12,6 +12,7 @@ use Magento\Authorization\Model\Rules; use Magento\Authorization\Model\RulesFactory; use Magento\Catalog\Api\Data\ProductInterface; +use Magento\Catalog\Model\ResourceModel\Product\Gallery; use Magento\CatalogInventory\Api\Data\StockItemInterface; use Magento\Downloadable\Api\DomainManagerInterface; use Magento\Downloadable\Model\Link; @@ -23,7 +24,9 @@ use Magento\Framework\Exception\NoSuchEntityException; use Magento\Framework\Webapi\Exception as HTTPExceptionCodes; use Magento\Integration\Api\AdminTokenServiceInterface; +use Magento\Store\Api\Data\StoreInterface; use Magento\Store\Model\Store; +use Magento\Store\Model\StoreRepository; use Magento\Store\Model\Website; use Magento\Store\Model\WebsiteRepository; use Magento\TestFramework\Helper\Bootstrap; @@ -34,6 +37,7 @@ * * @magentoAppIsolation enabled * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) */ class ProductRepositoryInterfaceTest extends WebapiAbstract { @@ -77,6 +81,10 @@ class ProductRepositoryInterfaceTest extends WebapiAbstract * @var AdminTokenServiceInterface */ private $adminTokens; + /** + * @var array + */ + private $fixtureProducts = []; /** * @inheritDoc @@ -98,6 +106,7 @@ protected function setUp(): void */ protected function tearDown(): void { + $this->deleteFixtureProducts(); parent::tearDown(); $objectManager = Bootstrap::getObjectManager(); @@ -214,6 +223,7 @@ private function loadWebsiteByCode($websiteCode) try { $website = $websiteRepository->get($websiteCode); } catch (NoSuchEntityException $e) { + $website = null; $this->fail("Couldn`t load website: {$websiteCode}"); } @@ -685,14 +695,15 @@ public function testProductOptions() */ public function testProductWithMediaGallery() { - $testImagePath = __DIR__ . DIRECTORY_SEPARATOR . '_files' . DIRECTORY_SEPARATOR . 'test_image.jpg'; - // @codingStandardsIgnoreLine - $encodedImage = base64_encode(file_get_contents($testImagePath)); + $encodedImage = $this->getTestImage(); //create a product with media gallery $filename1 = 'tiny1' . time() . '.jpg'; $filename2 = 'tiny2' . time() . '.jpeg'; $productData = $this->getSimpleProductData(); - $productData['media_gallery_entries'] = $this->getMediaGalleryData($filename1, $encodedImage, $filename2); + $productData['media_gallery_entries'] = [ + $this->getMediaGalleryData($filename1, $encodedImage, 1, 'tiny1', true), + $this->getMediaGalleryData($filename2, $encodedImage, 2, 'tiny2', false), + ]; $response = $this->saveProduct($productData); $this->assertArrayHasKey('media_gallery_entries', $response); $mediaGalleryEntries = $response['media_gallery_entries']; @@ -1595,38 +1606,33 @@ public function testUpdateProductCategoryLinksUnassign() /** * Get media gallery data * - * @param $filename1 - * @param $encodedImage - * @param $filename2 + * @param string $filename + * @param string $encodedImage + * @param int $position + * @param string $label + * @param bool $disabled + * @param array $types * @return array */ - private function getMediaGalleryData($filename1, $encodedImage, $filename2) - { + private function getMediaGalleryData( + string $filename, + string $encodedImage, + int $position, + string $label, + bool $disabled = false, + array $types = [] + ): array { return [ - [ - 'position' => 1, - 'media_type' => 'image', - 'disabled' => true, - 'label' => 'tiny1', - 'types' => [], - 'content' => [ - 'type' => 'image/jpeg', - 'name' => $filename1, - 'base64_encoded_data' => $encodedImage, - ] - ], - [ - 'position' => 2, - 'media_type' => 'image', - 'disabled' => false, - 'label' => 'tiny2', - 'types' => [], - 'content' => [ - 'type' => 'image/jpeg', - 'name' => $filename2, - 'base64_encoded_data' => $encodedImage, - ] - ], + 'position' => $position, + 'media_type' => 'image', + 'disabled' => $disabled, + 'label' => $label, + 'types' => $types, + 'content' => [ + 'type' => 'image/jpeg', + 'name' => $filename, + 'base64_encoded_data' => $encodedImage, + ] ]; } @@ -1901,4 +1907,196 @@ public function testSaveDesign(): void //We don't have permissions to do that. $this->assertEquals('Not allowed to edit the product\'s design attributes', $exceptionMessage); } + + /** + * @magentoApiDataFixture Magento/Store/_files/second_store.php + */ + public function testImageRolesWithMultipleStores() + { + $this->_markTestAsRestOnly( + 'Test skipped due to known issue with SOAP. NULL value is cast to corresponding attribute type.' + ); + $productData = $this->getSimpleProductData(); + $sku = $productData[ProductInterface::SKU]; + $defaultScope = Store::DEFAULT_STORE_ID; + $defaultWebsiteId = $this->loadWebsiteByCode('base')->getId(); + $defaultStoreId = $this->loadStoreByCode('default')->getId(); + $secondStoreId = $this->loadStoreByCode('fixture_second_store')->getId(); + $encodedImage = $this->getTestImage(); + $imageRoles = ['image', 'small_image', 'thumbnail']; + $img1 = uniqid('/t/e/test_image1_') . '.jpg'; + $img2 = uniqid('/t/e/test_image2_') . '.jpg'; + $productData['media_gallery_entries'] = [ + $this->getMediaGalleryData(basename($img1), $encodedImage, 1, 'front', false, ['image']), + $this->getMediaGalleryData(basename($img2), $encodedImage, 2, 'back', false, ['small_image', 'thumbnail']), + ]; + $productData[ProductInterface::EXTENSION_ATTRIBUTES_KEY]['website_ids'] = [ + $defaultWebsiteId + ]; + $response = $this->saveProduct($productData, 'all'); + if (isset($response['id'])) { + $this->fixtureProducts[] = $sku; + } + $imageRolesPerStore = $this->getProductStoreImageRoles( + $sku, + [$defaultScope, $defaultStoreId, $secondStoreId], + $imageRoles + ); + $this->assertEquals($img1, $imageRolesPerStore[$defaultScope]['image']); + $this->assertEquals($img2, $imageRolesPerStore[$defaultScope]['small_image']); + $this->assertEquals($img2, $imageRolesPerStore[$defaultScope]['thumbnail']); + $this->assertArrayNotHasKey($defaultStoreId, $imageRolesPerStore); + $this->assertArrayNotHasKey($secondStoreId, $imageRolesPerStore); + /** + * Override image roles for default store + */ + $storeProductData = $response; + $storeProductData['media_gallery_entries'][0]['types'] = ['image', 'small_image', 'thumbnail']; + $storeProductData['media_gallery_entries'][1]['types'] = []; + $this->saveProduct($storeProductData, 'default'); + $imageRolesPerStore = $this->getProductStoreImageRoles( + $sku, + [$defaultScope, $defaultStoreId, $secondStoreId], + $imageRoles + ); + $this->assertEquals($img1, $imageRolesPerStore[$defaultScope]['image']); + $this->assertEquals($img2, $imageRolesPerStore[$defaultScope]['small_image']); + $this->assertEquals($img2, $imageRolesPerStore[$defaultScope]['thumbnail']); + $this->assertEquals($img1, $imageRolesPerStore[$defaultStoreId]['image']); + $this->assertEquals($img1, $imageRolesPerStore[$defaultStoreId]['small_image']); + $this->assertEquals($img1, $imageRolesPerStore[$defaultStoreId]['thumbnail']); + $this->assertArrayNotHasKey($secondStoreId, $imageRolesPerStore); + /** + * Inherit image roles from default scope + */ + $customAttributes = $this->convertCustomAttributesToAssociativeArray($response['custom_attributes']); + $customAttributes['image'] = null; + $customAttributes['small_image'] = null; + $customAttributes['thumbnail'] = null; + $customAttributes = $this->convertAssociativeArrayToCustomAttributes($customAttributes); + $storeProductData = $response; + $storeProductData['media_gallery_entries'] = null; + $storeProductData['custom_attributes'] = $customAttributes; + $this->saveProduct($storeProductData, 'default'); + $imageRolesPerStore = $this->getProductStoreImageRoles( + $sku, + [$defaultScope, $defaultStoreId, $secondStoreId], + $imageRoles + ); + $this->assertEquals($img1, $imageRolesPerStore[$defaultScope]['image']); + $this->assertEquals($img2, $imageRolesPerStore[$defaultScope]['small_image']); + $this->assertEquals($img2, $imageRolesPerStore[$defaultScope]['thumbnail']); + $this->assertArrayNotHasKey($defaultStoreId, $imageRolesPerStore); + $this->assertArrayNotHasKey($secondStoreId, $imageRolesPerStore); + } + + /** + * Test that updating product image with same image name will result in incremented image name + */ + public function testUpdateProductWithMediaGallery(): void + { + $productData = $this->getSimpleProductData(); + $sku = $productData[ProductInterface::SKU]; + $defaultScope = Store::DEFAULT_STORE_ID; + $defaultWebsiteId = $this->loadWebsiteByCode('base')->getId(); + $encodedImage = $this->getTestImage(); + $imageRoles = ['image', 'small_image', 'thumbnail']; + $img1 = uniqid('/t/e/test_image1_') . '.jpg'; + $img2 = uniqid('/t/e/test_image2_') . '.jpg'; + $productData['media_gallery_entries'] = [ + $this->getMediaGalleryData(basename($img1), $encodedImage, 1, 'front', false, ['image']), + $this->getMediaGalleryData(basename($img2), $encodedImage, 2, 'back', false, ['small_image', 'thumbnail']), + ]; + $productData[ProductInterface::EXTENSION_ATTRIBUTES_KEY]['website_ids'] = [ + $defaultWebsiteId + ]; + $response = $this->saveProduct($productData, 'all'); + if (isset($response['id'])) { + $this->fixtureProducts[] = $sku; + } + $imageRolesPerStore = $this->getProductStoreImageRoles($sku, [$defaultScope], $imageRoles); + $this->assertEquals($img1, $imageRolesPerStore[$defaultScope]['image']); + $this->assertEquals($img2, $imageRolesPerStore[$defaultScope]['small_image']); + $this->assertEquals($img2, $imageRolesPerStore[$defaultScope]['thumbnail']); + $this->saveProduct($productData, 'all'); + $imageRolesPerStore = $this->getProductStoreImageRoles($sku, [$defaultScope], $imageRoles); + $img1 = substr_replace($img1, '_1', -4, 0); + $img2 = substr_replace($img2, '_1', -4, 0); + $this->assertEquals($img1, $imageRolesPerStore[$defaultScope]['image']); + $this->assertEquals($img2, $imageRolesPerStore[$defaultScope]['small_image']); + $this->assertEquals($img2, $imageRolesPerStore[$defaultScope]['thumbnail']); + } + + /** + * @return string + */ + private function getTestImage(): string + { + $testImagePath = __DIR__ . DIRECTORY_SEPARATOR . '_files' . DIRECTORY_SEPARATOR . 'test_image.jpg'; + // @codingStandardsIgnoreLine + return base64_encode(file_get_contents($testImagePath)); + } + + /** + * @return void + */ + private function deleteFixtureProducts(): void + { + foreach ($this->fixtureProducts as $sku) { + $this->deleteProduct($sku); + } + $this->fixtureProducts = []; + } + + /** + * @param string $code + * @return StoreInterface + */ + private function loadStoreByCode(string $code): StoreInterface + { + try { + $store = Bootstrap::getObjectManager()->get(StoreRepository::class)->get($code); + } catch (NoSuchEntityException $e) { + $store = null; + $this->fail("Couldn`t load store: {$code}"); + } + return $store; + } + + /** + * @param string $sku + * @param int|null $storeId + * @return ProductInterface + */ + private function getProductModel(string $sku, int $storeId = null): ProductInterface + { + try { + $productRepository = Bootstrap::getObjectManager()->get(ProductRepositoryInterface::class); + $product = $productRepository->get($sku, false, $storeId, true); + } catch (NoSuchEntityException $e) { + $product = null; + $this->fail("Couldn`t load product: {$sku}"); + } + return $product; + } + + /** + * @param string $sku + * @param array $stores + * @param array $roles + * @return array + */ + private function getProductStoreImageRoles(string $sku, array $stores, array $roles = []): array + { + /** @var Gallery $galleryResource */ + $galleryResource = Bootstrap::getObjectManager()->get(Gallery::class); + $productModel = $this->getProductModel($sku); + $imageRolesPerStore = []; + foreach ($galleryResource->getProductImages($productModel, $stores) as $role) { + if (empty($roles) || in_array($role['attribute_code'], $roles)) { + $imageRolesPerStore[$role['store_id']][$role['attribute_code']] = $role['filepath']; + } + } + return $imageRolesPerStore; + } } diff --git a/dev/tests/api-functional/testsuite/Magento/Cms/Api/PageRepositoryTest.php b/dev/tests/api-functional/testsuite/Magento/Cms/Api/PageRepositoryTest.php index 757530c4da693..8751f2a39921d 100644 --- a/dev/tests/api-functional/testsuite/Magento/Cms/Api/PageRepositoryTest.php +++ b/dev/tests/api-functional/testsuite/Magento/Cms/Api/PageRepositoryTest.php @@ -3,6 +3,8 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); + namespace Magento\Cms\Api; use Magento\Authorization\Model\Role; @@ -11,15 +13,20 @@ use Magento\Authorization\Model\RulesFactory; use Magento\Cms\Api\Data\PageInterface; use Magento\Cms\Api\Data\PageInterfaceFactory; +use Magento\Cms\Model\ResourceModel\Page as PageResource; +use Magento\Cms\Ui\Component\DataProvider as CmsDataProvider; use Magento\Framework\Api\DataObjectHelper; use Magento\Framework\Api\FilterBuilder; use Magento\Framework\Api\SearchCriteriaBuilder; use Magento\Framework\Api\SortOrder; use Magento\Framework\Api\SortOrderBuilder; +use Magento\Framework\Exception\NoSuchEntityException; use Magento\Framework\Reflection\DataObjectProcessor; use Magento\Framework\Webapi\Rest\Request; use Magento\Integration\Api\AdminTokenServiceInterface; +use Magento\Store\Model\StoreManagerInterface; use Magento\TestFramework\Helper\Bootstrap; +use Magento\TestFramework\ObjectManager; use Magento\TestFramework\TestCase\WebapiAbstract; /** @@ -79,22 +86,59 @@ class PageRepositoryTest extends WebapiAbstract private $adminTokens; /** - * @var array + * @var PageInterface[] */ private $createdPages = []; + /** + * @var ObjectManager + */ + private $objectManager; + + /** + * @var StoreManagerInterface + */ + private $storeManager; + + /** + * @var FilterBuilder + */ + private $filterBuilder; + + /** + * @var CmsDataProvider + */ + private $cmsUiDataProvider; + + /** + * @var PageResource + */ + private $pageResource; + /** * @inheritdoc */ protected function setUp(): void { - $this->pageFactory = Bootstrap::getObjectManager()->create(PageInterfaceFactory::class); - $this->pageRepository = Bootstrap::getObjectManager()->create(PageRepositoryInterface::class); - $this->dataObjectHelper = Bootstrap::getObjectManager()->create(DataObjectHelper::class); - $this->dataObjectProcessor = Bootstrap::getObjectManager()->create(DataObjectProcessor::class); - $this->roleFactory = Bootstrap::getObjectManager()->get(RoleFactory::class); - $this->rulesFactory = Bootstrap::getObjectManager()->get(RulesFactory::class); - $this->adminTokens = Bootstrap::getObjectManager()->get(AdminTokenServiceInterface::class); + $this->objectManager = Bootstrap::getObjectManager(); + $this->pageFactory = $this->objectManager->create(PageInterfaceFactory::class); + $this->pageRepository = $this->objectManager->create(PageRepositoryInterface::class); + $this->dataObjectHelper = $this->objectManager->create(DataObjectHelper::class); + $this->dataObjectProcessor = $this->objectManager->create(DataObjectProcessor::class); + $this->roleFactory = $this->objectManager->get(RoleFactory::class); + $this->rulesFactory = $this->objectManager->get(RulesFactory::class); + $this->adminTokens = $this->objectManager->get(AdminTokenServiceInterface::class); + $this->storeManager = $this->objectManager->get(StoreManagerInterface::class); + $this->filterBuilder = $this->objectManager->get(FilterBuilder::class); + $this->cmsUiDataProvider = $this->objectManager->create( + CmsDataProvider::class, + [ + 'name' => 'cms_page_listing_data_source', + 'primaryFieldName' => 'page_id', + 'requestFieldName' => 'id', + ] + ); + $this->pageResource = $this->objectManager->get(PageResource::class); } /** @@ -108,7 +152,9 @@ protected function tearDown(): void } foreach ($this->createdPages as $page) { - $this->pageRepository->delete($page); + if ($page->getId()) { + $this->pageRepository->delete($page); + } } } @@ -127,17 +173,11 @@ public function testGet(): void ->setIdentifier($pageIdentifier); $this->currentPage = $this->pageRepository->save($pageDataObject); - $serviceInfo = [ - 'rest' => [ - 'resourcePath' => self::RESOURCE_PATH . '/' . $this->currentPage->getId(), - 'httpMethod' => Request::HTTP_METHOD_GET, - ], - 'soap' => [ - 'service' => self::SERVICE_NAME, - 'serviceVersion' => self::SERVICE_VERSION, - 'operation' => self::SERVICE_NAME . 'GetById', - ], - ]; + $serviceInfo = $this->getServiceInfo( + 'GetById', + Request::HTTP_METHOD_GET, + self::RESOURCE_PATH . '/' . $this->currentPage->getId() + ); $page = $this->_webApiCall($serviceInfo, [PageInterface::PAGE_ID => $this->currentPage->getId()]); $this->assertNotNull($page['id']); @@ -147,6 +187,36 @@ public function testGet(): void $this->assertEquals($pageData->getIdentifier(), $pageIdentifier); } + /** + * @dataProvider byStoresProvider + * @magentoApiDataFixture Magento/Cms/_files/pages.php + * @magentoApiDataFixture Magento/Store/_files/second_website_with_store_group_and_store.php + * @param string $requestStore + * @return void + */ + public function testGetByStores(string $requestStore): void + { + $newStoreId = $this->getStoreIdByRequestStore($requestStore); + $this->updatePage('page100', 0, ['store_id' => $newStoreId]); + $page = $this->loadPageByIdentifier('page100', $newStoreId); + $expectedData = array_intersect_key( + $this->dataObjectProcessor->buildOutputDataArray($page, PageInterface::class), + $this->getPageRequestData()['page'] + ); + $serviceInfo = $this->getServiceInfo( + 'GetById', + Request::HTTP_METHOD_GET, + self::RESOURCE_PATH . '/' . $page->getId() + ); + $requestData = []; + if (TESTS_WEB_API_ADAPTER === self::ADAPTER_SOAP) { + $requestData[PageInterface::PAGE_ID] = $page->getId(); + } + + $page = $this->_webApiCall($serviceInfo, $requestData, null, $requestStore); + $this->assertResponseData($page, $expectedData); + } + /** * Test create page * @@ -161,17 +231,7 @@ public function testCreate(): void $pageDataObject->setTitle($pageTitle) ->setIdentifier($pageIdentifier); - $serviceInfo = [ - 'rest' => [ - 'resourcePath' => self::RESOURCE_PATH, - 'httpMethod' => Request::HTTP_METHOD_POST, - ], - 'soap' => [ - 'service' => self::SERVICE_NAME, - 'serviceVersion' => self::SERVICE_VERSION, - 'operation' => self::SERVICE_NAME . 'Save', - ], - ]; + $serviceInfo = $this->getServiceInfo('Save', Request::HTTP_METHOD_POST); $requestData = [ 'page' => [ @@ -187,10 +247,37 @@ public function testCreate(): void $this->assertEquals($this->currentPage->getIdentifier(), $pageIdentifier); } + /** + * @dataProvider byStoresProvider + * @magentoApiDataFixture Magento/Store/_files/second_website_with_store_group_and_store.php + * @param string $requestStore + * @return void + */ + public function testCreateByStores(string $requestStore): void + { + $newStoreId = $this->getStoreIdByRequestStore($requestStore); + $serviceInfo = $this->getServiceInfo('Save', Request::HTTP_METHOD_POST); + $requestData = $this->getPageRequestData(); + + $page = $this->_webApiCall($serviceInfo, $requestData, null, $requestStore); + $this->createdPages[] = $this->loadPageByIdentifier( + $requestData['page'][PageInterface::IDENTIFIER], + $newStoreId + ); + $this->assertResponseData($page, $requestData['page']); + $pageGridData = $this->getPageGridDataByStoreCode($requestStore); + $this->assertTrue( + $this->isPageInArray($pageGridData['items'], $page['id']), + sprintf('The "%s" page is missing from the "%s" store', $page['title'], $requestStore) + ); + } + /** * Test update \Magento\Cms\Api\Data\PageInterface + * + * @return void */ - public function testUpdate() + public function testUpdate(): void { $pageTitle = self::PAGE_TITLE; $newPageTitle = self::PAGE_TITLE_NEW; @@ -210,17 +297,10 @@ public function testUpdate() PageInterface::class ); - $serviceInfo = [ - 'rest' => [ - 'resourcePath' => self::RESOURCE_PATH, - 'httpMethod' => Request::HTTP_METHOD_POST, - ], - 'soap' => [ - 'service' => self::SERVICE_NAME, - 'serviceVersion' => self::SERVICE_VERSION, - 'operation' => self::SERVICE_NAME . 'Save', - ], - ]; + $serviceInfo = $this->getServiceInfo( + 'Save', + Request::HTTP_METHOD_POST + ); $page = $this->_webApiCall($serviceInfo, ['page' => $pageData]); $this->assertNotNull($page['id']); @@ -249,17 +329,11 @@ public function testUpdateOneField(): void $this->currentPage = $this->pageRepository->save($pageDataObject); $pageId = $this->currentPage->getId(); - $serviceInfo = [ - 'rest' => [ - 'resourcePath' => self::RESOURCE_PATH . '/' . $pageId, - 'httpMethod' => Request::HTTP_METHOD_PUT, - ], - 'soap' => [ - 'service' => self::SERVICE_NAME, - 'serviceVersion' => self::SERVICE_VERSION, - 'operation' => self::SERVICE_NAME . 'Save', - ], - ]; + $serviceInfo = $this->getServiceInfo( + 'Save', + Request::HTTP_METHOD_PUT, + self::RESOURCE_PATH . '/' . $pageId + ); $data = [ 'page' => [ @@ -283,12 +357,45 @@ public function testUpdateOneField(): void $this->assertEquals($page['content'], $content); } + /** + * @dataProvider byStoresProvider + * @magentoApiDataFixture Magento/Cms/_files/pages.php + * @magentoApiDataFixture Magento/Store/_files/second_website_with_store_group_and_store.php + * @param string $requestStore + * @return void + */ + public function testUpdateByStores(string $requestStore): void + { + $newStoreId = $this->getStoreIdByRequestStore($requestStore); + $page = $this->updatePage('page100', 0, ['store_id' => $newStoreId]); + $serviceInfo = $this->getServiceInfo( + 'Save', + Request::HTTP_METHOD_PUT, + self::RESOURCE_PATH . '/' . $page->getId() + ); + $requestData = $this->getPageRequestData(); + + $page = $this->_webApiCall($serviceInfo, $requestData, null, $requestStore); + $this->createdPages[] = $this->loadPageByIdentifier( + $requestData['page'][PageInterface::IDENTIFIER], + $newStoreId + ); + $this->assertResponseData($page, $requestData['page']); + $pageGridData = $this->getPageGridDataByStoreCode($requestStore); + $this->assertTrue( + $this->isPageInArray($pageGridData['items'], $page['id']), + sprintf('The "%s" page is missing from the "%s" store', $page['title'], $requestStore) + ); + } + /** * Test delete \Magento\Cms\Api\Data\PageInterface + * + * @return void */ - public function testDelete() + public function testDelete(): void { - $this->expectException(\Magento\Framework\Exception\NoSuchEntityException::class); + $this->expectException(NoSuchEntityException::class); $pageTitle = self::PAGE_TITLE; $pageIdentifier = self::PAGE_IDENTIFIER_PREFIX . uniqid(); @@ -298,34 +405,60 @@ public function testDelete() ->setIdentifier($pageIdentifier); $this->currentPage = $this->pageRepository->save($pageDataObject); - $serviceInfo = [ - 'rest' => [ - 'resourcePath' => self::RESOURCE_PATH . '/' . $this->currentPage->getId(), - 'httpMethod' => Request::HTTP_METHOD_DELETE, - ], - 'soap' => [ - 'service' => self::SERVICE_NAME, - 'serviceVersion' => self::SERVICE_VERSION, - 'operation' => self::SERVICE_NAME . 'DeleteById', - ], - ]; + $serviceInfo = $this->getServiceInfo( + 'DeleteById', + Request::HTTP_METHOD_DELETE, + self::RESOURCE_PATH . '/' . $this->currentPage->getId() + ); $this->_webApiCall($serviceInfo, [PageInterface::PAGE_ID => $this->currentPage->getId()]); $this->pageRepository->getById($this->currentPage['id']); } + /** + * @dataProvider byStoresProvider + * @magentoApiDataFixture Magento/Cms/_files/pages.php + * @magentoApiDataFixture Magento/Store/_files/second_website_with_store_group_and_store.php + * @param string $requestStore + * @return void + */ + public function testDeleteByStores(string $requestStore): void + { + $newStoreId = $this->getStoreIdByRequestStore($requestStore); + $page = $this->updatePage('page100', 0, ['store_id' => $newStoreId]); + $serviceInfo = $this->getServiceInfo( + 'DeleteById', + Request::HTTP_METHOD_DELETE, + self::RESOURCE_PATH . '/' . $page->getId() + ); + $requestData = []; + if (TESTS_WEB_API_ADAPTER === self::ADAPTER_SOAP) { + $requestData[PageInterface::PAGE_ID] = $page->getId(); + } + + $pageResponse = $this->_webApiCall($serviceInfo, $requestData, null, $requestStore); + $this->assertTrue($pageResponse); + $pageGridData = $this->getPageGridDataByStoreCode($requestStore); + $this->assertFalse( + $this->isPageInArray($pageGridData['items'], (int)$page->getId()), + sprintf('The "%s" page should not be present on the "%s" store', $page->getTitle(), $requestStore) + ); + } + /** * Test search \Magento\Cms\Api\Data\PageInterface + * + * @return void */ - public function testSearch() + public function testSearch(): void { $cmsPages = $this->prepareCmsPages(); /** @var FilterBuilder $filterBuilder */ - $filterBuilder = Bootstrap::getObjectManager()->create(FilterBuilder::class); + $filterBuilder = $this->objectManager->create(FilterBuilder::class); /** @var SearchCriteriaBuilder $searchCriteriaBuilder */ - $searchCriteriaBuilder = Bootstrap::getObjectManager() + $searchCriteriaBuilder = $this->objectManager ->create(SearchCriteriaBuilder::class); $filter1 = $filterBuilder @@ -351,7 +484,7 @@ public function testSearch() $searchCriteriaBuilder->addFilters([$filter3, $filter4]); /** @var SortOrderBuilder $sortOrderBuilder */ - $sortOrderBuilder = Bootstrap::getObjectManager()->create(SortOrderBuilder::class); + $sortOrderBuilder = $this->objectManager->create(SortOrderBuilder::class); /** @var SortOrder $sortOrder */ $sortOrder = $sortOrderBuilder->setField(PageInterface::IDENTIFIER) @@ -365,17 +498,11 @@ public function testSearch() $searchData = $searchCriteriaBuilder->create()->__toArray(); $requestData = ['searchCriteria' => $searchData]; - $serviceInfo = [ - 'rest' => [ - 'resourcePath' => self::RESOURCE_PATH . "/search" . '?' . http_build_query($requestData), - 'httpMethod' => Request::HTTP_METHOD_GET, - ], - 'soap' => [ - 'service' => self::SERVICE_NAME, - 'serviceVersion' => self::SERVICE_VERSION, - 'operation' => self::SERVICE_NAME . 'GetList', - ], - ]; + $serviceInfo = $this->getServiceInfo( + 'GetList', + Request::HTTP_METHOD_GET, + self::RESOURCE_PATH . "/search" . '?' . http_build_query($requestData) + ); $searchResult = $this->_webApiCall($serviceInfo, $requestData); $this->assertEquals(2, $searchResult['total_count']); @@ -388,8 +515,10 @@ public function testSearch() /** * Create page with the same identifier after one was removed. + * + * @return void */ - public function testCreateSamePage() + public function testCreateSamePage(): void { $pageIdentifier = self::PAGE_IDENTIFIER_PREFIX . uniqid(); @@ -399,10 +528,30 @@ public function testCreateSamePage() $this->currentPage = $this->pageRepository->getById($id); } + /** + * Get stores for CRUD operations + * + * @return array + */ + public function byStoresProvider(): array + { + return [ + 'default_store' => [ + 'request_store' => 'default', + ], + 'second_store' => [ + 'request_store' => 'fixture_second_store', + ], + 'all' => [ + 'request_store' => 'all', + ], + ]; + } + /** * @return PageInterface[] */ - private function prepareCmsPages() + private function prepareCmsPages(): array { $result = []; @@ -435,21 +584,11 @@ private function prepareCmsPages() /** * Create page with hard-coded identifier to test with create-delete-create flow. * @param string $identifier - * @return string + * @return int */ - private function createPageWithIdentifier($identifier) + private function createPageWithIdentifier($identifier): int { - $serviceInfo = [ - 'rest' => [ - 'resourcePath' => self::RESOURCE_PATH, - 'httpMethod' => Request::HTTP_METHOD_POST, - ], - 'soap' => [ - 'service' => self::SERVICE_NAME, - 'serviceVersion' => self::SERVICE_VERSION, - 'operation' => self::SERVICE_NAME . 'Save', - ], - ]; + $serviceInfo = $this->getServiceInfo('Save', Request::HTTP_METHOD_POST); $requestData = [ 'page' => [ PageInterface::IDENTIFIER => $identifier, @@ -466,19 +605,13 @@ private function createPageWithIdentifier($identifier) * @param string $pageId * @return void */ - private function deletePageByIdentifier($pageId) + private function deletePageByIdentifier($pageId): void { - $serviceInfo = [ - 'rest' => [ - 'resourcePath' => self::RESOURCE_PATH . '/' . $pageId, - 'httpMethod' => Request::HTTP_METHOD_DELETE, - ], - 'soap' => [ - 'service' => self::SERVICE_NAME, - 'serviceVersion' => self::SERVICE_VERSION, - 'operation' => self::SERVICE_NAME . 'DeleteById', - ], - ]; + $serviceInfo = $this->getServiceInfo( + 'DeleteById', + Request::HTTP_METHOD_DELETE, + self::RESOURCE_PATH . '/' . $pageId + ); $this->_webApiCall($serviceInfo, [PageInterface::PAGE_ID => $pageId]); } @@ -499,7 +632,7 @@ public function testSaveDesign(): void /** @var Rules $rules */ $rules = $this->rulesFactory->create(); $rules->setRoleId($role->getId()); - $rules->setResources(['Magento_Cms::page']); + $rules->setResources(['Magento_Cms::save']); $rules->saveRel(); //Using the admin user with custom role. $token = $this->adminTokens->createAdminAccessToken( @@ -547,9 +680,9 @@ public function testSaveDesign(): void //Updating the user role to allow access to design properties. /** @var Rules $rules */ - $rules = Bootstrap::getObjectManager()->create(Rules::class); + $rules = $this->objectManager->create(Rules::class); $rules->setRoleId($role->getId()); - $rules->setResources(['Magento_Cms::page', 'Magento_Cms::save_design']); + $rules->setResources(['Magento_Cms::save', 'Magento_Cms::save_design']); $rules->saveRel(); //Making the same request with design settings. $result = $this->_webApiCall($serviceInfo, $requestData); @@ -562,9 +695,9 @@ public function testSaveDesign(): void //Updating our role to remove design properties access. /** @var Rules $rules */ - $rules = Bootstrap::getObjectManager()->create(Rules::class); + $rules = $this->objectManager->create(Rules::class); $rules->setRoleId($role->getId()); - $rules->setResources(['Magento_Cms::page']); + $rules->setResources(['Magento_Cms::save']); $rules->saveRel(); //Updating the page but with the same design properties values. $result = $this->_webApiCall($serviceInfo, $requestData); @@ -587,4 +720,146 @@ public function testSaveDesign(): void //We don't have permissions to do that. $this->assertEquals('You are not allowed to change CMS pages design settings', $exceptionMessage); } + + /** + * Get service info array + * + * @param string $soapOperation + * @param string $httpMethod + * @param string $resourcePath + * @return array + */ + private function getServiceInfo( + string $soapOperation, + string $httpMethod, + string $resourcePath = self::RESOURCE_PATH + ): array { + return [ + 'rest' => [ + 'resourcePath' => $resourcePath, + 'httpMethod' => $httpMethod, + ], + 'soap' => [ + 'service' => self::SERVICE_NAME, + 'serviceVersion' => self::SERVICE_VERSION, + 'operation' => self::SERVICE_NAME . $soapOperation, + ], + ]; + } + + /** + * Check that the page is in the page grid data + * + * @param array $pageGridData + * @param int $pageId + * @return bool + */ + private function isPageInArray(array $pageGridData, int $pageId): bool + { + $isPagePresent = false; + foreach ($pageGridData as $pageData) { + if ($pageData['page_id'] == $pageId) { + $isPagePresent = true; + break; + } + } + + return $isPagePresent; + } + + /** + * Update page with data + * + * @param string $pageIdentifier + * @param int $storeId + * @param array $pageData + * @return PageInterface + */ + private function updatePage(string $pageIdentifier, int $storeId, array $pageData): PageInterface + { + $page = $this->loadPageByIdentifier($pageIdentifier, $storeId); + $page->addData($pageData); + + return $this->pageRepository->save($page); + } + + /** + * Get request data for create or update page + * + * @return array + */ + private function getPageRequestData(): array + { + return [ + 'page' => [ + PageInterface::IDENTIFIER => self::PAGE_IDENTIFIER_PREFIX . uniqid(), + PageInterface::TITLE => self::PAGE_TITLE . uniqid(), + 'active' => true, + PageInterface::PAGE_LAYOUT => '1column', + PageInterface::CONTENT => self::PAGE_CONTENT, + ] + ]; + } + + /** + * Get store id by request store code + * + * @param string $requestStoreCode + * @return int + */ + private function getStoreIdByRequestStore(string $requestStoreCode): int + { + $storeCode = $requestStoreCode === 'all' ? 'admin' : $requestStoreCode; + $store = $this->storeManager->getStore($storeCode); + + return (int)$store->getId(); + } + + /** + * Check that the response data is as expected + * + * @param array $page + * @param array $expectedData + * @return void + */ + private function assertResponseData(array $page, array $expectedData): void + { + $this->assertNotNull($page['id']); + $actualData = array_intersect_key($page, $expectedData); + $this->assertEquals($expectedData, $actualData, 'Response data does not match expected.'); + } + + /** + * Get page grid data of cms ui dataprovider filtering by store code + * + * @param string $requestStore + * @return array + */ + private function getPageGridDataByStoreCode(string $requestStore): array + { + if ($requestStore !== 'all') { + $store = $this->storeManager->getStore($requestStore); + $this->cmsUiDataProvider->addFilter( + $this->filterBuilder->setField('store_id')->setValue($store->getId())->create() + ); + } + + return $this->cmsUiDataProvider->getData(); + } + + /** + * Load page by identifier and store id + * + * @param string $identifier + * @param int $storeId + * @return PageInterface + */ + private function loadPageByIdentifier(string $identifier, int $storeId): PageInterface + { + $page = $this->pageFactory->create(); + $page->setStoreId($storeId); + $this->pageResource->load($page, $identifier, PageInterface::IDENTIFIER); + + return $page; + } } diff --git a/dev/tests/api-functional/testsuite/Magento/Customer/Api/CustomerRepositoryTest.php b/dev/tests/api-functional/testsuite/Magento/Customer/Api/CustomerRepositoryTest.php index e1fb9e29105b9..f7732b5fd68dd 100644 --- a/dev/tests/api-functional/testsuite/Magento/Customer/Api/CustomerRepositoryTest.php +++ b/dev/tests/api-functional/testsuite/Magento/Customer/Api/CustomerRepositoryTest.php @@ -7,19 +7,26 @@ namespace Magento\Customer\Api; use Magento\Customer\Api\Data\AddressInterface as Address; -use Magento\Customer\Api\Data\CustomerInterface as Customer; use Magento\Customer\Api\Data\CustomerInterfaceFactory; use Magento\Customer\Model\CustomerRegistry; use Magento\Framework\Api\DataObjectHelper; +use Magento\Customer\Api\Data\CustomerInterface as Customer; use Magento\Framework\Api\FilterBuilder; +use Magento\Framework\Api\Search\FilterGroupBuilder; +use Magento\Framework\Api\SearchCriteriaBuilder; use Magento\Framework\Api\SearchCriteriaInterface; use Magento\Framework\Api\SortOrder; +use Magento\Framework\Api\SortOrderBuilder; use Magento\Framework\Exception\InputException; use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\Reflection\DataObjectProcessor; use Magento\Framework\Exception\NoSuchEntityException; use Magento\Framework\Webapi\Exception as HTTPExceptionCodes; use Magento\Framework\Webapi\Rest\Request; use Magento\Integration\Api\CustomerTokenServiceInterface; +use Magento\Integration\Api\IntegrationServiceInterface; +use Magento\Integration\Api\OauthServiceInterface; +use Magento\Integration\Model\Integration; use Magento\TestFramework\Helper\Bootstrap; use Magento\TestFramework\Helper\Customer as CustomerHelper; use Magento\TestFramework\TestCase\WebapiAbstract; @@ -92,34 +99,20 @@ class CustomerRepositoryTest extends WebapiAbstract */ protected function setUp(): void { - $this->customerRegistry = Bootstrap::getObjectManager()->get( - \Magento\Customer\Model\CustomerRegistry::class - ); + $this->customerRegistry = Bootstrap::getObjectManager()->get(CustomerRegistry::class); $this->customerRepository = Bootstrap::getObjectManager()->get( - \Magento\Customer\Api\CustomerRepositoryInterface::class, + CustomerRepositoryInterface::class, ['customerRegistry' => $this->customerRegistry] ); - $this->dataObjectHelper = Bootstrap::getObjectManager()->create( - \Magento\Framework\Api\DataObjectHelper::class - ); - $this->customerDataFactory = Bootstrap::getObjectManager()->create( - \Magento\Customer\Api\Data\CustomerInterfaceFactory::class - ); - $this->searchCriteriaBuilder = Bootstrap::getObjectManager()->create( - \Magento\Framework\Api\SearchCriteriaBuilder::class - ); - $this->sortOrderBuilder = Bootstrap::getObjectManager()->create( - \Magento\Framework\Api\SortOrderBuilder::class - ); - $this->filterGroupBuilder = Bootstrap::getObjectManager()->create( - \Magento\Framework\Api\Search\FilterGroupBuilder::class - ); + $this->dataObjectHelper = Bootstrap::getObjectManager()->create(DataObjectHelper::class); + $this->customerDataFactory = Bootstrap::getObjectManager()->create(CustomerInterfaceFactory::class); + $this->searchCriteriaBuilder = Bootstrap::getObjectManager()->create(SearchCriteriaBuilder::class); + $this->sortOrderBuilder = Bootstrap::getObjectManager()->create(SortOrderBuilder::class); + $this->filterGroupBuilder = Bootstrap::getObjectManager()->create(FilterGroupBuilder::class); $this->customerHelper = new CustomerHelper(); - $this->dataObjectProcessor = Bootstrap::getObjectManager()->create( - \Magento\Framework\Reflection\DataObjectProcessor::class - ); + $this->dataObjectProcessor = Bootstrap::getObjectManager()->create(DataObjectProcessor::class); } protected function tearDown(): void @@ -174,7 +167,11 @@ public function testInvalidCustomerUpdate() $lastName = $existingCustomerDataObject->getLastname(); $customerData[Customer::LASTNAME] = $lastName . 'Updated'; $newCustomerDataObject = $this->customerDataFactory->create(); - $this->dataObjectHelper->populateWithArray($newCustomerDataObject, $customerData, Customer::class); + $this->dataObjectHelper->populateWithArray( + $newCustomerDataObject, + $customerData, + Customer::class + ); $serviceInfo = [ 'rest' => [ @@ -198,6 +195,31 @@ public function testInvalidCustomerUpdate() $this->_webApiCall($serviceInfo, $requestData); } + /** + * Create Integration and return token. + * + * @param string $name + * @param array $resource + * @return string + */ + private function createIntegrationToken(string $name, array $resource): string + { + /** @var IntegrationServiceInterface $integrationService */ + $integrationService = Bootstrap::getObjectManager()->get(IntegrationServiceInterface::class); + $oauthService = Bootstrap::getObjectManager()->get(OauthServiceInterface::class); + /** @var Integration $integration */ + $integration = $integrationService->create( + [ + 'name' => $name, + 'resource' => $resource, + ] + ); + /** @var OauthServiceInterface $oauthService */ + $oauthService->createAccessToken($integration->getConsumerId()); + + return $integrationService->get($integration->getId())->getToken(); + } + public function testDeleteCustomer() { $customerData = $this->_createCustomer(); @@ -223,11 +245,56 @@ public function testDeleteCustomer() $this->assertTrue($response); //Verify if the customer is deleted - $this->expectException(\Magento\Framework\Exception\NoSuchEntityException::class); + $this->expectException(NoSuchEntityException::class); $this->expectExceptionMessage(sprintf("No such entity with customerId = %s", $customerData[Customer::ID])); $this->getCustomerData($customerData[Customer::ID]); } + /** + * Check that non authorized consumer can`t delete customer. + * + * @return void + */ + public function testDeleteCustomerNonAuthorized(): void + { + $resource = [ + 'Magento_Customer::customer', + 'Magento_Customer::manage', + ]; + $token = $this->createIntegrationToken('TestAPI' . bin2hex(random_bytes(5)), $resource); + + $customerData = $this->_createCustomer(); + $this->currentCustomerId = []; + + $serviceInfo = [ + 'rest' => [ + 'resourcePath' => self::RESOURCE_PATH . '/' . $customerData[Customer::ID], + 'httpMethod' => Request::HTTP_METHOD_DELETE, + 'token' => $token, + ], + 'soap' => [ + 'service' => self::SERVICE_NAME, + 'serviceVersion' => self::SERVICE_VERSION, + 'operation' => self::SERVICE_NAME . 'DeleteById', + 'token' => $token, + ], + ]; + try { + $this->_webApiCall($serviceInfo, ['customerId' => $customerData['id']]); + $this->fail("Expected exception is not thrown."); + } catch (\SoapFault $e) { + } catch (\Exception $e) { + $expectedMessage = 'The consumer isn\'t authorized to access %resources.'; + $errorObj = $this->processRestExceptionResult($e); + $this->assertEquals($expectedMessage, $errorObj['message']); + $this->assertEquals(['resources' => 'Magento_Customer::delete'], $errorObj['parameters']); + $this->assertEquals(HTTPExceptionCodes::HTTP_UNAUTHORIZED, $e->getCode()); + } + /** @var Customer $data */ + $data = $this->getCustomerData($customerData[Customer::ID]); + $this->assertNotNull($data->getId()); + } + /** * Test delete customer with invalid id * @@ -643,7 +710,7 @@ public function subscriptionDataProvider(): array public function testSearchCustomersUsingGET() { $this->_markTestAsRestOnly('SOAP test is covered in testSearchCustomers'); - $builder = Bootstrap::getObjectManager()->create(\Magento\Framework\Api\FilterBuilder::class); + $builder = Bootstrap::getObjectManager()->create(FilterBuilder::class); $customerData = $this->_createCustomer(); $filter = $builder ->setField(Customer::EMAIL) @@ -697,7 +764,7 @@ public function testSearchCustomersUsingGETEmptyFilter() */ public function testSearchCustomersMultipleFiltersWithSort() { - $builder = Bootstrap::getObjectManager()->create(\Magento\Framework\Api\FilterBuilder::class); + $builder = Bootstrap::getObjectManager()->create(FilterBuilder::class); $customerData1 = $this->_createCustomer(); $customerData2 = $this->_createCustomer(); $filter1 = $builder->setField(Customer::EMAIL) @@ -714,7 +781,7 @@ public function testSearchCustomersMultipleFiltersWithSort() /**@var \Magento\Framework\Api\SortOrderBuilder $sortOrderBuilder */ $sortOrderBuilder = Bootstrap::getObjectManager()->create( - \Magento\Framework\Api\SortOrderBuilder::class + SortOrderBuilder::class ); /** @var SortOrder $sortOrder */ $sortOrder = $sortOrderBuilder->setField(Customer::EMAIL)->setDirection(SortOrder::SORT_ASC)->create(); @@ -746,7 +813,7 @@ public function testSearchCustomersMultipleFiltersWithSort() public function testSearchCustomersMultipleFiltersWithSortUsingGET() { $this->_markTestAsRestOnly('SOAP test is covered in testSearchCustomers'); - $builder = Bootstrap::getObjectManager()->create(\Magento\Framework\Api\FilterBuilder::class); + $builder = Bootstrap::getObjectManager()->create(FilterBuilder::class); $customerData1 = $this->_createCustomer(); $customerData2 = $this->_createCustomer(); $filter1 = $builder->setField(Customer::EMAIL) @@ -782,7 +849,7 @@ public function testSearchCustomersMultipleFiltersWithSortUsingGET() */ public function testSearchCustomersNonExistentMultipleFilters() { - $builder = Bootstrap::getObjectManager()->create(\Magento\Framework\Api\FilterBuilder::class); + $builder = Bootstrap::getObjectManager()->create(FilterBuilder::class); $customerData1 = $this->_createCustomer(); $customerData2 = $this->_createCustomer(); $filter1 = $filter1 = $builder->setField(Customer::EMAIL) @@ -820,7 +887,7 @@ public function testSearchCustomersNonExistentMultipleFilters() public function testSearchCustomersNonExistentMultipleFiltersGET() { $this->_markTestAsRestOnly('SOAP test is covered in testSearchCustomers'); - $builder = Bootstrap::getObjectManager()->create(\Magento\Framework\Api\FilterBuilder::class); + $builder = Bootstrap::getObjectManager()->create(FilterBuilder::class); $customerData1 = $this->_createCustomer(); $customerData2 = $this->_createCustomer(); $filter1 = $filter1 = $builder->setField(Customer::EMAIL) @@ -856,7 +923,7 @@ public function testSearchCustomersMultipleFilterGroups() $customerData1 = $this->_createCustomer(); /** @var \Magento\Framework\Api\FilterBuilder $builder */ - $builder = Bootstrap::getObjectManager()->create(\Magento\Framework\Api\FilterBuilder::class); + $builder = Bootstrap::getObjectManager()->create(FilterBuilder::class); $filter1 = $builder->setField(Customer::EMAIL) ->setValue($customerData1[Customer::EMAIL]) ->create(); diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/CategoriesQuery/CategoriesFilterTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/CategoriesQuery/CategoriesFilterTest.php index a3daf89631c17..09f39bf1441f5 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/CategoriesQuery/CategoriesFilterTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/CategoriesQuery/CategoriesFilterTest.php @@ -395,8 +395,9 @@ public function testMinimumMatchQueryLength() * Test category image full name is returned * * @magentoApiDataFixture Magento/Catalog/_files/catalog_category_with_long_image_name.php + * @magentoConfigFixture default_store web/seo/use_rewrites 0 */ - public function testCategoryImageName() + public function testCategoryImageNameAndSeoDisabled() { /** @var CategoryCollection $categoryCollection */ $categoryCollection = Bootstrap::getObjectManager()->get(CategoryCollection::class); @@ -427,14 +428,13 @@ public function testCategoryImageName() $categories = $response['categories']; $this->assertArrayNotHasKey('errors', $response); $this->assertNotEmpty($response['categories']['items']); - $expectedImageUrl = str_replace('index.php/', '', $expectedImageUrl); - $categories['items'][0]['image'] = str_replace('index.php/', '', $categories['items'][0]['image']); $this->assertEquals('Parent Image Category', $categories['items'][0]['name']); $this->assertEquals($expectedImageUrl, $categories['items'][0]['image']); } /** * @magentoApiDataFixture Magento/Catalog/_files/categories.php + * @magentoConfigFixture default_store web/seo/use_rewrites 1 */ public function testFilterByUrlPathTopLevelCategory() { diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/CategoriesQuery/CategoryTreeTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/CategoriesQuery/CategoryTreeTest.php index c2e82e734cd9b..dbbeaebc15936 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/CategoriesQuery/CategoryTreeTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/CategoriesQuery/CategoryTreeTest.php @@ -11,9 +11,11 @@ use Magento\Catalog\Api\ProductRepositoryInterface; use Magento\Catalog\Model\CategoryRepository; use Magento\Catalog\Model\ResourceModel\Category\Collection as CategoryCollection; +use Magento\Framework\App\ResourceConnection; use Magento\Framework\EntityManager\MetadataPool; use Magento\Store\Model\Store; use Magento\Store\Model\StoreManagerInterface; +use Magento\TestFramework\Helper\Bootstrap; use Magento\TestFramework\ObjectManager; use Magento\TestFramework\TestCase\GraphQlAbstract; @@ -564,10 +566,12 @@ public function testCategoryImage(?string $imagePrefix) ->addAttributeToFilter('name', ['eq' => 'Parent Image Category']) ->getFirstItem(); $categoryId = $categoryModel->getId(); + /** @var ResourceConnection $resourceConnection */ + $resourceConnection = Bootstrap::getObjectManager()->create(ResourceConnection::class); + $connection = $resourceConnection->getConnection(); if ($imagePrefix !== null) { // update image to account for different stored image formats - $connection = $categoryCollection->getConnection(); $productLinkField = $this->metadataPool ->getMetadata(\Magento\Catalog\Api\Data\ProductInterface::class) ->getLinkField(); @@ -577,20 +581,20 @@ public function testCategoryImage(?string $imagePrefix) $imageAttributeValue = $imagePrefix . basename($categoryModel->getImage()); if (!empty($imageAttributeValue)) { - $query = sprintf( + $sqlQuery = sprintf( 'UPDATE %s SET `value` = "%s" ' . 'WHERE `%s` = %d ' . 'AND `store_id`= %d ' . 'AND `attribute_id` = ' . '(SELECT `ea`.`attribute_id` FROM %s ea WHERE `ea`.`attribute_code` = "image" LIMIT 1)', - $connection->getTableName('catalog_category_entity_varchar'), + $resourceConnection->getTableName('catalog_category_entity_varchar'), $imageAttributeValue, $productLinkField, $categoryModel->getData($productLinkField), $defaultStoreId, - $connection->getTableName('eav_attribute') + $resourceConnection->getTableName('eav_attribute') ); - $connection->query($query); + $connection->query($sqlQuery); } } @@ -645,7 +649,7 @@ public function categoryImageDataProvider(): array 'image_prefix' => '' ], 'with_pub_media_strategy' => [ - 'image_prefix' => '/pub/media/catalog/category/' + 'image_prefix' => '/media/catalog/category/' ], 'catalog_category_strategy' => [ 'image_prefix' => 'catalog/category/' diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/CategoryTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/CategoryTest.php index 463b07c7261a6..72b014fd39f0e 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/CategoryTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/CategoryTest.php @@ -12,16 +12,20 @@ use Magento\Catalog\Api\ProductRepositoryInterface; use Magento\Catalog\Model\CategoryRepository; use Magento\Catalog\Model\ResourceModel\Category\Collection as CategoryCollection; +use Magento\Framework\App\ResourceConnection; use Magento\Framework\DataObject; use Magento\Framework\EntityManager\MetadataPool; use Magento\Store\Model\Store; use Magento\Store\Model\StoreManagerInterface; +use Magento\TestFramework\Helper\Bootstrap; use Magento\TestFramework\ObjectManager; use Magento\TestFramework\TestCase\GraphQl\ResponseContainsErrorsException; use Magento\TestFramework\TestCase\GraphQlAbstract; /** * Test loading of category tree + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class CategoryTest extends GraphQlAbstract { @@ -47,7 +51,7 @@ class CategoryTest extends GraphQlAbstract protected function setUp(): void { - $this->objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); + $this->objectManager = Bootstrap::getObjectManager(); $this->categoryRepository = $this->objectManager->get(CategoryRepository::class); $this->store = $this->objectManager->get(Store::class); $this->metadataPool = $this->objectManager->get(MetadataPool::class); @@ -587,9 +591,12 @@ public function testCategoryImage(?string $imagePrefix) ->getFirstItem(); $categoryId = $categoryModel->getId(); + /** @var ResourceConnection $resourceConnection */ + $resourceConnection = Bootstrap::getObjectManager()->create(ResourceConnection::class); + $connection = $resourceConnection->getConnection(); + if ($imagePrefix !== null) { - // update image to account for different stored image formats - $connection = $categoryCollection->getConnection(); + // update image to account for different stored image format $productLinkField = $this->metadataPool ->getMetadata(\Magento\Catalog\Api\Data\ProductInterface::class) ->getLinkField(); @@ -599,20 +606,20 @@ public function testCategoryImage(?string $imagePrefix) $imageAttributeValue = $imagePrefix . basename($categoryModel->getImage()); if (!empty($imageAttributeValue)) { - $query = sprintf( + $sqlQuery = sprintf( 'UPDATE %s SET `value` = "%s" ' . 'WHERE `%s` = %d ' . 'AND `store_id`= %d ' . 'AND `attribute_id` = ' . '(SELECT `ea`.`attribute_id` FROM %s ea WHERE `ea`.`attribute_code` = "image" LIMIT 1)', - $connection->getTableName('catalog_category_entity_varchar'), + $resourceConnection->getTableName('catalog_category_entity_varchar'), $imageAttributeValue, $productLinkField, $categoryModel->getData($productLinkField), $defaultStoreId, - $connection->getTableName('eav_attribute') + $resourceConnection->getTableName('eav_attribute') ); - $connection->query($query); + $connection->query($sqlQuery); } } @@ -653,6 +660,45 @@ public function testCategoryImage(?string $imagePrefix) $this->assertEquals($expectedImageUrl, $childCategory['image']); } + /** + * Testing breadcrumbs that shouldn't include disabled parent categories + * + * @magentoApiDataFixture Magento/Catalog/_files/categories.php + */ + public function testBreadCrumbsWithDisabledParentCategory() + { + $parentCategoryId = 4; + $childCategoryId = 5; + $category = $this->categoryRepository->get($parentCategoryId); + $category->setIsActive(false); + $this->categoryRepository->save($category); + + $query = <<<QUERY +{ + category(id: {$childCategoryId}) { + name + breadcrumbs { + category_id + category_name + } + } +} +QUERY; + $response = $this->graphQlQuery($query); + $expectedResponse = [ + 'category' => [ + 'name' => 'Category 1.1.1', + 'breadcrumbs' => [ + [ + 'category_id' => 3, + 'category_name' => "Category 1", + ] + ] + ] + ]; + $this->assertEquals($expectedResponse, $response); + } + /** * @return array */ @@ -666,7 +712,7 @@ public function categoryImageDataProvider(): array 'image_prefix' => '' ], 'with_pub_media_strategy' => [ - 'image_prefix' => '/pub/media/catalog/category/' + 'image_prefix' => '/media/catalog/category/' ], 'catalog_category_strategy' => [ 'image_prefix' => 'catalog/category/' diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductAttributeStoreTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductAttributeStoreTest.php new file mode 100644 index 0000000000000..8cb7cec1b9a12 --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductAttributeStoreTest.php @@ -0,0 +1,61 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\GraphQl\Catalog; + +use Exception; +use Magento\Framework\Exception\LocalizedException; +use Magento\TestFramework\TestCase\GraphQlAbstract; + +class ProductAttributeStoreTest extends GraphQlAbstract +{ + /** + * Test that custom attribute labels are returned respecting store + * + * @magentoApiDataFixture Magento/Store/_files/store.php + * @magentoApiDataFixture Magento/Catalog/_files/products_with_layered_navigation_attribute_store_options.php + * @throws LocalizedException + */ + public function testAttributeStoreLabels(): void + { + $this->attributeLabelTest('Test Configurable Default Store'); + $this->attributeLabelTest('Test Configurable Test Store', ['Store' => 'test']); + } + + /** + * @param $expectedLabel + * @param array $headers + * @throws LocalizedException + * @throws Exception + */ + private function attributeLabelTest($expectedLabel, array $headers = []): void + { + $query = <<<QUERY +{ + products(search:"Simple", + pageSize: 3 + currentPage: 1 + ) + { + aggregations + { + attribute_code + label + } + } +} +QUERY; + $response = $this->graphQlQuery($query, [], '', $headers); + $this->assertNotEmpty($response['products']['aggregations']); + $attributes = $response['products']['aggregations']; + foreach ($attributes as $attribute) { + if ($attribute['attribute_code'] === 'test_configurable') { + $this->assertEquals($expectedLabel, $attribute['label']); + } + } + } +} diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductPriceTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductPriceTest.php index f1c1be44ccd13..b6c4b55dc1d23 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductPriceTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductPriceTest.php @@ -78,6 +78,55 @@ public function testProductWithSinglePrice() $this->assertPrices($expectedPriceRange, $product['price_range']); } + /** + * @magentoApiDataFixture Magento/Catalog/_files/products.php + * @magentoApiDataFixture Magento/Directory/_files/usd_cny_rate.php + * @magentoConfigFixture default_store currency/options/allow CNY,USD + */ + public function testProductWithSinglePriceNonDefaultCurrency() + { + $skus = ['simple']; + $query = $this->getProductQuery($skus); + $headerMap = [ + 'Content-Currency' => 'CNY' + ]; + $result = $this->graphQlQuery($query, [], '', $headerMap); + + $this->assertArrayNotHasKey('errors', $result); + $this->assertNotEmpty($result['products']['items']); + $product = $result['products']['items'][0]; + $this->assertNotEmpty($product['price_range']); + + $expectedPriceRange = [ + "minimum_price" => [ + "regular_price" => [ + "value" => 70 + ], + "final_price" => [ + "value" => 70 + ], + "discount" => [ + "amount_off" => 0, + "percent_off" => 0 + ] + ], + "maximum_price" => [ + "regular_price" => [ + "value" => 70 + ], + "final_price" => [ + "value" => 70 + ], + "discount" => [ + "amount_off" => 0, + "percent_off" => 0 + ] + ] + ]; + + $this->assertPrices($expectedPriceRange, $product['price_range'], 'CNY'); + } + /** * Pricing for Simple, Grouped and Configurable products with no special or tier prices configured * @@ -909,7 +958,7 @@ private function getQueryConfigurableProductAndVariants(array $sku): string name sku price_range { - minimum_price {regular_price + minimum_price {regular_price { value currency @@ -949,13 +998,13 @@ private function getQueryConfigurableProductAndVariants(array $sku): string ... on ConfigurableProduct{ variants{ product{ - + sku price_range { minimum_price {regular_price {value} final_price { value - + } discount { amount_off @@ -965,11 +1014,11 @@ private function getQueryConfigurableProductAndVariants(array $sku): string maximum_price { regular_price { value - + } final_price { value - + } discount { amount_off @@ -985,7 +1034,7 @@ private function getQueryConfigurableProductAndVariants(array $sku): string final_price{value} quantity } - + } } } diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductSearchAggregationsTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductSearchAggregationsTest.php index 9dbd902f1714e..41a5d41f2641d 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductSearchAggregationsTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductSearchAggregationsTest.php @@ -7,7 +7,6 @@ namespace Magento\GraphQl\Catalog; -use Magento\TestFramework\Helper\Bootstrap; use Magento\TestFramework\TestCase\GraphQlAbstract; class ProductSearchAggregationsTest extends GraphQlAbstract @@ -17,33 +16,11 @@ class ProductSearchAggregationsTest extends GraphQlAbstract */ public function testAggregationBooleanAttribute() { - $this->markTestSkipped( - 'MC-22184: Elasticsearch returns incorrect aggregation options for booleans' - . 'MC-36768: Custom attribute not appears in elasticsearch' - ); + $this->markTestSkipped('MC-22184: Elasticsearch returns incorrect aggregation options for booleans'); - $skus= '"search_product_1", "search_product_2", "search_product_3", "search_product_4" ,"search_product_5"'; - $query = <<<QUERY -{ - products(filter: {sku: {in: [{$skus}]}}){ - items{ - id - sku - name - } - aggregations{ - label - attribute_code - count - options{ - label - value - count - } - } - } -} -QUERY; + $query = $this->getGraphQlQuery( + '"search_product_1", "search_product_2", "search_product_3", "search_product_4" ,"search_product_5"' + ); $result = $this->graphQlQuery($query); @@ -64,9 +41,93 @@ function ($a) { $this->assertEquals('boolean_attribute', $booleanAggregation['attribute_code']); $this->assertContainsEquals(['label' => '1', 'value'=> '1', 'count' => '3'], $booleanAggregation['options']); - $this->markTestSkipped('MC-22184: Elasticsearch returns incorrect aggregation options for booleans'); $this->assertEquals(2, $booleanAggregation['count']); $this->assertCount(2, $booleanAggregation['options']); $this->assertContainsEquals(['label' => '0', 'value'=> '0', 'count' => '2'], $booleanAggregation['options']); } + + /** + * @magentoApiDataFixture Magento/Catalog/_files/products_for_search.php + */ + public function testAggregationPriceRanges() + { + $query = $this->getGraphQlQuery( + '"search_product_1", "search_product_2", "search_product_3", "search_product_4" ,"search_product_5"' + ); + $result = $this->graphQlQuery($query); + + $this->assertArrayNotHasKey('errors', $result); + $this->assertArrayHasKey('aggregations', $result['products']); + + $priceAggregation = array_filter( + $result['products']['aggregations'], + function ($a) { + return $a['attribute_code'] == 'price'; + } + ); + $this->assertNotEmpty($priceAggregation); + $priceAggregation = reset($priceAggregation); + $this->assertEquals('Price', $priceAggregation['label']); + $this->assertEquals(4, $priceAggregation['count']); + $expectedOptions = [ + ['label' => '10-20', 'value'=> '10_20', 'count' => '2'], + ['label' => '20-30', 'value'=> '20_30', 'count' => '1'], + ['label' => '30-40', 'value'=> '30_40', 'count' => '1'], + ['label' => '40-50', 'value'=> '40_50', 'count' => '1'] + ]; + $this->assertEquals($expectedOptions, $priceAggregation['options']); + } + + /** + * @magentoApiDataFixture Magento/Catalog/_files/products_for_search.php + * @magentoApiDataFixture Magento/Directory/_files/usd_cny_rate.php + * @magentoConfigFixture default_store currency/options/allow CNY,USD + */ + public function testAggregationPriceRangesWithCurrencyHeader() + { + $headerMap['Content-Currency'] = 'CNY'; + $query = $this->getGraphQlQuery( + '"search_product_1", "search_product_2", "search_product_3", "search_product_4" ,"search_product_5"' + ); + $result = $this->graphQlQuery($query, [], '', $headerMap); + $this->assertArrayNotHasKey('errors', $result); + $this->assertArrayHasKey('aggregations', $result['products']); + $priceAggregation = array_filter( + $result['products']['aggregations'], + function ($a) { + return $a['attribute_code'] == 'price'; + } + ); + $this->assertNotEmpty($priceAggregation); + $priceAggregation = reset($priceAggregation); + $this->assertEquals('Price', $priceAggregation['label']); + $this->assertEquals(4, $priceAggregation['count']); + $expectedOptions = [ + ['label' => '70-140', 'value'=> '70_140', 'count' => '2'], + ['label' => '140-210', 'value'=> '140_210', 'count' => '1'], + ['label' => '210-280', 'value'=> '210_280', 'count' => '1'], + ['label' => '280-350', 'value'=> '280_350', 'count' => '1'] + ]; + $this->assertEquals($expectedOptions, $priceAggregation['options']); + } + + private function getGraphQlQuery(string $skus) + { + return <<<QUERY +{ + products(filter: {sku: {in: [{$skus}]}}){ + aggregations{ + label + attribute_code + count + options{ + label + value + count + } + } + } +} +QUERY; + } } diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductSearchTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductSearchTest.php index f755a1a1e0282..7b14fe9159c57 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductSearchTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductSearchTest.php @@ -13,12 +13,14 @@ use Magento\Catalog\Api\ProductRepositoryInterface; use Magento\Catalog\Model\Category; use Magento\Catalog\Model\CategoryLinkManagement; +use Magento\Catalog\Model\Indexer\Product\Category\Processor; use Magento\Catalog\Model\Product; use Magento\Catalog\Model\ResourceModel\Category\Collection; use Magento\Eav\Api\Data\AttributeOptionInterface; use Magento\Eav\Model\Config; use Magento\Framework\App\Config\ScopeConfigInterface; use Magento\Framework\DataObject; +use Magento\TestFramework\Catalog\Model\GetCategoryByName; use Magento\TestFramework\Helper\Bootstrap; use Magento\TestFramework\Helper\CacheCleaner; use Magento\TestFramework\ObjectManager; @@ -67,14 +69,11 @@ public function testFilterForNonExistingCategory() * Verify that layered navigation filters and aggregations are correct for product query * * Filter products by an array of skus - * @magentoApiDataFixture Magento/Catalog/_files/category.php * @magentoApiDataFixture Magento/Catalog/_files/products_with_layered_navigation_attribute.php * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ public function testFilterLn() { - $this->markTestSkipped('MC-36768: Custom attribute value created in integration tests' - . 'fixtures product not appears in elasticsearch'); $query = <<<QUERY { products ( @@ -149,14 +148,12 @@ private function compareFilterNames(array $a, array $b) * Layered navigation for Configurable products with out of stock options * Two configurable products each having two variations and one of the child products of one Configurable set to OOS * - * @magentoApiDataFixture Magento/Catalog/_files/category.php * @magentoApiDataFixture Magento/Catalog/_files/configurable_products_with_custom_attribute_layered_navigation.php + * @magentoApiDataFixture Magento/Indexer/_files/reindex_all_invalid.php * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ public function testLayeredNavigationForConfigurableProducts() { - $this->markTestSkipped('MC-36768: Custom attribute value created in integration tests' - . 'fixtures product not appears in elasticsearch'); CacheCleaner::cleanAll(); $attributeCode = 'test_configurable'; @@ -256,12 +253,11 @@ private function getQueryProductsWithArrayOfCustomAttributes($attributeCode, $fi * Filter products by custom attribute of dropdown type and filterTypeInput eq * * @magentoApiDataFixture Magento/Catalog/_files/products_with_layered_navigation_custom_attribute.php + * @magentoApiDataFixture Magento/Indexer/_files/reindex_all_invalid.php * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ public function testFilterProductsByDropDownCustomAttribute() { - $this->markTestSkipped('MC-36768: Custom attribute value created in integration tests' - . 'fixtures product not appears in elasticsearch'); CacheCleaner::cleanAll(); $attributeCode = 'second_test_configurable'; $optionValue = $this->getDefaultAttributeOptionValue($attributeCode); @@ -455,12 +451,11 @@ private function getDefaultAttributeOptionValue(string $attributeCode): string * Full text search for Products and then filter the results by custom attribute (default sort is relevance) * * @magentoApiDataFixture Magento/Catalog/_files/products_with_layered_navigation_custom_attribute.php + * @magentoApiDataFixture Magento/Indexer/_files/reindex_all_invalid.php * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ public function testSearchAndFilterByCustomAttribute() { - $this->markTestSkipped('MC-36768: Custom attribute value created in integration tests' - . 'fixtures product not appears in elasticsearch'); $attribute_code = 'second_test_configurable'; $optionValue = $this->getDefaultAttributeOptionValue($attribute_code); @@ -603,18 +598,19 @@ public function testSearchAndFilterByCustomAttribute() * Filter by category and custom attribute * * @magentoApiDataFixture Magento/Catalog/_files/products_with_layered_navigation_custom_attribute.php + * @magentoApiDataFixture Magento/Indexer/_files/reindex_all_invalid.php * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ public function testFilterByCategoryIdAndCustomAttribute() { - $this->markTestSkipped('MC-36768: Custom attribute value created in integration tests' - . 'fixtures product not appears in elasticsearch'); - $categoryId = 13; + /** @var GetCategoryByName $getCategoryByName */ + $getCategoryByName = Bootstrap::getObjectManager()->get(GetCategoryByName::class); + $category = $getCategoryByName->execute('Category 1.2'); $optionValue = $this->getDefaultAttributeOptionValue('second_test_configurable'); $query = <<<QUERY { products(filter:{ - category_id : {eq:"{$categoryId}"} + category_id : {eq:"{$category->getId()}"} second_test_configurable: {eq: "{$optionValue}"} }, pageSize: 3 @@ -1170,6 +1166,11 @@ public function testSortByPosition() $category->setPostedProducts($productPositions); $category->save(); + // Reindex products from the result to invalidate query cache. + /** @var $indexer Processor */ + $indexer = Bootstrap::getObjectManager()->get(Processor::class); + $indexer->reindexList(array_keys($productPositions)); + $queryDesc = <<<QUERY { products(filter: {category_id: {eq: "$categoryId"}}, sort: {position: ASC}) { @@ -1448,11 +1449,12 @@ public function testFilterProductsForExactMatchingName() */ public function testFilteringForProductsFromMultipleCategories() { + $categoriesIds = ["4","5","12"]; $query = <<<QUERY { products(filter:{ - category_id :{in:["4","5","12"]} + category_id :{in:["{$categoriesIds[0]}","{$categoriesIds[1]}","{$categoriesIds[2]}"]} }) { items @@ -1478,6 +1480,21 @@ public function testFilteringForProductsFromMultipleCategories() $response = $this->graphQlQuery($query); /** @var ProductRepositoryInterface $productRepository */ $this->assertEquals(3, $response['products']['total_count']); + $actualProducts = []; + foreach ($categoriesIds as $categoriesId) { + /** @var CategoryLinkManagement $productLinks */ + $productLinks = ObjectManager::getInstance()->get(CategoryLinkManagement::class); + $links = $productLinks->getAssignedProducts($categoriesId); + $links = array_reverse($links); + foreach ($links as $linkProduct) { + $productRepository = ObjectManager::getInstance()->get(ProductRepositoryInterface::class); + /** @var ProductInterface $product */ + $product = $productRepository->get($linkProduct->getSku()); + $actualProducts[$linkProduct->getSku()] = $product->getName(); + } + } + $expectedProducts = array_column($response['products']['items'], "name", "sku"); + $this->assertEquals($expectedProducts, $actualProducts); } /** @@ -2368,7 +2385,6 @@ public function testFilterProductsThatAreOutOfStockWithConfigSettings() /** * Verify that invalid current page return an error * - * @magentoApiDataFixture Magento/Catalog/_files/category.php * @magentoApiDataFixture Magento/Catalog/_files/products_with_layered_navigation_attribute.php */ public function testInvalidCurrentPage() @@ -2399,7 +2415,6 @@ public function testInvalidCurrentPage() /** * Verify that invalid page size returns an error. * - * @magentoApiDataFixture Magento/Catalog/_files/category.php * @magentoApiDataFixture Magento/Catalog/_files/products_with_layered_navigation_attribute.php */ public function testInvalidPageSize() diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/CmsUrlRewrite/UrlResolverTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/CmsUrlRewrite/UrlResolverTest.php index e059960074fbf..ce74a432dcba3 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/CmsUrlRewrite/UrlResolverTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/CmsUrlRewrite/UrlResolverTest.php @@ -45,18 +45,7 @@ public function testCMSPageUrlResolver() $targetPath = $urlPathGenerator->getCanonicalUrlPath($page); $expectedEntityType = CmsPageUrlRewriteGenerator::ENTITY_TYPE; - $query - = <<<QUERY -{ - urlResolver(url:"{$requestPath}") - { - id - relative_url - type - redirectCode - } -} -QUERY; + $query = $this->createQuery($requestPath); $response = $this->graphQlQuery($query); $this->assertEquals($cmsPageId, $response['urlResolver']['id']); $this->assertEquals($requestPath, $response['urlResolver']['relative_url']); @@ -64,18 +53,7 @@ public function testCMSPageUrlResolver() $this->assertEquals(0, $response['urlResolver']['redirectCode']); // querying by non seo friendly url path should return seo friendly relative url - $query - = <<<QUERY -{ - urlResolver(url:"{$targetPath}") - { - id - relative_url - type - redirectCode - } -} -QUERY; + $query = $this->createQuery($targetPath); $response = $this->graphQlQuery($query); $this->assertEquals($cmsPageId, $response['urlResolver']['id']); $this->assertEquals($requestPath, $response['urlResolver']['relative_url']); @@ -83,6 +61,24 @@ public function testCMSPageUrlResolver() $this->assertEquals(0, $response['urlResolver']['redirectCode']); } + /** + * @magentoApiDataFixture Magento/Cms/_files/pages.php + */ + public function testResolveCMSPageWithQueryParameters() + { + $page = $this->objectManager->create(\Magento\Cms\Model\Page::class); + $page->load('page100'); + $cmsPageId = $page->getId(); + $requestPath = $page->getIdentifier(); + $requestPath .= '?key=value'; + + $query = $this->createQuery($requestPath); + $response = $this->graphQlQuery($query); + $this->assertNotEmpty($response['urlResolver']); + $this->assertEquals($cmsPageId, $response['urlResolver']['id']); + $this->assertEquals($requestPath, $response['urlResolver']['relative_url']); + } + /** * Test resolution of '/' path to home page */ @@ -98,10 +94,24 @@ public function testResolveSlash() $page = $this->objectManager->get(\Magento\Cms\Model\Page::class); $page->load($homePageIdentifier); $homePageId = $page->getId(); - $query - = <<<QUERY + $query = $this->createQuery('/'); + $response = $this->graphQlQuery($query); + $this->assertArrayHasKey('urlResolver', $response); + $this->assertEquals($homePageId, $response['urlResolver']['id']); + $this->assertEquals($homePageIdentifier, $response['urlResolver']['relative_url']); + $this->assertEquals('CMS_PAGE', $response['urlResolver']['type']); + $this->assertEquals(0, $response['urlResolver']['redirectCode']); + } + + /** + * @param string $path + * @return string + */ + private function createQuery(string $path): string + { + return <<<QUERY { - urlResolver(url:"/") + urlResolver(url:"{$path}") { id relative_url @@ -110,11 +120,5 @@ public function testResolveSlash() } } QUERY; - $response = $this->graphQlQuery($query); - $this->assertArrayHasKey('urlResolver', $response); - $this->assertEquals($homePageId, $response['urlResolver']['id']); - $this->assertEquals($homePageIdentifier, $response['urlResolver']['relative_url']); - $this->assertEquals('CMS_PAGE', $response['urlResolver']['type']); - $this->assertEquals(0, $response['urlResolver']['redirectCode']); } } diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/ConfigurableProduct/AddConfigurableProductToCartSingleMutationTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/ConfigurableProduct/AddConfigurableProductToCartSingleMutationTest.php index a2b7b54fb875a..fb6b36b883e77 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/ConfigurableProduct/AddConfigurableProductToCartSingleMutationTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/ConfigurableProduct/AddConfigurableProductToCartSingleMutationTest.php @@ -8,9 +8,13 @@ namespace Magento\GraphQl\ConfigurableProduct; use Exception; +use Magento\Config\Model\ResourceModel\Config; +use Magento\Framework\App\Config\ReinitableConfigInterface; +use Magento\Framework\App\Config\ScopeConfigInterface; use Magento\GraphQl\Quote\GetMaskedQuoteIdByReservedOrderId; use Magento\TestFramework\Helper\Bootstrap; use Magento\TestFramework\TestCase\GraphQlAbstract; +use Magento\CatalogInventory\Model\Configuration; /** * Add configurable product to cart testcases @@ -22,6 +26,21 @@ class AddConfigurableProductToCartSingleMutationTest extends GraphQlAbstract */ private $getMaskedQuoteIdByReservedOrderId; + /** + * @var Config $config + */ + private $resourceConfig; + + /** + * @var ScopeConfigInterface + */ + private $scopeConfig; + + /** + * @var ReinitableConfigInterface + */ + private $reinitConfig; + /** * @inheritdoc */ @@ -29,6 +48,9 @@ protected function setUp(): void { $objectManager = Bootstrap::getObjectManager(); $this->getMaskedQuoteIdByReservedOrderId = $objectManager->get(GetMaskedQuoteIdByReservedOrderId::class); + $this->resourceConfig = $objectManager->get(Config::class); + $this->scopeConfig = $objectManager->get(ScopeConfigInterface::class); + $this->reinitConfig = $objectManager->get(ReinitableConfigInterface::class); } /** @@ -166,9 +188,20 @@ public function testAddNonExistentConfigurableProductParentToCart() */ public function testOutOfStockVariationToCart() { + $showOutOfStock = $this->scopeConfig->getValue(Configuration::XML_PATH_SHOW_OUT_OF_STOCK); + + // Changing SHOW_OUT_OF_STOCK to show the out of stock option, otherwise graphql won't display it. + $this->resourceConfig->saveConfig(Configuration::XML_PATH_SHOW_OUT_OF_STOCK, 1); + $this->reinitConfig->reinit(); + $product = $this->getConfigurableProductInfo(); $attributeId = (int) $product['configurable_options'][0]['attribute_id']; $valueIndex = $product['configurable_options'][0]['values'][0]['value_index']; + // Asserting that the first value is the right option we want to add to cart + $this->assertEquals( + $product['configurable_options'][0]['values'][0]['label'], + 'Option 1' + ); $parentSku = $product['sku']; $configurableOptionsQuery = $this->generateSuperAttributesUIDQuery($attributeId, $valueIndex); @@ -191,6 +224,8 @@ public function testOutOfStockVariationToCart() $response['addProductsToCart']['user_errors'][0]['message'], $expectedErrorMessages ); + $this->resourceConfig->saveConfig(Configuration::XML_PATH_SHOW_OUT_OF_STOCK, $showOutOfStock); + $this->reinitConfig->reinit(); } /** diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/ConfigurableProduct/AddConfigurableProductToCartTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/ConfigurableProduct/AddConfigurableProductToCartTest.php index 519f5fef13fdc..5bc543f9f122a 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/ConfigurableProduct/AddConfigurableProductToCartTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/ConfigurableProduct/AddConfigurableProductToCartTest.php @@ -328,6 +328,94 @@ public function testOutOfStockVariationToCart() $this->graphQlMutation($query); } + /** + * @magentoApiDataFixture Magento/ConfigurableProduct/_files/product_configurable_with_custom_option_dropdown.php + * @magentoApiDataFixture Magento/Checkout/_files/active_quote.php + */ + public function testAddConfigurableProductToCartWithCustomOption() + { + $maskedQuoteId = $this->getMaskedQuoteIdByReservedOrderId->execute('test_order_1'); + $sku = 'configurable'; + $variantSku = 'simple_10'; + $productOptions = $this->getAvailableProductCustomOption($sku); + $optionId = $productOptions[0]['option_id']; + $optionValueId = $productOptions[0]['value'][1]['option_type_id']; + + $mutation = <<<QUERY +mutation { + addConfigurableProductsToCart(input: { + cart_id: "{$maskedQuoteId}", + cart_items: [ + { + parent_sku: "{$sku}", + variant_sku: "{$variantSku}", + data: { + sku: "{$variantSku}", + quantity: 1 + }, + customizable_options: [ + {id: {$optionId}, value_string: "{$optionValueId}"}] + } + ] + }) { + cart { + items { + id + quantity + product { + sku + name + } + ... on ConfigurableCartItem { + configurable_options { + option_label + value_label + } + customizable_options { + id + label + values{ + label + value + } + } + } + } + } + } +} +QUERY; + + $response = $this->graphQlMutation($mutation); + $this->assertArrayNotHasKey('errors', $response); + $this->assertCount(1, $response['addConfigurableProductsToCart']['cart']['items']); + $item = $response['addConfigurableProductsToCart']['cart']['items'][0]; + $this->assertEquals($sku, $item['product']['sku']); + $expectedOptions = [ + 'configurable_options' => [ + [ + 'option_label' => 'Test Configurable', + 'value_label' => 'Option 1' + ] + ], + 'customizable_options' => [ + [ + 'id' => $optionId, + 'label' => 'Dropdown Options', + 'values' => [ + [ + 'label' => 'Option 2', + 'value' => $optionValueId + ] + ] + ] + ] + ]; + + $this->assertResponseFields($item['configurable_options'], $expectedOptions['configurable_options']); + $this->assertResponseFields($item['customizable_options'], $expectedOptions['customizable_options']); + } + /** * @param string $maskedQuoteId * @param string $parentSku @@ -406,4 +494,39 @@ private function getFetchProductQuery(string $term): string } QUERY; } + + /** + * Get product customizable dropdown options + * + * @param string $productSku + * @return array + */ + private function getAvailableProductCustomOption(string $productSku): array + { + $query = <<<QUERY +{ + products(filter: {sku: {eq: "${productSku}"}}) { + items { + name + ... on CustomizableProductInterface { + options { + option_id + title + ... on CustomizableDropDownOption { + value { + option_type_id + title + } + } + } + } + } + } +} +QUERY; + + $response = $this->graphQlQuery($query); + $this->assertNotEmpty($response['products']['items'], "No result for product with sku: '{$productSku}'"); + return $response['products']['items'][0]['options']; + } } diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/ConfigurableProduct/ConfigurableOptionsSelectionMetadataTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/ConfigurableProduct/ConfigurableOptionsSelectionMetadataTest.php new file mode 100644 index 0000000000000..f0e4df50794a3 --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/ConfigurableProduct/ConfigurableOptionsSelectionMetadataTest.php @@ -0,0 +1,410 @@ +<?php + +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\GraphQl\ConfigurableProduct; + +use Magento\ConfigurableProductGraphQl\Model\Options\SelectionUidFormatter; +use Magento\Framework\Exception\NoSuchEntityException; +use Magento\Eav\Api\Data\AttributeInterface; +use Magento\Eav\Model\AttributeRepository; +use Magento\TestFramework\TestCase\GraphQlAbstract; +use Magento\TestFramework\Helper\Bootstrap; + +/** + * Test configurable product option selection. + */ +class ConfigurableOptionsSelectionMetadataTest extends GraphQlAbstract +{ + /** + * @var AttributeRepository + */ + private $attributeRepository; + + /** + * @var SelectionUidFormatter + */ + private $selectionUidFormatter; + + private $firstConfigurableAttribute = null; + + private $secondConfigurableAttribute = null; + + /** + * @inheritdoc + */ + protected function setUp(): void + { + $this->attributeRepository = Bootstrap::getObjectManager()->create(AttributeRepository::class); + $this->selectionUidFormatter = Bootstrap::getObjectManager()->create(SelectionUidFormatter::class); + } + + /** + * @magentoApiDataFixture Magento/ConfigurableProduct/_files/configurable_products_with_two_attributes_combination.php + */ + public function testWithoutSelectedOption() + { + $query = <<<QUERY +{ + products(filter:{ + sku: {eq: "configurable_12345"} + }) + { + items + { + id + sku + name + description { + html + } + ... on ConfigurableProduct { + configurable_options_selection_metadata( + selectedConfigurableOptionValues: [] + ) { + options_available_for_selection { + option_value_uids + attribute_code + } + } + } + } + } +} +QUERY; + $response = $this->graphQlQuery($query); + $this->assertEquals(1, count($response['products']['items'])); + $this->assertEquals(2, count($response['products']['items'][0]['configurable_options_selection_metadata'] + ['options_available_for_selection'])); + $this->assertEquals(4, count($response['products']['items'][0]['configurable_options_selection_metadata'] + ['options_available_for_selection'][0]['option_value_uids'])); + $this->assertEquals(4, count($response['products']['items'][0]['configurable_options_selection_metadata'] + ['options_available_for_selection'][1]['option_value_uids'])); + } + + /** + * @magentoApiDataFixture Magento/ConfigurableProduct/_files/configurable_products_with_two_attributes_combination.php + */ + public function testSelectedFirstAttributeFirstOption() + { + $attribute = $this->getFirstConfigurableAttribute(); + $options = $attribute->getOptions(); + $firstOptionUid = $this->selectionUidFormatter->encode( + (int)$attribute->getAttributeId(), + (int)$options[1]->getValue() + ); + $query = <<<QUERY +{ + products(filter:{ + sku: {eq: "configurable_12345"} + }) + { + items + { + id + sku + name + description { + html + } + ... on ConfigurableProduct { + configurable_options_selection_metadata( + selectedConfigurableOptionValues: ["{$firstOptionUid}"] + ) { + options_available_for_selection { + option_value_uids + attribute_code + } + } + } + } + } +} +QUERY; + + $response = $this->graphQlQuery($query); + $this->assertEquals(1, count($response['products']['items'])); + $this->assertEquals(2, count($response['products']['items'][0]['configurable_options_selection_metadata'] + ['options_available_for_selection'])); + $this->assertEquals(1, count($response['products']['items'][0]['configurable_options_selection_metadata'] + ['options_available_for_selection'][0]['option_value_uids'])); + $this->assertEquals($firstOptionUid, $response['products']['items'][0] + ['configurable_options_selection_metadata']['options_available_for_selection'][0]['option_value_uids'][0]); + $this->assertEquals(4, count($response['products']['items'][0]['configurable_options_selection_metadata'] + ['options_available_for_selection'][1]['option_value_uids'])); + + $secondAttributeOptions = $this->getSecondConfigurableAttribute()->getOptions(); + $this->assertAvailableOptionUids( + $this->getSecondConfigurableAttribute()->getAttributeId(), + $secondAttributeOptions, + $response['products']['items'][0]['configurable_options_selection_metadata'] + ['options_available_for_selection'][1]['option_value_uids'] + ); + } + + /** + * @magentoApiDataFixture Magento/ConfigurableProduct/_files/configurable_products_with_two_attributes_combination.php + */ + public function testSelectedFirstAttributeLastOption() + { + $attribute = $this->getFirstConfigurableAttribute(); + $options = $attribute->getOptions(); + $lastOptionUid = $this->selectionUidFormatter->encode( + (int)$attribute->getAttributeId(), + (int)$options[4]->getValue() + ); + $query = <<<QUERY +{ + products(filter:{ + sku: {eq: "configurable_12345"} + }) + { + items + { + id + sku + name + description { + html + } + ... on ConfigurableProduct { + configurable_options_selection_metadata( + selectedConfigurableOptionValues: ["{$lastOptionUid}"] + ) { + options_available_for_selection { + option_value_uids + attribute_code + } + } + } + } + } +} +QUERY; + + $response = $this->graphQlQuery($query); + $this->assertEquals(1, count($response['products']['items'])); + $this->assertEquals(2, count($response['products']['items'][0]['configurable_options_selection_metadata'] + ['options_available_for_selection'])); + $this->assertEquals(1, count($response['products']['items'][0]['configurable_options_selection_metadata'] + ['options_available_for_selection'][0]['option_value_uids'])); + $this->assertEquals($lastOptionUid, $response['products']['items'][0]['configurable_options_selection_metadata'] + ['options_available_for_selection'][0]['option_value_uids'][0]); + $this->assertEquals(2, count($response['products']['items'][0]['configurable_options_selection_metadata'] + ['options_available_for_selection'][1]['option_value_uids'])); + $secondAttributeOptions = $this->getSecondConfigurableAttribute()->getOptions(); + unset($secondAttributeOptions[0]); + unset($secondAttributeOptions[1]); + unset($secondAttributeOptions[2]); + $this->assertAvailableOptionUids( + $this->getSecondConfigurableAttribute()->getAttributeId(), + $secondAttributeOptions, + $response['products']['items'][0]['configurable_options_selection_metadata'] + ['options_available_for_selection'][1]['option_value_uids'] + ); + } + + /** + * @magentoApiDataFixture Magento/ConfigurableProduct/_files/configurable_products_with_two_attributes_combination.php + */ + public function testSelectedVariant() + { + $firstAttribute = $this->getFirstConfigurableAttribute(); + $firstOptions = $firstAttribute->getOptions(); + $firstAttributeFirstOptionUid = $this->selectionUidFormatter->encode( + (int)$firstAttribute->getAttributeId(), + (int)$firstOptions[1]->getValue() + ); + $secodnAttribute = $this->getSecondConfigurableAttribute(); + $secondOptions = $secodnAttribute->getOptions(); + $secondAttributeFirstOptionUid = $this->selectionUidFormatter->encode( + (int)$secodnAttribute->getAttributeId(), + (int)$secondOptions[1]->getValue() + ); + $query = <<<QUERY +{ + products(filter:{ + sku: {eq: "configurable_12345"} + }) + { + items + { + id + sku + name + description { + html + } + ... on ConfigurableProduct { + configurable_options_selection_metadata( + selectedConfigurableOptionValues: ["{$firstAttributeFirstOptionUid}", "{$secondAttributeFirstOptionUid}"] + ) { + options_available_for_selection { + option_value_uids + } + variant { + id + sku + name + } + } + } + } + } +} +QUERY; + $response = $this->graphQlQuery($query); + $this->assertEquals(1, count($response['products']['items'])); + $this->assertNotNull($response['products']['items'][0]['configurable_options_selection_metadata'] + ['variant']); + $this->assertEquals( + 'simple_' . $firstOptions[1]->getValue() . '_' . $secondOptions[1]->getValue(), + $response['products']['items'][0]['configurable_options_selection_metadata'] + ['variant']['sku'] + ); + } + + /** + * @magentoApiDataFixture Magento/ConfigurableProduct/_files/configurable_products_with_two_attributes_combination.php + */ + public function testMediaGalleryForAll() + { + $query = <<<QUERY +{ + products(filter:{ + sku: {eq: "configurable_12345"} + }) + { + items + { + id + sku + name + description { + html + } + ... on ConfigurableProduct { + configurable_options_selection_metadata( + selectedConfigurableOptionValues: [] + ) { + options_available_for_selection { + option_value_uids + attribute_code + } + media_gallery { + url + } + } + } + } + } +} +QUERY; + $response = $this->graphQlQuery($query); + $this->assertEquals(1, count($response['products']['items'])); + $this->assertEquals(14, count($response['products']['items'][0]['configurable_options_selection_metadata'] + ['media_gallery'])); + } + + /** + * @magentoApiDataFixture Magento/ConfigurableProduct/_files/configurable_products_with_two_attributes_combination.php + */ + public function testMediaGalleryWithSelection() + { + $attribute = $this->getFirstConfigurableAttribute(); + $options = $attribute->getOptions(); + $lastOptionUid = $this->selectionUidFormatter->encode( + (int)$attribute->getAttributeId(), + (int)$options[4]->getValue() + ); + $query = <<<QUERY +{ + products(filter:{ + sku: {eq: "configurable_12345"} + }) + { + items + { + id + sku + name + description { + html + } + ... on ConfigurableProduct { + configurable_options_selection_metadata( + selectedConfigurableOptionValues: ["$lastOptionUid"] + ) { + options_available_for_selection { + option_value_uids + attribute_code + } + media_gallery { + url + } + } + } + } + } +} +QUERY; + $response = $this->graphQlQuery($query); + $this->assertEquals(1, count($response['products']['items'])); + $this->assertEquals(2, count($response['products']['items'][0]['configurable_options_selection_metadata'] + ['media_gallery'])); + } + + /** + * Assert option uid. + * + * @param $attributeId + * @param $expectedOptions + * @param $selectedOptions + */ + private function assertAvailableOptionUids($attributeId, $expectedOptions, $selectedOptions) + { + unset($expectedOptions[0]); + foreach ($expectedOptions as $option) { + $this->assertContains( + $this->selectionUidFormatter->encode((int)$attributeId, (int)$option->getValue()), + $selectedOptions + ); + } + } + + /** + * Get first configurable attribute. + * + * @return AttributeInterface + * @throws NoSuchEntityException + */ + private function getFirstConfigurableAttribute() + { + if (!$this->firstConfigurableAttribute) { + $attributeCode = 'test_configurable_first'; + $this->firstConfigurableAttribute = $this->attributeRepository->get('catalog_product', $attributeCode); + } + + return $this->firstConfigurableAttribute; + } + + /** + * Get second configurable attribute. + * + * @return AttributeInterface + * @throws NoSuchEntityException + */ + private function getSecondConfigurableAttribute() + { + if (!$this->secondConfigurableAttribute) { + $attributeCode = 'test_configurable_second'; + $this->secondConfigurableAttribute = $this->attributeRepository->get('catalog_product', $attributeCode); + } + + return $this->secondConfigurableAttribute; + } +} diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/CorsHeadersTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/CorsHeadersTest.php deleted file mode 100644 index b8f59b34fae0c..0000000000000 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/CorsHeadersTest.php +++ /dev/null @@ -1,126 +0,0 @@ -<?php -/** - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. - */ -declare(strict_types=1); - -namespace Magento\GraphQl; - -use Magento\Config\Model\ResourceModel\Config; -use Magento\Framework\App\Config\ReinitableConfigInterface; -use Magento\Framework\App\Config\ScopeConfigInterface; -use Magento\GraphQl\Model\Cors\Configuration; -use Magento\TestFramework\ObjectManager; -use Magento\TestFramework\TestCase\GraphQlAbstract; - -class CorsHeadersTest extends GraphQlAbstract -{ - /** - * @var Config $config - */ - private $resourceConfig; - /** - * @var ReinitableConfigInterface - */ - private $reinitConfig; - /** - * @var ScopeConfigInterface - */ - private $scopeConfig; - - /** - * @inheritdoc - */ - protected function setUp(): void - { - parent::setUp(); - - $objectManager = ObjectManager::getInstance(); - - $this->resourceConfig = $objectManager->get(Config::class); - $this->reinitConfig = $objectManager->get(ReinitableConfigInterface::class); - $this->scopeConfig = $objectManager->get(ScopeConfigInterface::class); - } - - protected function tearDown(): void - { - parent::tearDown(); - - $this->resourceConfig->saveConfig(Configuration::XML_PATH_CORS_HEADERS_ENABLED, 0); - $this->reinitConfig->reinit(); - } - - public function testNoCorsHeadersWhenCorsIsDisabled(): void - { - $this->resourceConfig->saveConfig(Configuration::XML_PATH_CORS_HEADERS_ENABLED, 0); - $this->resourceConfig->saveConfig(Configuration::XML_PATH_CORS_ALLOWED_HEADERS, 'Origin'); - $this->resourceConfig->saveConfig(Configuration::XML_PATH_CORS_ALLOW_CREDENTIALS, '1'); - $this->resourceConfig->saveConfig(Configuration::XML_PATH_CORS_ALLOWED_METHODS, 'GET,POST'); - $this->resourceConfig->saveConfig(Configuration::XML_PATH_CORS_ALLOWED_ORIGINS, 'magento.local'); - $this->resourceConfig->saveConfig(Configuration::XML_PATH_CORS_MAX_AGE, '86400'); - $this->reinitConfig->reinit(); - - $headers = $this->getHeadersFromIntrospectionQuery(); - - self::assertArrayNotHasKey('Access-Control-Max-Age', $headers); - self::assertArrayNotHasKey('Access-Control-Allow-Credentials', $headers); - self::assertArrayNotHasKey('Access-Control-Allow-Headers', $headers); - self::assertArrayNotHasKey('Access-Control-Allow-Methods', $headers); - self::assertArrayNotHasKey('Access-Control-Allow-Origin', $headers); - } - - public function testCorsHeadersWhenCorsIsEnabled(): void - { - $this->resourceConfig->saveConfig(Configuration::XML_PATH_CORS_HEADERS_ENABLED, 1); - $this->resourceConfig->saveConfig(Configuration::XML_PATH_CORS_ALLOWED_HEADERS, 'Origin'); - $this->resourceConfig->saveConfig(Configuration::XML_PATH_CORS_ALLOW_CREDENTIALS, '1'); - $this->resourceConfig->saveConfig(Configuration::XML_PATH_CORS_ALLOWED_METHODS, 'GET,POST'); - $this->resourceConfig->saveConfig(Configuration::XML_PATH_CORS_ALLOWED_ORIGINS, 'http://magento.local'); - $this->resourceConfig->saveConfig(Configuration::XML_PATH_CORS_MAX_AGE, '86400'); - $this->reinitConfig->reinit(); - - $headers = $this->getHeadersFromIntrospectionQuery(); - - self::assertEquals('Origin', $headers['Access-Control-Allow-Headers']); - self::assertEquals('1', $headers['Access-Control-Allow-Credentials']); - self::assertEquals('GET,POST', $headers['Access-Control-Allow-Methods']); - self::assertEquals('http://magento.local', $headers['Access-Control-Allow-Origin']); - self::assertEquals('86400', $headers['Access-Control-Max-Age']); - } - - public function testEmptyCorsHeadersWhenCorsIsEnabled(): void - { - $this->resourceConfig->saveConfig(Configuration::XML_PATH_CORS_HEADERS_ENABLED, 1); - $this->resourceConfig->saveConfig(Configuration::XML_PATH_CORS_ALLOWED_HEADERS, ''); - $this->resourceConfig->saveConfig(Configuration::XML_PATH_CORS_ALLOW_CREDENTIALS, ''); - $this->resourceConfig->saveConfig(Configuration::XML_PATH_CORS_ALLOWED_METHODS, ''); - $this->resourceConfig->saveConfig(Configuration::XML_PATH_CORS_ALLOWED_ORIGINS, ''); - $this->resourceConfig->saveConfig(Configuration::XML_PATH_CORS_MAX_AGE, ''); - $this->reinitConfig->reinit(); - - $headers = $this->getHeadersFromIntrospectionQuery(); - - self::assertArrayNotHasKey('Access-Control-Max-Age', $headers); - self::assertArrayNotHasKey('Access-Control-Allow-Credentials', $headers); - self::assertArrayNotHasKey('Access-Control-Allow-Headers', $headers); - self::assertArrayNotHasKey('Access-Control-Allow-Methods', $headers); - self::assertArrayNotHasKey('Access-Control-Allow-Origin', $headers); - } - - private function getHeadersFromIntrospectionQuery(): array - { - $query - = <<<QUERY - query IntrospectionQuery { - __schema { - types { - name - } - } - } -QUERY; - - return $this->graphQlQueryWithResponseHeaders($query)['headers'] ?? []; - } -} diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Customer/ResetPasswordTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Customer/ResetPasswordTest.php index 9c89b80433afd..b5649cbf3bd64 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Customer/ResetPasswordTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Customer/ResetPasswordTest.php @@ -156,7 +156,7 @@ public function testResetPasswordTokenEmptyValue() public function testResetPasswordTokenMismatched() { $this->expectException(\Exception::class); - $this->expectExceptionMessage('Cannot set the customer\'s password'); + $this->expectExceptionMessage('The password token is mismatched. Reset and try again'); $query = <<<QUERY mutation { resetPassword ( @@ -192,6 +192,55 @@ public function testNewPasswordEmptyValue() $this->graphQlMutation($query); } + /** + * @magentoApiDataFixture Magento/Customer/_files/customer.php + * + * @throws NoSuchEntityException + * @throws Exception + * @throws LocalizedException + */ + public function testNewPasswordCheckMinLength() + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('The password needs at least 8 characters. Create a new password and try again'); + $query = <<<QUERY +mutation { + resetPassword ( + email: "{$this->getCustomerEmail()}" + resetPasswordToken: "{$this->getResetPasswordToken()}" + newPassword: "new_" + ) +} +QUERY; + $this->graphQlMutation($query); + } + + /** + * @magentoApiDataFixture Magento/Customer/_files/customer.php + * + * @throws NoSuchEntityException + * @throws Exception + * @throws LocalizedException + */ + public function testNewPasswordCheckCharactersStrength() + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage( + 'Minimum of different classes of characters in password is 3. ' . + 'Classes of characters: Lower Case, Upper Case, Digits, Special Characters.' + ); + $query = <<<QUERY +mutation { + resetPassword ( + email: "{$this->getCustomerEmail()}" + resetPasswordToken: "{$this->getResetPasswordToken()}" + newPassword: "new_password" + ) +} +QUERY; + $this->graphQlMutation($query); + } + /** * Check password reset for lock customer * diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Customer/CheckoutEndToEndTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Customer/CheckoutEndToEndTest.php index 7b686ea8c92f9..dba712869e6ce 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Customer/CheckoutEndToEndTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Customer/CheckoutEndToEndTest.php @@ -13,6 +13,7 @@ use Magento\Framework\Registry; use Magento\Quote\Model\ResourceModel\Quote\CollectionFactory as QuoteCollectionFactory; use Magento\Sales\Api\OrderRepositoryInterface; +use Magento\Sales\Model\OrderFactory; use Magento\Sales\Model\ResourceModel\Order\CollectionFactory; use Magento\TestFramework\Helper\Bootstrap; use Magento\TestFramework\TestCase\GraphQlAbstract; @@ -57,23 +58,31 @@ class CheckoutEndToEndTest extends GraphQlAbstract */ private $orderRepository; + /** + * @var OrderFactory + */ + private $orderFactory; + /** * @var array */ private $headers = []; + /** + * @inheritdoc + */ protected function setUp(): void { - parent::setUp(); - $objectManager = Bootstrap::getObjectManager(); + $this->registry = $objectManager->get(Registry::class); $this->quoteCollectionFactory = $objectManager->get(QuoteCollectionFactory::class); $this->quoteResource = $objectManager->get(QuoteResource::class); $this->quoteIdMaskFactory = $objectManager->get(QuoteIdMaskFactory::class); - $this->customerRepository = Bootstrap::getObjectManager()->get(CustomerRepositoryInterface::class); + $this->customerRepository = $objectManager->get(CustomerRepositoryInterface::class); $this->orderCollectionFactory = $objectManager->get(CollectionFactory::class); $this->orderRepository = $objectManager->get(OrderRepositoryInterface::class); + $this->orderFactory = $objectManager->get(OrderFactory::class); } /** @@ -97,8 +106,13 @@ public function testCheckoutWorkflow() $paymentMethod = $this->setShippingMethod($cartId, $shippingMethod); $this->setPaymentMethod($cartId, $paymentMethod); - $orderId = $this->placeOrder($cartId); - $this->checkOrderInHistory($orderId); + $orderIncrementId = $this->placeOrder($cartId); + + $order = $this->orderFactory->create(); + $order->loadByIncrementId($orderIncrementId); + + $this->checkOrderInHistory($orderIncrementId); + $this->assertNotEmpty($order->getEmailSent()); } /** @@ -208,7 +222,7 @@ private function createEmptyCart(): string private function addProductToCart(string $cartId, float $qty, string $sku): void { $query = <<<QUERY -mutation { +mutation { addSimpleProductsToCart( input: { cart_id: "{$cartId}" @@ -350,7 +364,7 @@ private function setShippingMethod(string $cartId, array $method): array $query = <<<QUERY mutation { setShippingMethodsOnCart(input: { - cart_id: "{$cartId}", + cart_id: "{$cartId}", shipping_methods: [ { carrier_code: "{$method['carrier_code']}" diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Customer/MergeCartsTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Customer/MergeCartsTest.php index 65e91bf193020..4f50b9df3098a 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Customer/MergeCartsTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Customer/MergeCartsTest.php @@ -193,7 +193,7 @@ public function testMergeCartsWithEmptySourceCartId() public function testMergeCartsWithEmptyDestinationCartId() { $this->expectException(\Exception::class); - $this->expectExceptionMessage('Required parameter "destination_cart_id" is missing'); + $this->expectExceptionMessage('The parameter "destination_cart_id" cannot be empty'); $guestQuote = $this->quoteFactory->create(); $this->quoteResource->load( @@ -209,6 +209,54 @@ public function testMergeCartsWithEmptyDestinationCartId() $this->graphQlMutation($query, [], '', $this->getHeaderMap()); } + /** + * @magentoApiDataFixture Magento/Checkout/_files/quote_with_virtual_product_saved.php + * @magentoApiDataFixture Magento/Customer/_files/customer.php + * @magentoApiDataFixture Magento/GraphQl/Catalog/_files/simple_product.php + * @magentoApiDataFixture Magento/GraphQl/Quote/_files/customer/create_empty_cart.php + * @magentoApiDataFixture Magento/GraphQl/Quote/_files/add_simple_product.php + */ + public function testMergeCartsWithoutDestinationCartId() + { + $guestQuote = $this->quoteFactory->create(); + $this->quoteResource->load( + $guestQuote, + 'test_order_with_virtual_product_without_address', + 'reserved_order_id' + ); + $guestQuoteMaskedId = $this->quoteIdToMaskedId->execute((int)$guestQuote->getId()); + $query = $this->getCartMergeMutationWithoutDestinationCartId( + $guestQuoteMaskedId + ); + $mergeResponse = $this->graphQlMutation($query, [], '', $this->getHeaderMap()); + + self::assertArrayHasKey('mergeCarts', $mergeResponse); + $cartResponse = $mergeResponse['mergeCarts']; + self::assertArrayHasKey('items', $cartResponse); + self::assertCount(2, $cartResponse['items']); + + $customerQuote = $this->quoteFactory->create(); + $this->quoteResource->load($customerQuote, 'test_quote', 'reserved_order_id'); + $customerQuoteMaskedId = $this->quoteIdToMaskedId->execute((int)$customerQuote->getId()); + + $cartResponse = $this->graphQlMutation( + $this->getCartQuery($customerQuoteMaskedId), + [], + '', + $this->getHeaderMap() + ); + + self::assertArrayHasKey('cart', $cartResponse); + self::assertArrayHasKey('items', $cartResponse['cart']); + self::assertCount(2, $cartResponse['cart']['items']); + $item1 = $cartResponse['cart']['items'][0]; + self::assertArrayHasKey('quantity', $item1); + self::assertEquals(2, $item1['quantity']); + $item2 = $cartResponse['cart']['items'][1]; + self::assertArrayHasKey('quantity', $item2); + self::assertEquals(1, $item2['quantity']); + } + /** * Add simple product to cart * @@ -256,6 +304,31 @@ private function getCartMergeMutation(string $guestQuoteMaskedId, string $custom QUERY; } + /** + * Create the mergeCart mutation + * + * @param string $guestQuoteMaskedId + * @return string + */ + private function getCartMergeMutationWithoutDestinationCartId( + string $guestQuoteMaskedId + ): string { + return <<<QUERY +mutation { + mergeCarts( + source_cart_id: "{$guestQuoteMaskedId}" + ){ + items { + quantity + product { + sku + } + } + } +} +QUERY; + } + /** * Get cart query * diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Customer/SetPurchaseOrderPaymentMethodOnCartTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Customer/SetPurchaseOrderPaymentMethodOnCartTest.php index 1777289afe5bc..69dc78b9d08d9 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Customer/SetPurchaseOrderPaymentMethodOnCartTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Customer/SetPurchaseOrderPaymentMethodOnCartTest.php @@ -7,7 +7,6 @@ namespace Magento\GraphQl\Quote\Customer; -use Exception; use Magento\GraphQl\Quote\GetMaskedQuoteIdByReservedOrderId; use Magento\Integration\Api\CustomerTokenServiceInterface; use Magento\OfflinePayments\Model\Purchaseorder; @@ -100,9 +99,6 @@ public function testSetPurchaseOrderPaymentMethodOnCartWithSimpleProduct() */ public function testSetPurchaseOrderPaymentMethodOnCartWithoutPurchaseOrderNumber() { - $this->expectException(\Exception::class); - $this->expectExceptionMessage('Purchase order number is a required field.'); - $methodCode = Purchaseorder::PAYMENT_METHOD_PURCHASEORDER_CODE; $maskedQuoteId = $this->getMaskedQuoteIdByReservedOrderId->execute('test_quote'); @@ -122,7 +118,18 @@ public function testSetPurchaseOrderPaymentMethodOnCartWithoutPurchaseOrderNumbe } } QUERY; - $this->graphQlMutation($query, [], '', $this->getHeaderMap()); + $response = $this->graphQlMutation($query, [], '', $this->getHeaderMap()); + self::assertArrayHasKey('setPaymentMethodOnCart', $response); + self::assertArrayHasKey('cart', $response['setPaymentMethodOnCart']); + self::assertArrayHasKey('selected_payment_method', $response['setPaymentMethodOnCart']['cart']); + self::assertEquals( + $methodCode, + $response['setPaymentMethodOnCart']['cart']['selected_payment_method']['code'] + ); + self::assertArrayNotHasKey( + 'purchase_order_number', + $response['setPaymentMethodOnCart']['cart']['selected_payment_method'] + ); } /** diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Guest/ApplyCouponsToCartTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Guest/ApplyCouponsToCartTest.php index d33d0ee0569cd..a9dadccaa5373 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Guest/ApplyCouponsToCartTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Guest/ApplyCouponsToCartTest.php @@ -21,6 +21,9 @@ class ApplyCouponsToCartTest extends GraphQlAbstract */ private $getMaskedQuoteIdByReservedOrderId; + /** + * @inheritdoc + */ protected function setUp(): void { $objectManager = Bootstrap::getObjectManager(); @@ -36,12 +39,17 @@ protected function setUp(): void public function testApplyCouponsToCart() { $couponCode = '2?ds5!2d'; + $expectedGrandTotal = 15.00; $maskedQuoteId = $this->getMaskedQuoteIdByReservedOrderId->execute('test_quote'); $query = $this->getQuery($maskedQuoteId, $couponCode); $response = $this->graphQlMutation($query); self::assertArrayHasKey('applyCouponToCart', $response); self::assertEquals($couponCode, $response['applyCouponToCart']['cart']['applied_coupons'][0]['code']); + self::assertEquals( + $expectedGrandTotal, + $response['applyCouponToCart']['cart']['prices']['grand_total']['value'] + ); } /** @@ -146,6 +154,11 @@ private function getQuery(string $maskedQuoteId, string $couponCode): string applied_coupons { code } + prices { + grand_total { + value + } + } } } } diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Guest/PlaceOrderTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Guest/PlaceOrderTest.php index 00b960d66cfc6..72d35fdd51b96 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Guest/PlaceOrderTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Guest/PlaceOrderTest.php @@ -7,10 +7,11 @@ namespace Magento\GraphQl\Quote\Guest; -use Exception; +use Magento\Framework\App\Config\ScopeConfigInterface; use Magento\Framework\Registry; use Magento\GraphQl\Quote\GetMaskedQuoteIdByReservedOrderId; use Magento\Sales\Api\OrderRepositoryInterface; +use Magento\Sales\Model\OrderFactory; use Magento\Sales\Model\ResourceModel\Order\CollectionFactory; use Magento\TestFramework\Helper\Bootstrap; use Magento\TestFramework\TestCase\GraphQlAbstract; @@ -40,6 +41,11 @@ class PlaceOrderTest extends GraphQlAbstract */ private $registry; + /** + * @var OrderFactory + */ + private $orderFactory; + /** * @inheritdoc */ @@ -49,7 +55,11 @@ protected function setUp(): void $this->getMaskedQuoteIdByReservedOrderId = $objectManager->get(GetMaskedQuoteIdByReservedOrderId::class); $this->orderCollectionFactory = $objectManager->get(CollectionFactory::class); $this->orderRepository = $objectManager->get(OrderRepositoryInterface::class); - $this->registry = Bootstrap::getObjectManager()->get(Registry::class); + $this->orderFactory = $objectManager->get(OrderFactory::class); + $this->registry = $objectManager->get(Registry::class); + /** @var ScopeConfigInterface $scopeConfig */ + $scopeConfig = $objectManager->get(ScopeConfigInterface::class); + $scopeConfig->clean(); } /** @@ -61,6 +71,7 @@ protected function setUp(): void * @magentoConfigFixture default_store payment/cashondelivery/active 1 * @magentoConfigFixture default_store payment/checkmo/active 1 * @magentoConfigFixture default_store payment/purchaseorder/active 1 + * @magentoConfigFixture default_store customer/create_account/auto_group_assign 0 * @magentoApiDataFixture Magento/GraphQl/Quote/_files/guest/create_empty_cart.php * @magentoApiDataFixture Magento/GraphQl/Quote/_files/guest/set_guest_email.php * @magentoApiDataFixture Magento/GraphQl/Quote/_files/add_simple_product.php @@ -80,9 +91,49 @@ public function testPlaceOrder() self::assertArrayHasKey('placeOrder', $response); self::assertArrayHasKey('order_number', $response['placeOrder']['order']); self::assertEquals($reservedOrderId, $response['placeOrder']['order']['order_number']); + $orderIncrementId = $response['placeOrder']['order']['order_number']; + $order = $this->orderFactory->create(); + $order->loadByIncrementId($orderIncrementId); + $this->assertNotEmpty($order->getEmailSent()); + } + + /** + * @magentoApiDataFixture Magento/GraphQl/Catalog/_files/simple_product.php + * @magentoConfigFixture default_store carriers/flatrate/active 1 + * @magentoConfigFixture default_store carriers/tablerate/active 1 + * @magentoConfigFixture default_store carriers/freeshipping/active 1 + * @magentoConfigFixture default_store payment/banktransfer/active 1 + * @magentoConfigFixture default_store payment/cashondelivery/active 1 + * @magentoConfigFixture default_store payment/checkmo/active 1 + * @magentoConfigFixture default_store payment/purchaseorder/active 1 + * @magentoConfigFixture default_store customer/create_account/auto_group_assign 1 + * @magentoApiDataFixture Magento/GraphQl/Quote/_files/guest/create_empty_cart.php + * @magentoApiDataFixture Magento/GraphQl/Quote/_files/guest/set_guest_email.php + * @magentoApiDataFixture Magento/GraphQl/Quote/_files/add_simple_product.php + * @magentoApiDataFixture Magento/GraphQl/Quote/_files/set_new_shipping_address.php + * @magentoApiDataFixture Magento/GraphQl/Quote/_files/set_new_billing_address.php + * @magentoApiDataFixture Magento/GraphQl/Quote/_files/set_flatrate_shipping_method.php + * @magentoApiDataFixture Magento/GraphQl/Quote/_files/set_checkmo_payment_method.php + */ + public function testPlaceOrderWithAutoGroup() + { + $reservedOrderId = 'test_quote'; + $maskedQuoteId = $this->getMaskedQuoteIdByReservedOrderId->execute($reservedOrderId); + + $query = $this->getQuery($maskedQuoteId); + $response = $this->graphQlMutation($query); + + self::assertArrayHasKey('placeOrder', $response); + self::assertArrayHasKey('order_number', $response['placeOrder']['order']); + self::assertEquals($reservedOrderId, $response['placeOrder']['order']['order_number']); + $orderIncrementId = $response['placeOrder']['order']['order_number']; + $order = $this->orderFactory->create(); + $order->loadByIncrementId($orderIncrementId); + $this->assertNotEmpty($order->getEmailSent()); } /** + * @magentoConfigFixture default_store customer/create_account/auto_group_assign 0 */ public function testPlaceOrderIfCartIdIsEmpty() { @@ -104,6 +155,7 @@ public function testPlaceOrderIfCartIdIsEmpty() * @magentoConfigFixture default_store payment/cashondelivery/active 1 * @magentoConfigFixture default_store payment/checkmo/active 1 * @magentoConfigFixture default_store payment/purchaseorder/active 1 + * @magentoConfigFixture default_store customer/create_account/auto_group_assign 0 * @magentoApiDataFixture Magento/GraphQl/Quote/_files/guest/create_empty_cart.php * @magentoApiDataFixture Magento/GraphQl/Quote/_files/add_simple_product.php * @magentoApiDataFixture Magento/GraphQl/Quote/_files/set_new_shipping_address.php @@ -257,6 +309,7 @@ public function testPlaceOrderWithOutOfStockProduct() * @magentoConfigFixture default_store payment/cashondelivery/active 1 * @magentoConfigFixture default_store payment/checkmo/active 1 * @magentoConfigFixture default_store payment/purchaseorder/active 1 + * @magentoConfigFixture default_store customer/create_account/auto_group_assign 0 * @magentoApiDataFixture Magento/GraphQl/Quote/_files/customer/create_empty_cart.php * @magentoApiDataFixture Magento/GraphQl/Quote/_files/add_simple_product.php * @magentoApiDataFixture Magento/GraphQl/Quote/_files/set_new_shipping_address.php diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Guest/SetPurchaseOrderPaymentMethodOnCartTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Guest/SetPurchaseOrderPaymentMethodOnCartTest.php index 2c93a27012a01..121b04cc8ed11 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Guest/SetPurchaseOrderPaymentMethodOnCartTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Guest/SetPurchaseOrderPaymentMethodOnCartTest.php @@ -7,7 +7,6 @@ namespace Magento\GraphQl\Quote\Guest; -use Exception; use Magento\GraphQl\Quote\GetMaskedQuoteIdByReservedOrderId; use Magento\OfflinePayments\Model\Purchaseorder; use Magento\TestFramework\Helper\Bootstrap; @@ -91,9 +90,6 @@ public function testSetPurchaseOrderPaymentMethodOnCartWithSimpleProduct() */ public function testSetPurchaseOrderPaymentMethodOnCartWithoutPurchaseOrderNumber() { - $this->expectException(\Exception::class); - $this->expectExceptionMessage('Purchase order number is a required field.'); - $methodCode = Purchaseorder::PAYMENT_METHOD_PURCHASEORDER_CODE; $maskedQuoteId = $this->getMaskedQuoteIdByReservedOrderId->execute('test_quote'); @@ -113,7 +109,18 @@ public function testSetPurchaseOrderPaymentMethodOnCartWithoutPurchaseOrderNumbe } } QUERY; - $this->graphQlMutation($query); + $response = $this->graphQlMutation($query); + self::assertArrayHasKey('setPaymentMethodOnCart', $response); + self::assertArrayHasKey('cart', $response['setPaymentMethodOnCart']); + self::assertArrayHasKey('selected_payment_method', $response['setPaymentMethodOnCart']['cart']); + self::assertEquals( + $methodCode, + $response['setPaymentMethodOnCart']['cart']['selected_payment_method']['code'] + ); + self::assertArrayNotHasKey( + 'purchase_order_number', + $response['setPaymentMethodOnCart']['cart']['selected_payment_method'] + ); } /** diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Guest/UpdateCartItemsTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Guest/UpdateCartItemsTest.php index 0a22f3ca9721c..703e30314ef5f 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Guest/UpdateCartItemsTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Guest/UpdateCartItemsTest.php @@ -284,7 +284,10 @@ private function getCartQuery(string $maskedQuoteId) */ public function testUpdateGiftMessageCartForItemNotAllow() { - $query = $this->getUpdateGiftMessageQuery(); + $messageTo = ""; + $messageFrom = ""; + $message = ""; + $query = $this->getUpdateGiftMessageQuery($messageTo, $messageFrom, $message); foreach ($this->graphQlMutation($query)['updateCartItems']['cart']['items'] as $item) { self::assertNull($item['gift_message']); } @@ -297,16 +300,27 @@ public function testUpdateGiftMessageCartForItemNotAllow() */ public function testUpdateGiftMessageCartForItem() { - $query = $this->getUpdateGiftMessageQuery(); + $messageTo = "Alex"; + $messageFrom = "Mike"; + $message = "Best regards"; + $query = $this->getUpdateGiftMessageQuery($messageTo, $messageFrom, $message); foreach ($this->graphQlMutation($query)['updateCartItems']['cart']['items'] as $item) { self::assertArrayHasKey('gift_message', $item); self::assertSame('Alex', $item['gift_message']['to']); self::assertSame('Mike', $item['gift_message']['from']); - self::assertSame('Best regards.', $item['gift_message']['message']); + self::assertSame('Best regards', $item['gift_message']['message']); + } + $messageTo = ""; + $messageFrom = ""; + $message = ""; + $query = $this->getUpdateGiftMessageQuery($messageTo, $messageFrom, $message); + foreach ($this->graphQlMutation($query)['updateCartItems']['cart']['items'] as $item) { + self::assertArrayHasKey('gift_message', $item); + self::assertSame(null, $item['gift_message']); } } - private function getUpdateGiftMessageQuery() + private function getUpdateGiftMessageQuery(string $messageTo, string $messageFrom, string $message) { $quote = $this->quoteFactory->create(); $this->quoteResource->load($quote, 'test_guest_order_with_gift_message', 'reserved_order_id'); @@ -323,9 +337,9 @@ private function getUpdateGiftMessageQuery() cart_item_id: $itemId quantity: 3 gift_message: { - to: "Alex" - from: "Mike" - message: "Best regards." + to: "$messageTo" + from: "$messageFrom" + message: "$message" } } ] diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/Fixtures/CustomerPlaceOrderWithDownloadable.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/Fixtures/CustomerPlaceOrderWithDownloadable.php new file mode 100644 index 0000000000000..4355662abe9ff --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/Fixtures/CustomerPlaceOrderWithDownloadable.php @@ -0,0 +1,284 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\GraphQl\Sales\Fixtures; + +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Catalog\Model\Product; +use Magento\Downloadable\Api\Data\LinkInterface; +use Magento\Integration\Api\CustomerTokenServiceInterface; +use Magento\TestFramework\TestCase\GraphQl\Client; + +class CustomerPlaceOrderWithDownloadable +{ + /** + * @var Client + */ + private $gqlClient; + + /** + * @var CustomerTokenServiceInterface + */ + private $tokenService; + + /** + * @var ProductRepositoryInterface + */ + private $productRepository; + + /** + * @var string + */ + private $authHeader; + + /** + * @var string + */ + private $cartId; + + /** + * @var array + */ + private $customerLogin; + + /** + * @param Client $gqlClient + * @param CustomerTokenServiceInterface $tokenService + * @param ProductRepositoryInterface $productRepository + */ + public function __construct( + Client $gqlClient, + CustomerTokenServiceInterface $tokenService, + ProductRepositoryInterface $productRepository + ) { + $this->gqlClient = $gqlClient; + $this->tokenService = $tokenService; + $this->productRepository = $productRepository; + } + + /** + * Place order for a downloadable product + * + * @param array $customerLogin + * @param array $productData + * @return array + */ + public function placeOrderWithDownloadableProduct(array $customerLogin, array $productData): array + { + $this->customerLogin = $customerLogin; + $this->createCustomerCart(); + $this->addDownloadableProduct($productData); + $this->setBillingAddress(); + $paymentMethodCode ='checkmo'; + $this->setPaymentMethod($paymentMethodCode); + return $this->doPlaceOrder(); + } + + /** + * Make GraphQl POST request + * + * @param string $query + * @param array $additionalHeaders + * @return array + */ + private function makeRequest(string $query, array $additionalHeaders = []): array + { + $headers = array_merge([$this->getAuthHeader()], $additionalHeaders); + return $this->gqlClient->post($query, [], '', $headers); + } + + /** + * Get header for authenticated requests + * + * @return string + * @throws \Magento\Framework\Exception\AuthenticationException + */ + private function getAuthHeader(): string + { + if (empty($this->authHeader)) { + $customerToken = $this->tokenService + ->createCustomerAccessToken($this->customerLogin['email'], $this->customerLogin['password']); + $this->authHeader = "Authorization: Bearer {$customerToken}"; + } + return $this->authHeader; + } + + /** + * Get cart id + * + * @return string + */ + private function getCartId(): string + { + if (empty($this->cartId)) { + $this->cartId = $this->createCustomerCart(); + } + return $this->cartId; + } + + /** + * Create empty cart for the customer + * + * @return array + */ + private function createCustomerCart(): string + { + //Create empty cart + $createEmptyCart = <<<QUERY +mutation { + createEmptyCart +} +QUERY; + $result = $this->makeRequest($createEmptyCart); + return $result['createEmptyCart']; + } + + /** + * Add downloadable product with link to the cart + * + * @param array $productData + * @return array + * @throws \Magento\Framework\Exception\NoSuchEntityException + */ + private function addDownloadableProduct(array $productData) + { + $productSku = $productData['sku']; + $qty = $productData['quantity'] ?? 1; + /** @var Product $downloadableProduct */ + $downloadableProduct = $this->productRepository->get($productSku); + /** @var LinkInterface $downloadableProductLinks */ + $downloadableProductLinks = $downloadableProduct->getExtensionAttributes()->getDownloadableProductLinks(); + $linkId = $downloadableProductLinks[0]->getId(); + + $addProduct = <<<QUERY +mutation { + addDownloadableProductsToCart( + input: { + cart_id: "{$this->getCartId()}", + cart_items: [ + { + data: { + quantity: {$qty}, + sku: "{$productSku}" + }, + downloadable_product_links: [ + { + link_id: {$linkId} + } + ] + } + ] + } + ) { + cart { + items { + quantity + ... on DownloadableCartItem { + links { + title + link_type + price + } + } + } + } + } +} +QUERY; + return $this->makeRequest($addProduct); + } + + /** + * Set the billing address on the cart + * + * @return array + */ + private function setBillingAddress(): array + { + $setBillingAddress = <<<QUERY +mutation { + setBillingAddressOnCart( + input: { + cart_id: "{$this->getCartId()}" + billing_address: { + address: { + firstname: "John" + lastname: "Smith" + company: "Test company" + street: ["test street 1", "test street 2"] + city: "Texas City" + postcode: "78717" + telephone: "5123456677" + region: "TX" + country_code: "US" + } + } + } + ) { + cart { + billing_address { + __typename + } + } + } +} +QUERY; + return $this->makeRequest($setBillingAddress); + } + + /** + * Set the payment method on the cart + * + * @param string $paymentMethodCode + * @return array + */ + private function setPaymentMethod(string $paymentMethodCode): array + { + $setPaymentMethod = <<<QUERY +mutation { + setPaymentMethodOnCart( + input: { + cart_id: "{$this->getCartId()}" + payment_method: { + code: "{$paymentMethodCode}" + } + } + ) { + cart { + selected_payment_method { + code + } + } + } +} +QUERY; + return $this->makeRequest($setPaymentMethod); + } + + /** + * Place the order + * + * @return array + */ + private function doPlaceOrder(): array + { + $placeOrder = <<<QUERY +mutation { + placeOrder( + input: { + cart_id: "{$this->getCartId()}" + } + ) { + order { + order_number + } + } +} +QUERY; + return $this->makeRequest($placeOrder); + } +} diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/ReorderConfigurableWithVariationsTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/ReorderConfigurableWithVariationsTest.php index d29187dc7986d..5bd01e8eaff20 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/ReorderConfigurableWithVariationsTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/ReorderConfigurableWithVariationsTest.php @@ -7,10 +7,12 @@ namespace Magento\GraphQl\Sales; +use Magento\CatalogInventory\Model\StockRegistryStorage; use Magento\Framework\Exception\NoSuchEntityException; use Magento\Integration\Api\CustomerTokenServiceInterface; use Magento\Quote\Api\CartRepositoryInterface; use Magento\TestFramework\Helper\Bootstrap; +use Magento\TestFramework\ObjectManager; use Magento\TestFramework\TestCase\GraphQlAbstract; /** @@ -73,7 +75,6 @@ public function testVariations() $productSku = 'simple_20'; /** @var \Magento\Catalog\Api\Data\ProductInterface $product */ $product = $productRepository->get($productSku); - $this->assertValidVariations(); $this->assertWithOutOfStockVariation($productRepository, $product); } @@ -141,6 +142,10 @@ private function assertWithOutOfStockVariation( \Magento\Catalog\Api\ProductRepositoryInterface $productRepository, \Magento\Catalog\Api\Data\ProductInterface $product ): void { + /** @var $stockRegistryStorage StockRegistryStorage */ + $stockRegistryStorage = Bootstrap::getObjectManager()->get(StockRegistryStorage::class); + // clean stock registry + $stockRegistryStorage->clean(); // make product available in stock but disable and make reorder $product->setStockData( [ diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/RetrieveOrdersWithDownloadableProductTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/RetrieveOrdersWithDownloadableProductTest.php new file mode 100644 index 0000000000000..2f007fb922e57 --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/RetrieveOrdersWithDownloadableProductTest.php @@ -0,0 +1,488 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\GraphQl\Sales; + +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Downloadable\Api\Data\LinkInterface; +use Magento\Framework\Api\SearchCriteriaBuilder; +use Magento\GraphQl\GetCustomerAuthenticationHeader; +use Magento\GraphQl\Sales\Fixtures\CustomerPlaceOrderWithDownloadable; +use Magento\Sales\Api\CreditmemoRepositoryInterface; +use Magento\Sales\Api\OrderRepositoryInterface; +use Magento\Sales\Model\Order; +use Magento\Sales\Model\Order\CreditmemoFactory; +use Magento\Sales\Model\ResourceModel\Order\Collection as OrderCollection; +use Magento\Sales\Model\ResourceModel\Order\Creditmemo\Collection as CreditmemoCollection; +use Magento\Sales\Model\Service\CreditmemoService; +use Magento\TestFramework\Helper\Bootstrap; +use Magento\TestFramework\TestCase\GraphQlAbstract; +use Magento\Sales\Model\Order\Creditmemo\ItemFactory; +use Magento\Framework\Registry; +use Magento\Framework\DB\Transaction; +use Magento\Sales\Api\InvoiceManagementInterface; + +/** + * Tests downloadable product fields in Orders, Invoices, CreditMemo and Shipments + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ +class RetrieveOrdersWithDownloadableProductTest extends GraphQlAbstract +{ + /** @var OrderRepositoryInterface */ + private $orderRepository; + + /** @var SearchCriteriaBuilder */ + private $searchCriteriaBuilder; + + /** @var GetCustomerAuthenticationHeader */ + private $customerAuthenticationHeader; + + /** @var ProductRepositoryInterface */ + private $productRepository; + + /** @var CreditmemoService */ + private $creditMemoService; + + /** @var Order */ + private $order; + + /** @var CreditmemoFactory */ + private $creditMemoFactory; + + /** @var ItemFactory */ + private $creditmemoItemFactory; + + /** @var CustomerPlaceOrderWithDownloadable */ + private $customerPlaceOrderWithDownloadable; + + /** @var InvoiceManagementInterface */ + private $invoiceManagement; + + /** @var OrderCollection */ + private $orderCollection; + + /** @var CreditmemoRepositoryInterface */ + private $creditmemoRepository; + + /** @var CreditmemoCollection */ + private $creditmemoCollection; + + /** @var Registry */ + private $registry; + + /** @var Transaction */ + private $transaction; + + protected function setUp():void + { + $objectManager = Bootstrap::getObjectManager(); + $this->customerAuthenticationHeader = $objectManager->get(GetCustomerAuthenticationHeader::class); + $this->orderRepository = $objectManager->get(OrderRepositoryInterface::class); + $this->searchCriteriaBuilder = $objectManager->get(SearchCriteriaBuilder::class); + $this->productRepository = $objectManager->get(ProductRepositoryInterface::class); + $this->order = $objectManager->create(Order::class); + $this->creditMemoService = $objectManager->get(CreditmemoService::class); + $this->creditMemoFactory = $objectManager->get(CreditmemoFactory::class); + $this->creditmemoItemFactory = $objectManager->create(ItemFactory::class); + $this->customerPlaceOrderWithDownloadable = $objectManager->create(CustomerPlaceOrderWithDownloadable::class); + $this->invoiceManagement = $objectManager->create(InvoiceManagementInterface::class); + $this->orderCollection = $objectManager->create(OrderCollection::class); + $this->creditmemoRepository = $objectManager->get(CreditmemoRepositoryInterface::class); + $this->creditmemoCollection = $objectManager->create(CreditmemoCollection::class); + $this->registry = $objectManager->get(Registry::class); + $this->transaction = $objectManager->create(Transaction::class); + } + + protected function tearDown(): void + { + $this->cleanUpCreditMemos(); + $this->deleteOrder(); + } + + /** + * @magentoApiDataFixture Magento/Downloadable/_files/order_with_customer_and_downloadable_product.php + * @magentoApiDataFixture Magento/Downloadable/_files/customer_order_with_invoice_downloadable_product.php + */ + public function testGetCustomerOrdersDownloadableProduct() + { + $orderNumber = '100000001'; + $customerOrders = $this->getCustomersOrderQuery($orderNumber); + $customerOrderItemsInResponse = $customerOrders[0]['items']; + + $this->assertNotEmpty($customerOrderItemsInResponse); + $downloadableItemInTheOrder = $customerOrderItemsInResponse[0]; + $this->assertEquals( + 'downloadable-product', + $downloadableItemInTheOrder['product_sku'] + ); + $priceOfDownloadableItemInOrder = $downloadableItemInTheOrder['product_sale_price']['value']; + $this->assertEquals(10, $priceOfDownloadableItemInOrder); + $this->assertArrayHasKey('downloadable_links', $downloadableItemInTheOrder); + $downloadableLinksFromResponse = $downloadableItemInTheOrder['downloadable_links']; + $this->assertNotEmpty($downloadableLinksFromResponse); + + $downloadableProduct = $this->productRepository->get('downloadable-product'); + /** @var LinkInterface $downloadableProductLinks */ + $downloadableProductLinks = $downloadableProduct->getExtensionAttributes()->getDownloadableProductLinks(); + $linkId = $downloadableProductLinks[0]->getId(); + $expectedDownloadableLinksData = + [ + [ + 'title' =>'Downloadable Product Link', + 'sort_order' => 1, + 'uid'=> base64_encode("downloadable/{$linkId}") + ] + ]; + $this->assertResponseFields($expectedDownloadableLinksData, $downloadableLinksFromResponse); + // invoices assertions + $customerOrderItemsInvoicesResponse = $customerOrders[0]['invoices'][0]; + $this->assertNotEmpty($customerOrderItemsInvoicesResponse); + $this->assertNotEmpty($customerOrderItemsInvoicesResponse['number']); + $customerOrderItemsInvoicesItemsResponse = $customerOrderItemsInvoicesResponse['items'][0]; + $this->assertEquals('Downloadable Product', $customerOrderItemsInvoicesItemsResponse['product_name']); + $this->assertEquals(10, $customerOrderItemsInvoicesItemsResponse['product_sale_price']['value']); + $this->assertEquals(1, $customerOrderItemsInvoicesItemsResponse['quantity_invoiced']); + $downloadableItemLinks = $customerOrderItemsInvoicesItemsResponse['downloadable_links']; + $this->assertNotEmpty($downloadableItemLinks); + + $downloadableProduct = $this->productRepository->get('downloadable-product'); + /** @var LinkInterface $downloadableProductLinks */ + $downloadableProductLinks = $downloadableProduct->getExtensionAttributes()->getDownloadableProductLinks(); + $linkId = $downloadableProductLinks[0]->getId(); + $expectedDownloadableLinksData = + [ + [ + 'title' =>'Downloadable Product Link', + 'sort_order' => 1, + 'uid'=> base64_encode("downloadable/{$linkId}") + ] + ]; + $this->assertResponseFields($expectedDownloadableLinksData, $downloadableItemLinks); + } + + /** + * @magentoApiDataFixture Magento/Customer/_files/customer.php + * @magentoApiDataFixture Magento/Downloadable/_files/product_downloadable_with_purchased_separately_links.php + */ + public function testGetCustomerOrdersAndCreditMemoDownloadable() + { + //Place order with downloadable product + $qty = 1; + $downloadableSku = 'downloadable-product-with-purchased-separately-links'; + $orderResponse = $this->customerPlaceOrderWithDownloadable->placeOrderWithDownloadableProduct( + ['email' => 'customer@example.com', 'password' => 'password'], + ['sku' => $downloadableSku, 'quantity' => $qty] + ); + $orderNumber = $orderResponse['placeOrder']['order']['order_number']; + //End place order with downloadable product + + // prepare invoice + $this->prepareInvoice($orderNumber, 1); + $order = $this->order->loadByIncrementId($orderNumber); + // Create a credit memo + $creditMemo = $this->creditMemoFactory->createByOrder($order, $order->getData()); + $creditMemo->setOrder($order); + $creditMemo->setState(1); + $creditMemo->setSubtotal(12); + $creditMemo->setBaseSubTotal(12); + $creditMemo->setBaseGrandTotal(12); + $creditMemo->setGrandTotal(12); + $creditMemo->setAdjustment(-2.00); + $creditMemo->addComment("Test comment for downloadable refund", false, true); + $creditMemo->save(); + $this->creditMemoService->refund($creditMemo, true); + $response = $this->getCustomerOrderWithCreditMemoQuery(); + $downloadableProduct = $this->productRepository->get('downloadable-product-with-purchased-separately-links'); + /** @var LinkInterface $downloadableProductLinks */ + $downloadableProductLinks = $downloadableProduct->getExtensionAttributes()->getDownloadableProductLinks(); + $linkId = $downloadableProductLinks[0]->getId(); + $expectedCreditMemoData = [ + [ + 'comments' => [ + ['message' => 'Test comment for downloadable refund'] + ], + 'items' => [ + [ + 'product_name'=> 'Downloadable Product (Links can be purchased separately)', + 'product_sku' => 'downloadable-product-with-purchased-separately-links', + 'product_sale_price' => ['value' => 12], + 'discounts' => [], + 'quantity_refunded' => 1, + 'downloadable_links' => [ + [ + 'uid'=> base64_encode("downloadable/{$linkId}"), + 'title' => 'Downloadable Product Link 1'] + ] + ] + ], + 'total' => [ + 'subtotal' => [ + 'value' => 12 + ], + 'grand_total' => [ + 'value' => 12, + 'currency' => 'USD' + ], + 'base_grand_total' => [ + 'value' => 12, + 'currency' => 'USD' + ], + 'total_shipping' => [ + 'value' => 0 + ], + 'total_tax' => [ + 'value' => 0 + ], + 'shipping_handling' => [ + 'amount_including_tax' => [ + 'value' => 0 + ], + 'amount_excluding_tax' => [ + 'value' => 0 + ], + 'total_amount' => [ + 'value' => 0 + ], + 'taxes' => [] + + ], + 'adjustment' => [ + 'value' => 2 + ] + ] + ] + ]; + $firstOrderItem = current($response['customer']['orders']['items'] ?? []); + $this->assertArrayHasKey('credit_memos', $firstOrderItem); + + $creditMemos = $firstOrderItem['credit_memos']; + $this->assertResponseFields($creditMemos, $expectedCreditMemoData); + } + + /** + * Prepare invoice for the order + * + * @param string $orderNumber + * @param int|null $qty + */ + private function prepareInvoice(string $orderNumber, int $qty = null) + { + /** @var Order $order */ + $order = $this->order->loadByIncrementId($orderNumber); + $orderItem = current($order->getItems()); + $invoice = $this->invoiceManagement->prepareInvoice($order, [$orderItem->getId() => $qty]); + $invoice->register(); + $order = $invoice->getOrder(); + $order->setIsInProcess(true); + $this->transaction->addObject($invoice)->addObject($order)->save(); + } + + /** + * Get customer order query with invoices + * + * @param string $orderNumber + * @return array + */ + private function getCustomersOrderQuery($orderNumber): array + { + $query = + <<<QUERY +{ + customer { + orders(filter:{number:{eq:"{$orderNumber}"}}) { + total_count + items { + id + number + order_date + status + items{ + __typename + product_sku + product_name + product_url_key + product_sale_price{value} + quantity_ordered + discounts{amount{value} label} + ... on DownloadableOrderItem{ + downloadable_links{ + title + sort_order + uid + } + entered_options{value label} + product_sku + product_name + quantity_ordered + } + } + total { + base_grand_total{value currency} + grand_total{value currency} + subtotal {value currency } + total_tax{value currency} + taxes {amount{value currency} title rate} + total_shipping{value currency} + shipping_handling + { + amount_including_tax{value} + amount_excluding_tax{value} + total_amount{value} + discounts{amount{value}} + taxes {amount{value} title rate} + } + discounts {amount{value currency} label} + } + invoices { + number + items { + product_name + product_sale_price{value currency} + quantity_invoiced + ... on DownloadableInvoiceItem { + downloadable_links + { + sort_order + title + uid + } + id + product_name + product_sale_price{value} + quantity_invoiced + } + } + } + } + } + } +} +QUERY; + $currentEmail = 'customer@example.com'; + $currentPassword = 'password'; + $response = $this->graphQlQuery( + $query, + [], + '', + $this->customerAuthenticationHeader->execute($currentEmail, $currentPassword) + ); + + $this->assertArrayHasKey('orders', $response['customer']); + $this->assertArrayHasKey('items', $response['customer']['orders']); + $this->assertNotEmpty($response['customer']['orders']['items']); + $customerOrderItemsInResponse = $response['customer']['orders']['items']; + return $customerOrderItemsInResponse; + } + + /** + * Get CustomerOrder with credit memo details + * + * @return array + */ + private function getCustomerOrderWithCreditMemoQuery(): array + { + $query = + <<<QUERY +query { + customer { + orders { + items { + credit_memos { + comments { message} + items { + product_name + product_sku + product_sale_price {value } + discounts { amount{value currency} label } + quantity_refunded + ... on DownloadableCreditMemoItem + { + product_name + discounts{amount{value}} + downloadable_links{ + uid + title + } + quantity_refunded + } + } + total { + subtotal { + value + } + base_grand_total { + value + currency + } + grand_total { + value + currency + } + total_shipping { + value + } + total_tax { + value + } + shipping_handling { + amount_including_tax{value} + amount_excluding_tax{value} + total_amount{value} + taxes {amount{value} title rate} + + } + adjustment { + value + } + } + } + } + } + } +} +QUERY; + + $currentEmail = 'customer@example.com'; + $currentPassword = 'password'; + $response = $this->graphQlQuery( + $query, + [], + '', + $this->customerAuthenticationHeader->execute($currentEmail, $currentPassword) + ); + return $response; + } + + /** + * @return void + */ + private function deleteOrder(): void + { + $this->registry->unregister('isSecureArea'); + $this->registry->register('isSecureArea', true); + + foreach ($this->orderCollection as $order) { + $this->orderRepository->delete($order); + } + $this->registry->unregister('isSecureArea'); + $this->registry->register('isSecureArea', false); + } + + /** + * @return void + */ + private function cleanUpCreditMemos(): void + { + $this->registry->unregister('isSecureArea'); + $this->registry->register('isSecureArea', true); + foreach ($this->creditmemoCollection as $creditmemo) { + $this->creditmemoRepository->delete($creditmemo); + } + $this->registry->unregister('isSecureArea'); + $this->registry->register('isSecureArea', false); + } +} diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Swatches/ProductSwatchDataTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Swatches/ProductSwatchDataTest.php index 1514613987b40..ae34ea31f0d51 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Swatches/ProductSwatchDataTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Swatches/ProductSwatchDataTest.php @@ -32,8 +32,7 @@ protected function setUp(): void } /** - * @magentoApiDataFixture Magento/Swatches/_files/text_swatch_attribute.php - * @magentoApiDataFixture Magento/ConfigurableProduct/_files/configurable_products.php + * @magentoApiDataFixture Magento/Swatches/_files/configurable_product_text_swatch_attribute.php */ public function testTextSwatchDataValues() { @@ -68,14 +67,15 @@ public function testTextSwatchDataValues() $option = $product['configurable_options'][0]; $this->assertArrayHasKey('values', $option); $length = count($option['values']); + $swatchData = ['Swatch 1', 'Swatch 2', 'Swatch 3']; for ($i = 0; $i < $length; $i++) { - $this->assertEquals('option ' . ($i + 1), $option['values'][$i]['swatch_data']['value']); + $swatchValue = $option['values'][$i]['swatch_data']['value']; + $this->assertContains($swatchValue, $swatchData); } } /** - * @magentoApiDataFixture Magento/Swatches/_files/visual_swatch_attribute_with_different_options_type.php - * @magentoApiDataFixture Magento/ConfigurableProduct/_files/configurable_products.php + * @magentoApiDataFixture Magento/Swatches/_files/configurable_product_with_visual_swatch_attribute.php */ public function testVisualSwatchDataValues() { diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/AddBundleProductToWishlistTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/AddBundleProductToWishlistTest.php index b97cd379e4384..04518fad47052 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/AddBundleProductToWishlistTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/AddBundleProductToWishlistTest.php @@ -98,6 +98,46 @@ public function testAddBundleProductWithOptions(): void $this->assertEquals(Select::NAME, $bundleOptions[0]['type']); } + /** + * @magentoApiDataFixture Magento/Bundle/_files/product_with_multiple_options_and_custom_quantity.php + * @magentoApiDataFixture Magento/Customer/_files/customer.php + * + * @throws Exception + */ + public function testAddingBundleItemWithCustomOptionQuantity() + { + $response = $this->graphQlQuery($this->getProductQuery("bundle-product")); + $bundleItem = $response['products']['items'][0]; + $sku = $bundleItem['sku']; + $bundleOptions = $bundleItem['items']; + $customerId = 1; + $uId0 = $bundleOptions[0]['options'][0]['uid']; + $uId1 = $bundleOptions[1]['options'][0]['uid']; + $query= $this->getQueryWithCustomOptionQuantity($sku, 5, $uId0, $uId1); + $response = $this->graphQlMutation($query, [], '', $this->getHeaderMap()); + $wishlist = $this->wishlistFactory->create()->loadByCustomerId($customerId, true); + /** @var Item $item */ + $item = $wishlist->getItemCollection()->getFirstItem(); + + $this->assertArrayHasKey('addProductsToWishlist', $response); + $this->assertArrayHasKey('wishlist', $response['addProductsToWishlist']); + $response = $response['addProductsToWishlist']['wishlist']; + $this->assertEquals($wishlist->getItemsCount(), $response['items_count']); + $this->assertEquals($wishlist->getSharingCode(), $response['sharing_code']); + $this->assertEquals($wishlist->getUpdatedAt(), $response['updated_at']); + $this->assertEquals($item->getData('qty'), $response['items_v2'][0]['quantity']); + $this->assertEquals($item->getDescription(), $response['items_v2'][0]['description']); + $this->assertEquals($item->getAddedAt(), $response['items_v2'][0]['added_at']); + $this->assertNotEmpty($response['items_v2'][0]['bundle_options']); + $bundleOptions = $response['items_v2'][0]['bundle_options']; + $this->assertEquals('Option 1', $bundleOptions[0]['label']); + $bundleOptionOneValues = $bundleOptions[0]['values']; + $this->assertEquals(7, $bundleOptionOneValues[0]['quantity']); + $this->assertEquals('Option 2', $bundleOptions[1]['label']); + $bundleOptionTwoValues = $bundleOptions[1]['values']; + $this->assertEquals(1, $bundleOptionTwoValues[0]['quantity']); + } + /** * Authentication header map * @@ -179,6 +219,118 @@ private function getQuery( MUTATION; } + /** + * Query with custom option quantity + * + * @param string $sku + * @param int $qty + * @param string $uId0 + * @param string $uId1 + * @param int $wishlistId + * @return string + */ + private function getQueryWithCustomOptionQuantity( + string $sku, + int $qty, + string $uId0, + string $uId1, + int $wishlistId = 0 + ): string { + return <<<MUTATION +mutation { + addProductsToWishlist( + wishlistId: {$wishlistId}, + wishlistItems: [ + { + sku: "{$sku}" + quantity: {$qty} + entered_options: [ + { + uid:"{$uId0}", + value:"7" + }, + { + uid:"{$uId1}", + value:"7" + } + ] + } + ] +) { + user_errors { + code + message + } + wishlist { + id + sharing_code + items_count + updated_at + items_v2 { + id + description + quantity + added_at + ... on BundleWishlistItem { + bundle_options { + id + label + type + values { + id + label + quantity + price + } + } + } + } + } + } +} +MUTATION; + } + + /** + * Returns GraphQL query for retrieving a product with customizable options + * + * @param string $sku + * @return string + */ + private function getProductQuery(string $sku): string + { + return <<<QUERY +{ + products(search: "{$sku}") { + items { + sku + ... on BundleProduct { + items { + sku + option_id + required + type + title + options { + uid + label + product { + sku + } + can_change_quantity + id + price + + quantity + } + } + } + } + } +} +QUERY; + } + /** * @param int $optionId * @param int $selectionId diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/CustomerWishlistTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/CustomerWishlistTest.php index 04095c1679d2f..a38f8f8015d66 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/CustomerWishlistTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/CustomerWishlistTest.php @@ -124,6 +124,150 @@ public function testGuestCannotGetWishlist() $this->graphQlQuery($query); } + /** + * Add product to wishlist with quantity 0 + * + * @magentoConfigFixture default_store wishlist/general/active 1 + * @magentoApiDataFixture Magento/Customer/_files/customer.php + * @magentoApiDataFixture Magento/Catalog/_files/product_simple_duplicated.php + */ + public function testAddProductToWishlistWithZeroQty() + { + $customerWishlistQuery = + <<<QUERY +{ + customer { + wishlist { + id + } + } +} +QUERY; + + $response = $this->graphQlQuery( + $customerWishlistQuery, + [], + '', + $this->getCustomerAuthHeaders('customer@example.com', 'password') + ); + $qty = 0; + $sku = 'simple-1'; + $wishlistId = $response['customer']['wishlist']['id']; + $addProductToWishlistQuery = + <<<QUERY +mutation{ + addProductsToWishlist( + wishlistId:{$wishlistId} + wishlistItems:[ + { + sku:"{$sku}" + quantity:{$qty} + } + ]) + { + wishlist{ + id + items_count + items{product{name sku} description qty} + } + user_errors{code message} + } +} + +QUERY; + $addToWishlistResponse = $this->graphQlMutation( + $addProductToWishlistQuery, + [], + '', + $this->getCustomerAuthHeaders('customer@example.com', 'password') + ); + $this->assertArrayHasKey('user_errors', $addToWishlistResponse['addProductsToWishlist']); + $this->assertCount(1, $addToWishlistResponse['addProductsToWishlist']['user_errors']); + $this->assertEmpty($addToWishlistResponse['addProductsToWishlist']['wishlist']['items']); + $this->assertEquals( + 0, + $addToWishlistResponse['addProductsToWishlist']['wishlist']['items_count'], + 'Count is greater than 0' + ); + $message = 'The quantity of a wish list item cannot be 0'; + $this->assertEquals( + $message, + $addToWishlistResponse['addProductsToWishlist']['user_errors'][0]['message'] + ); + } + + /** + * Add disabled product to wishlist + * + * @magentoConfigFixture default_store wishlist/general/active 1 + * @magentoApiDataFixture Magento/Customer/_files/customer.php + * @magentoApiDataFixture Magento/Catalog/_files/simple_product_disabled.php + */ + public function testAddProductToWishlistWithDisabledProduct() + { + $customerWishlistQuery = + <<<QUERY +{ + customer { + wishlist { + id + } + } +} +QUERY; + + $response = $this->graphQlQuery( + $customerWishlistQuery, + [], + '', + $this->getCustomerAuthHeaders('customer@example.com', 'password') + ); + $qty = 2; + $sku = 'product_disabled'; + $wishlistId = $response['customer']['wishlist']['id']; + $addProductToWishlistQuery = + <<<QUERY +mutation{ + addProductsToWishlist( + wishlistId:{$wishlistId} + wishlistItems:[ + { + sku:"{$sku}" + quantity:{$qty} + } + ]) + { + wishlist{ + id + items_count + items{product{name sku} description qty} + } + user_errors{code message} + } +} + +QUERY; + $addToWishlistResponse = $this->graphQlMutation( + $addProductToWishlistQuery, + [], + '', + $this->getCustomerAuthHeaders('customer@example.com', 'password') + ); + $this->assertArrayHasKey('user_errors', $addToWishlistResponse['addProductsToWishlist']); + $this->assertCount(1, $addToWishlistResponse['addProductsToWishlist']['user_errors']); + $this->assertEmpty($addToWishlistResponse['addProductsToWishlist']['wishlist']['items']); + $this->assertEquals( + 0, + $addToWishlistResponse['addProductsToWishlist']['wishlist']['items_count'], + 'Count is greater than 0' + ); + $message = 'The product is disabled'; + $this->assertEquals( + $message, + $addToWishlistResponse['addProductsToWishlist']['user_errors'][0]['message'] + ); + } + /** * @magentoConfigFixture default_store wishlist/general/active 0 * @magentoApiDataFixture Magento/Customer/_files/customer.php diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/DeleteProductsFromWishlistTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/DeleteProductsFromWishlistTest.php index 13aaecbc7b733..dd7a54cff32a0 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/DeleteProductsFromWishlistTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/DeleteProductsFromWishlistTest.php @@ -55,6 +55,34 @@ public function testDeleteWishlistItemFromWishlist(): void $this->assertEmpty($wishlistResponse['items_v2']); } + /** + * Test deleting the wishlist item of another customer + * + * @magentoConfigFixture default_store wishlist/general/active 1 + * @magentoApiDataFixture Magento/Wishlist/_files/two_wishlists_for_two_diff_customers.php + */ + public function testUnauthorizedWishlistItemDelete() + { + $wishlist = $this->getWishlist(); + $wishlistItem = $wishlist['customer']['wishlist']['items_v2'][0]; + $wishlist2 = $this->getWishlist('customer_two@example.com'); + $wishlist2Id = $wishlist2['customer']['wishlist']['id']; + $query = $this->getQuery((int) $wishlist2Id, (int) $wishlistItem['id']); + $response = $this->graphQlMutation( + $query, + [], + '', + $this->getHeaderMap('customer_two@example.com') + ); + self::assertEquals(1, $response['removeProductsFromWishlist']['wishlist']['items_count']); + self::assertNotEmpty($response['removeProductsFromWishlist']['wishlist']['items_v2'], 'empty wish list items'); + self::assertCount(1, $response['removeProductsFromWishlist']['wishlist']['items_v2']); + self::assertEquals( + 'The wishlist item with ID "'.$wishlistItem['id'].'" does not belong to the wishlist', + $response['removeProductsFromWishlist']['user_errors'][0]['message'] + ); + } + /** * Authentication header map * @@ -116,9 +144,9 @@ private function getQuery( * * @throws Exception */ - public function getWishlist(): array + public function getWishlist(string $username = 'customer@example.com'): array { - return $this->graphQlQuery($this->getCustomerWishlistQuery(), [], '', $this->getHeaderMap()); + return $this->graphQlQuery($this->getCustomerWishlistQuery(), [], '', $this->getHeaderMap($username)); } /** diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/UpdateProductsFromWishlistTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/UpdateProductsFromWishlistTest.php index 08273e7936640..691c06782070f 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/UpdateProductsFromWishlistTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/UpdateProductsFromWishlistTest.php @@ -57,6 +57,88 @@ public function testUpdateSimpleProductFromWishlist(): void $this->assertEquals($description, $wishlistResponse['items_v2'][0]['description']); } + /** + * Test updating the wishlist item of another customer + * + * @magentoConfigFixture default_store wishlist/general/active 1 + * @magentoApiDataFixture Magento/Customer/_files/two_customers.php + * @magentoApiDataFixture Magento/Wishlist/_files/two_wishlists_for_two_diff_customers.php + */ + public function testUnauthorizedWishlistItemUpdate() + { + $wishlist = $this->getWishlist(); + $wishlistItem = $wishlist['customer']['wishlist']['items_v2'][0]; + $wishlist2 = $this->getWishlist('customer_two@example.com'); + $wishlist2Id = $wishlist2['customer']['wishlist']['id']; + $qty = 2; + $description = 'New Description'; + $updateWishlistQuery = $this->getQuery((int) $wishlist2Id, (int) $wishlistItem['id'], $qty, $description); + $response = $this->graphQlMutation( + $updateWishlistQuery, + [], + '', + $this->getHeaderMap('customer_two@example.com') + ); + self::assertEquals(1, $response['updateProductsInWishlist']['wishlist']['items_count']); + self::assertNotEmpty($response['updateProductsInWishlist']['wishlist']['items_v2'], 'empty wish list items'); + self::assertCount(1, $response['updateProductsInWishlist']['wishlist']['items_v2']); + self::assertEquals( + 'The wishlist item with ID "'.$wishlistItem['id'].'" does not belong to the wishlist', + $response['updateProductsInWishlist']['user_errors'][0]['message'] + ); + } + + /** + * update the wishlist by setting an qty = 0 + * + * @magentoConfigFixture default_store wishlist/general/active 1 + * @magentoApiDataFixture Magento/Customer/_files/customer.php + * @magentoApiDataFixture Magento/Wishlist/_files/wishlist_with_simple_product.php + */ + public function testUpdateProductInWishlistWithZeroQty() + { + $wishlist = $this->getWishlist(); + $wishlistId = $wishlist['customer']['wishlist']['id']; + $wishlistItem = $wishlist['customer']['wishlist']['items_v2'][0]; + $qty = 0; + $description = 'Description for zero quantity'; + $updateWishlistQuery = $this->getQuery((int) $wishlistId, (int) $wishlistItem['id'], $qty, $description); + $response = $this->graphQlMutation($updateWishlistQuery, [], '', $this->getHeaderMap()); + self::assertEquals(1, $response['updateProductsInWishlist']['wishlist']['items_count']); + self::assertNotEmpty($response['updateProductsInWishlist']['wishlist']['items_v2'], 'empty wish list items'); + self::assertCount(1, $response['updateProductsInWishlist']['wishlist']['items_v2']); + self::assertArrayHasKey('user_errors', $response['updateProductsInWishlist']); + self::assertCount(1, $response['updateProductsInWishlist']['user_errors']); + $message = 'The quantity of a wish list item cannot be 0'; + self::assertEquals( + $message, + $response['updateProductsInWishlist']['user_errors'][0]['message'] + ); + } + + /** + * update the wishlist by setting qty to a valid value and no description + * + * @magentoConfigFixture default_store wishlist/general/active 1 + * @magentoApiDataFixture Magento/Customer/_files/customer.php + * @magentoApiDataFixture Magento/Wishlist/_files/wishlist_with_simple_product.php + */ + public function testUpdateProductWithValidQtyAndNoDescription() + { + $wishlist = $this->getWishlist(); + $wishlistId = $wishlist['customer']['wishlist']['id']; + $wishlistItem = $wishlist['customer']['wishlist']['items_v2'][0]; + $qty = 2; + $updateWishlistQuery = $this->getQueryWithNoDescription((int) $wishlistId, (int) $wishlistItem['id'], $qty); + $response = $this->graphQlMutation($updateWishlistQuery, [], '', $this->getHeaderMap()); + self::assertEquals(1, $response['updateProductsInWishlist']['wishlist']['items_count']); + self::assertNotEmpty($response['updateProductsInWishlist']['wishlist']['items'], 'empty wish list items'); + self::assertCount(1, $response['updateProductsInWishlist']['wishlist']['items']); + $itemsInWishlist = $response['updateProductsInWishlist']['wishlist']['items'][0]; + self::assertEquals($qty, $itemsInWishlist['qty']); + self::assertEquals('simple-1', $itemsInWishlist['product']['sku']); + } + /** * Authentication header map * @@ -121,16 +203,62 @@ private function getQuery( MUTATION; } + /** + * Returns GraphQl mutation string + * + * @param int $wishlistId + * @param int $wishlistItemId + * @param int $qty + * + * @return string + */ + private function getQueryWithNoDescription( + int $wishlistId, + int $wishlistItemId, + int $qty + ): string { + return <<<MUTATION +mutation { + updateProductsInWishlist( + wishlistId: {$wishlistId}, + wishlistItems: [ + { + wishlist_item_id: "{$wishlistItemId}" + quantity: {$qty} + + } + ] +) { + user_errors { + code + message + } + wishlist { + id + sharing_code + items_count + items { + id + qty + product{sku name} + } + } + } +} +MUTATION; + } + /** * Get wishlist result * + * @param string $username * @return array * * @throws Exception */ - public function getWishlist(): array + public function getWishlist(string $username = 'customer@example.com'): array { - return $this->graphQlQuery($this->getCustomerWishlistQuery(), [], '', $this->getHeaderMap()); + return $this->graphQlQuery($this->getCustomerWishlistQuery(), [], '', $this->getHeaderMap($username)); } /** diff --git a/dev/tests/api-functional/testsuite/Magento/Sales/Service/V1/OrderAddressUpdateTest.php b/dev/tests/api-functional/testsuite/Magento/Sales/Service/V1/OrderAddressUpdateTest.php index c5b06285f1fe1..ef47b8819f73b 100644 --- a/dev/tests/api-functional/testsuite/Magento/Sales/Service/V1/OrderAddressUpdateTest.php +++ b/dev/tests/api-functional/testsuite/Magento/Sales/Service/V1/OrderAddressUpdateTest.php @@ -29,7 +29,7 @@ public function testOrderAddressUpdate() $order = $objectManager->get(\Magento\Sales\Model\Order::class)->loadByIncrementId('100000001'); $address = [ - OrderAddress::REGION => 'California', + OrderAddress::REGION => 'CA', OrderAddress::POSTCODE => '11111', OrderAddress::LASTNAME => 'lastname', OrderAddress::STREET => ['street'], @@ -76,7 +76,7 @@ public function testOrderAddressUpdate() $billingAddress = $actualOrder->getBillingAddress(); $validate = [ - OrderAddress::REGION => 'California', + OrderAddress::REGION => 'CA', OrderAddress::POSTCODE => '11111', OrderAddress::LASTNAME => 'lastname', OrderAddress::STREET => 'street', diff --git a/dev/tests/api-functional/testsuite/Magento/Sales/Service/V1/OrderGetTest.php b/dev/tests/api-functional/testsuite/Magento/Sales/Service/V1/OrderGetTest.php index e28cca72e8fb8..021698f874e55 100644 --- a/dev/tests/api-functional/testsuite/Magento/Sales/Service/V1/OrderGetTest.php +++ b/dev/tests/api-functional/testsuite/Magento/Sales/Service/V1/OrderGetTest.php @@ -76,7 +76,7 @@ public function testOrderGet(): void 'city' => 'Los Angeles', 'email' => 'customer@null.com', 'postcode' => '11111', - 'region' => 'California' + 'region' => 'CA' ]; $result = $this->makeServiceCall(self::ORDER_INCREMENT_ID); diff --git a/dev/tests/api-functional/testsuite/Magento/Sales/Service/V1/ShipOrderTest.php b/dev/tests/api-functional/testsuite/Magento/Sales/Service/V1/ShipOrderTest.php index 2d8c308389452..64fc612120332 100644 --- a/dev/tests/api-functional/testsuite/Magento/Sales/Service/V1/ShipOrderTest.php +++ b/dev/tests/api-functional/testsuite/Magento/Sales/Service/V1/ShipOrderTest.php @@ -62,6 +62,7 @@ public function testConfigurableShipOrder() $shipmentId = (int)$this->_webApiCall($this->getServiceInfo($existingOrder), $requestData); $this->assertNotEmpty($shipmentId); + $shipment = null; try { $shipment = $this->shipmentRepository->get($shipmentId); } catch (\Magento\Framework\Exception\NoSuchEntityException $e) { @@ -89,6 +90,42 @@ public function testConfigurableShipOrder() ); } + /** + * Tests that order doesn't change a status from custom to the default after shipment creation. + * + * @magentoApiDataFixture Magento/Sales/_files/order_status.php + */ + public function testShipOrderStatusPreserve() + { + $incrementId = '100000001'; + $orderStatus = 'example'; + + /** @var Order $existingOrder */ + $order = $this->getOrder($incrementId); + $this->assertEquals($orderStatus, $order->getStatus()); + + $requestData = [ + 'orderId' => $order->getId() + ]; + /** @var OrderItemInterface $item */ + foreach ($order->getAllItems() as $item) { + $requestData['items'][] = [ + 'order_item_id' => $item->getItemId(), + 'qty' => $item->getQtyOrdered(), + ]; + } + + $shipmentId = $this->_webApiCall($this->getServiceInfo($order), $requestData); + $this->assertNotEmpty($shipmentId); + $actualOrder = $this->getOrder($order->getIncrementId()); + + $this->assertEquals( + $order->getStatus(), + $actualOrder->getStatus(), + 'Failed asserting that Order status wasn\'t changed' + ); + } + /** * @magentoApiDataFixture Magento/Sales/_files/order_new.php */ @@ -214,6 +251,7 @@ public function testPartialShipOrderWithBundleShippedSeparately() $shipmentId = $this->_webApiCall($this->getServiceInfo($existingOrder), $requestData); $this->assertNotEmpty($shipmentId); + $shipment = null; try { $shipment = $this->shipmentRepository->get($shipmentId); } catch (\Magento\Framework\Exception\NoSuchEntityException $e) { @@ -268,6 +306,7 @@ public function testPartialShipOrderWithTwoBundleShippedSeparatelyContainsSameSi $shipmentId = $this->_webApiCall($this->getServiceInfo($order), $requestData); $this->assertNotEmpty($shipmentId); + $shipment = null; try { $shipment = $this->shipmentRepository->get($shipmentId); } catch (\Magento\Framework\Exception\NoSuchEntityException $e) { diff --git a/dev/tests/api-functional/testsuite/Magento/Sales/Service/V1/ShipmentCreateTest.php b/dev/tests/api-functional/testsuite/Magento/Sales/Service/V1/ShipmentCreateTest.php index 29a11f9d68e8f..dab4ad05f84d3 100644 --- a/dev/tests/api-functional/testsuite/Magento/Sales/Service/V1/ShipmentCreateTest.php +++ b/dev/tests/api-functional/testsuite/Magento/Sales/Service/V1/ShipmentCreateTest.php @@ -6,10 +6,6 @@ namespace Magento\Sales\Service\V1; -use Magento\Framework\ObjectManagerInterface; -use Magento\Framework\Webapi\Rest\Request; -use Magento\Sales\Model\Order; -use Magento\TestFramework\Helper\Bootstrap; use Magento\TestFramework\TestCase\WebapiAbstract; /** @@ -26,78 +22,23 @@ class ShipmentCreateTest extends WebapiAbstract const SERVICE_VERSION = 'V1'; /** - * @var ObjectManagerInterface + * @var \Magento\Framework\ObjectManagerInterface */ protected $objectManager; protected function setUp(): void { - $this->objectManager = Bootstrap::getObjectManager(); + $this->objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); } /** - * Test save shipment return valid result with multiple tracks with multiple comments - * * @magentoApiDataFixture Magento/Sales/_files/order.php */ - public function testInvokeWithMultipleTrackAndComments() + public function testInvoke() { - $data = $this->getEntityData(); - $result = $this->_webApiCall( - $this->getServiceInfo(), - [ - 'entity' => $data['shipment data with multiple tracking and multiple comments']] - ); - $this->assertNotEmpty($result); - $this->assertEquals(3, count($result['tracks'])); - $this->assertEquals(3, count($result['comments'])); - } - - /** - * Test save shipment return valid result with multiple tracks with no comments - * - * @magentoApiDataFixture Magento/Sales/_files/order.php - */ - public function testInvokeWithMultipleTrackAndNoComments() - { - $data = $this->getEntityData(); - $result = $this->_webApiCall( - $this->getServiceInfo(), - [ - 'entity' => $data['shipment data with multiple tracking']] - ); - $this->assertNotEmpty($result); - $this->assertEquals(3, count($result['tracks'])); - $this->assertEquals(0, count($result['comments'])); - } - - /** - * Test save shipment return valid result with no tracks with multiple comments - * - * @magentoApiDataFixture Magento/Sales/_files/order.php - */ - public function testInvokeWithNoTrackAndMultipleComments() - { - $data = $this->getEntityData(); - $result = $this->_webApiCall( - $this->getServiceInfo(), - [ - 'entity' => $data['shipment data with multiple comments']] - ); - $this->assertNotEmpty($result); - $this->assertEquals(0, count($result['tracks'])); - $this->assertEquals(3, count($result['comments'])); - } - - /** - * @return array - * @SuppressWarnings(PHPMD.ExcessiveMethodLength) - */ - public function getEntityData() - { - $existingOrder = $this->getOrder('100000001'); - $orderItem = current($existingOrder->getAllItems()); - + /** @var \Magento\Sales\Model\Order $order */ + $order = $this->objectManager->create(\Magento\Sales\Model\Order::class)->loadByIncrementId('100000001'); + $orderItem = current($order->getAllItems()); $items = [ [ 'order_item_id' => $orderItem->getId(), @@ -114,201 +55,10 @@ public function getEntityData() 'weight' => null, ], ]; - return [ - 'shipment data with multiple tracking and multiple comments' => [ - 'order_id' => $existingOrder->getId(), - 'entity_id' => null, - 'store_id' => null, - 'total_weight' => null, - 'total_qty' => null, - 'email_sent' => null, - 'customer_id' => null, - 'shipping_address_id' => null, - 'billing_address_id' => null, - 'shipment_status' => null, - 'increment_id' => null, - 'created_at' => null, - 'updated_at' => null, - 'shipping_label' => null, - 'tracks' => [ - [ - 'carrier_code' => 'UPS', - 'order_id' => $existingOrder->getId(), - 'title' => 'ground', - 'description' => null, - 'track_number' => '12345678', - 'parent_id' => null, - 'created_at' => null, - 'updated_at' => null, - 'qty' => null, - 'weight' => null - ], - [ - 'carrier_code' => 'UPS', - 'order_id' => $existingOrder->getId(), - 'title' => 'ground', - 'description' => null, - 'track_number' => '654563221', - 'parent_id' => null, - 'created_at' => null, - 'updated_at' => null, - 'qty' => null, - 'weight' => null - ], - [ - 'carrier_code' => 'USPS', - 'order_id' => $existingOrder->getId(), - 'title' => 'ground', - 'description' => null, - 'track_number' => '789654565', - 'parent_id' => null, - 'created_at' => null, - 'updated_at' => null, - 'qty' => null, - 'weight' => null - ] - ], - 'items' => $items, - 'comments' => [ - [ - 'comment' => 'Shipment-related comment-1.', - 'is_customer_notified' => null, - 'is_visible_on_front' => null, - 'parent_id' => null - ], - [ - 'comment' => 'Shipment-related comment-2.', - 'is_customer_notified' => null, - 'is_visible_on_front' => null, - 'parent_id' => null - ], - [ - 'comment' => 'Shipment-related comment-3.', - 'is_customer_notified' => null, - 'is_visible_on_front' => null, - 'parent_id' => null - ] - - ] - ], - 'shipment data with multiple tracking' => [ - 'order_id' => $existingOrder->getId(), - 'entity_id' => null, - 'store_id' => null, - 'total_weight' => null, - 'total_qty' => null, - 'email_sent' => null, - 'customer_id' => null, - 'shipping_address_id' => null, - 'billing_address_id' => null, - 'shipment_status' => null, - 'increment_id' => null, - 'created_at' => null, - 'updated_at' => null, - 'shipping_label' => null, - 'tracks' => [ - [ - 'carrier_code' => 'UPS', - 'order_id' => $existingOrder->getId(), - 'title' => 'ground', - 'description' => null, - 'track_number' => '12345678', - 'parent_id' => null, - 'created_at' => null, - 'updated_at' => null, - 'qty' => null, - 'weight' => null - ], - [ - 'carrier_code' => 'UPS', - 'order_id' => $existingOrder->getId(), - 'title' => 'ground', - 'description' => null, - 'track_number' => '654563221', - 'parent_id' => null, - 'created_at' => null, - 'updated_at' => null, - 'qty' => null, - 'weight' => null - ], - [ - 'carrier_code' => 'USPS', - 'order_id' => $existingOrder->getId(), - 'title' => 'ground', - 'description' => null, - 'track_number' => '789654565', - 'parent_id' => null, - 'created_at' => null, - 'updated_at' => null, - 'qty' => null, - 'weight' => null - ] - ], - 'items' => $items, - 'comments' => [] - ], - 'shipment data with multiple comments' => [ - 'order_id' => $existingOrder->getId(), - 'entity_id' => null, - 'store_id' => null, - 'total_weight' => null, - 'total_qty' => null, - 'email_sent' => null, - 'customer_id' => null, - 'shipping_address_id' => null, - 'billing_address_id' => null, - 'shipment_status' => null, - 'increment_id' => null, - 'created_at' => null, - 'updated_at' => null, - 'shipping_label' => null, - 'tracks' => [], - 'items' => $items, - 'comments' => [ - [ - 'comment' => 'Shipment-related comment-1.', - 'is_customer_notified' => null, - 'is_visible_on_front' => null, - 'parent_id' => null - ], - [ - 'comment' => 'Shipment-related comment-2.', - 'is_customer_notified' => null, - 'is_visible_on_front' => null, - 'parent_id' => null - ], - [ - 'comment' => 'Shipment-related comment-3.', - 'is_customer_notified' => null, - 'is_visible_on_front' => null, - 'parent_id' => null - ] - - ] - ] - ]; - } - - /** - * Returns order by increment id. - * - * @param string $incrementId - * @return Order - */ - private function getOrder(string $incrementId): Order - { - return $this->objectManager->create(Order::class)->loadByIncrementId($incrementId); - } - - /** - * @return array - */ - private function getServiceInfo(): array - { - return [ + $serviceInfo = [ 'rest' => [ 'resourcePath' => self::RESOURCE_PATH, - 'httpMethod' => Request::HTTP_METHOD_POST, + 'httpMethod' => \Magento\Framework\Webapi\Rest\Request::HTTP_METHOD_POST, ], 'soap' => [ 'service' => self::SERVICE_READ_NAME, @@ -316,5 +66,46 @@ private function getServiceInfo(): array 'operation' => self::SERVICE_READ_NAME . 'save', ], ]; + $data = [ + 'order_id' => $order->getId(), + 'entity_id' => null, + 'store_id' => null, + 'total_weight' => null, + 'total_qty' => null, + 'email_sent' => null, + 'customer_id' => null, + 'shipping_address_id' => null, + 'billing_address_id' => null, + 'shipment_status' => null, + 'increment_id' => null, + 'created_at' => null, + 'updated_at' => null, + 'shipping_label' => null, + 'tracks' => [ + [ + 'carrier_code' => 'UPS', + 'order_id' => $order->getId(), + 'title' => 'ground', + 'description' => null, + 'track_number' => '12345678', + 'parent_id' => null, + 'created_at' => null, + 'updated_at' => null, + 'qty' => null, + 'weight' => null + ] + ], + 'items' => $items, + 'comments' => [ + [ + 'comment' => 'Shipment-related comment.', + 'is_customer_notified' => null, + 'is_visible_on_front' => null, + 'parent_id' => null + ] + ], + ]; + $result = $this->_webApiCall($serviceInfo, ['entity' => $data]); + $this->assertNotEmpty($result); } } diff --git a/dev/tests/api-functional/testsuite/Magento/WebApiTest.php b/dev/tests/api-functional/testsuite/Magento/WebApiTest.php index 32670dfeb7b1b..cc07d7b761f2b 100644 --- a/dev/tests/api-functional/testsuite/Magento/WebApiTest.php +++ b/dev/tests/api-functional/testsuite/Magento/WebApiTest.php @@ -11,9 +11,16 @@ use Magento\TestFramework\Workaround\Override\Config; use Magento\TestFramework\Workaround\Override\WrapperGenerator; use PHPUnit\Framework\TestSuite; +use PHPUnit\TextUI\Configuration\Configuration as LegacyConfiguration; use PHPUnit\TextUI\Configuration\Registry; -use PHPUnit\TextUI\Configuration\TestSuiteCollection; -use PHPUnit\TextUI\Configuration\TestSuiteMapper; +use PHPUnit\TextUI\Configuration\TestSuite as LegacyTestSuiteConfiguration; +use PHPUnit\TextUI\Configuration\TestSuiteCollection as LegacyTestSuiteCollection; +use PHPUnit\TextUI\Configuration\TestSuiteMapper as LegacyTestSuiteMapper; +use PHPUnit\TextUI\XmlConfiguration\Configuration; +use PHPUnit\TextUI\XmlConfiguration\Loader; +use PHPUnit\TextUI\XmlConfiguration\TestSuite as TestSuiteConfiguration; +use PHPUnit\TextUI\XmlConfiguration\TestSuiteCollection; +use PHPUnit\TextUI\XmlConfiguration\TestSuiteMapper; /** * Web API tests wrapper. @@ -24,17 +31,17 @@ class WebApiTest extends TestSuite * @SuppressWarnings(PHPMD.UnusedFormalParameter) * @param string $className * @return TestSuite + * @throws \ReflectionException */ public static function suite($className) { $generator = new WrapperGenerator(); $overrideConfig = Config::getInstance(); - $configuration = Registry::getInstance()->get(self::getConfigurationFile()); + $configuration = self::getConfiguration(); $suitesConfig = $configuration->testSuite(); $suite = new TestSuite(); - /** @var \PHPUnit\TextUI\Configuration\TestSuite $suiteConfig */ foreach ($suitesConfig as $suiteConfig) { - $suites = (new TestSuiteMapper())->map(TestSuiteCollection::fromArray([$suiteConfig]), ''); + $suites = self::getSuites($suiteConfig); /** @var TestSuite $testSuite */ foreach ($suites as $testSuite) { /** @var TestSuite $test */ @@ -68,4 +75,39 @@ private static function getConfigurationFile(): string return $shortConfig ? $shortConfig : $longConfig; } + + /** + * Retrieve configuration depends on used phpunit version + * + * @return Configuration|LegacyConfiguration + */ + private static function getConfiguration() + { + // Compatibility with phpunit < 9.3 + if (!class_exists(Configuration::class)) { + // @phpstan-ignore-next-line + return Registry::getInstance()->get(self::getConfigurationFile()); + } + + // @phpstan-ignore-next-line + return (new Loader())->load(self::getConfigurationFile()); + } + + /** + * Retrieve test suites by suite config depends on used phpunit version + * + * @param TestSuiteConfiguration|LegacyTestSuiteConfiguration $suiteConfig + * @return TestSuite + */ + private static function getSuites($suiteConfig) + { + // Compatibility with phpunit < 9.3 + if (!class_exists(Configuration::class)) { + // @phpstan-ignore-next-line + return (new LegacyTestSuiteMapper())->map(LegacyTestSuiteCollection::fromArray([$suiteConfig]), ''); + } + + // @phpstan-ignore-next-line + return (new TestSuiteMapper())->map(TestSuiteCollection::fromArray([$suiteConfig]), ''); + } } diff --git a/dev/tests/integration/_files/Magento/TestModuleCatalogInventoryCache/Plugin/PreventCachingPreloadedStockDataInToStockRegistry.php b/dev/tests/integration/_files/Magento/TestModuleCatalogInventoryCache/Plugin/PreventCachingPreloadedStockDataInToStockRegistry.php new file mode 100644 index 0000000000000..95705afb0c5a8 --- /dev/null +++ b/dev/tests/integration/_files/Magento/TestModuleCatalogInventoryCache/Plugin/PreventCachingPreloadedStockDataInToStockRegistry.php @@ -0,0 +1,21 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\TestModuleCatalogInventoryCache\Plugin; + +class PreventCachingPreloadedStockDataInToStockRegistry +{ + public function aroundSetStockItems(): void + { + //do not cache + } + + public function aroundSetStockStatuses(): void + { + //do not cache + } +} diff --git a/dev/tests/integration/_files/Magento/TestModuleCatalogInventoryCache/etc/di.xml b/dev/tests/integration/_files/Magento/TestModuleCatalogInventoryCache/etc/di.xml new file mode 100644 index 0000000000000..d539c3aad158d --- /dev/null +++ b/dev/tests/integration/_files/Magento/TestModuleCatalogInventoryCache/etc/di.xml @@ -0,0 +1,12 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd"> + <type name="Magento\CatalogInventory\Model\StockRegistryPreloader"> + <plugin name="prevent_caching_preloaded_stock_data" type="Magento\TestModuleCatalogInventoryCache\Plugin\PreventCachingPreloadedStockDataInToStockRegistry"/> + </type> +</config> diff --git a/dev/tests/integration/_files/Magento/TestModuleCatalogInventoryCache/etc/module.xml b/dev/tests/integration/_files/Magento/TestModuleCatalogInventoryCache/etc/module.xml new file mode 100644 index 0000000000000..4446f4186d30c --- /dev/null +++ b/dev/tests/integration/_files/Magento/TestModuleCatalogInventoryCache/etc/module.xml @@ -0,0 +1,10 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd"> + <module name="Magento_TestModuleCatalogInventoryCache" /> +</config> diff --git a/dev/tests/integration/_files/Magento/TestModuleCatalogInventoryCache/registration.php b/dev/tests/integration/_files/Magento/TestModuleCatalogInventoryCache/registration.php new file mode 100644 index 0000000000000..15279c9839dd2 --- /dev/null +++ b/dev/tests/integration/_files/Magento/TestModuleCatalogInventoryCache/registration.php @@ -0,0 +1,13 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +use Magento\Framework\Component\ComponentRegistrar; + +$registrar = new ComponentRegistrar(); +if ($registrar->getPath(ComponentRegistrar::MODULE, 'Magento_TestModuleCatalogInventoryCache') === null) { + ComponentRegistrar::register(ComponentRegistrar::MODULE, 'Magento_TestModuleCatalogInventoryCache', __DIR__); +} diff --git a/dev/tests/integration/_files/Magento/TestModuleFakePaymentMethod/etc/config.xml b/dev/tests/integration/_files/Magento/TestModuleFakePaymentMethod/etc/config.xml index 42c21d544d01e..adf5af6f037ab 100644 --- a/dev/tests/integration/_files/Magento/TestModuleFakePaymentMethod/etc/config.xml +++ b/dev/tests/integration/_files/Magento/TestModuleFakePaymentMethod/etc/config.xml @@ -32,6 +32,11 @@ <supported>1</supported> </instant_purchase> </fake_vault> + <fake_no_model> + <!-- This method on purpose does not have a 'model' node. --> + <title>Fake Payment Method without <model> + 0 + - \ No newline at end of file + diff --git a/dev/tests/integration/etc/di/preferences/ce.php b/dev/tests/integration/etc/di/preferences/ce.php index 9374fb4dfe5df..6f0794d50c42e 100644 --- a/dev/tests/integration/etc/di/preferences/ce.php +++ b/dev/tests/integration/etc/di/preferences/ce.php @@ -5,30 +5,26 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +use \Magento\Framework\App; +use \Magento\Framework as MF; +use \Magento\TestFramework as TF; return [ - \Magento\Framework\Stdlib\CookieManagerInterface::class => \Magento\TestFramework\CookieManager::class, - \Magento\Framework\ObjectManager\DynamicConfigInterface::class => - \Magento\TestFramework\ObjectManager\Configurator::class, - \Magento\Framework\App\RequestInterface::class => \Magento\TestFramework\Request::class, - \Magento\Framework\App\Request\Http::class => \Magento\TestFramework\Request::class, - \Magento\Framework\App\ResponseInterface::class => \Magento\TestFramework\Response::class, - \Magento\Framework\App\Response\Http::class => \Magento\TestFramework\Response::class, - \Magento\Framework\Interception\PluginListInterface::class => - \Magento\TestFramework\Interception\PluginList::class, - \Magento\Framework\Interception\ObjectManager\ConfigInterface::class => - \Magento\TestFramework\ObjectManager\Config::class, - \Magento\Framework\Interception\ObjectManager\Config\Developer::class => - \Magento\TestFramework\ObjectManager\Config::class, - \Magento\Framework\View\LayoutInterface::class => \Magento\TestFramework\View\Layout::class, - \Magento\Framework\App\ResourceConnection\ConnectionAdapterInterface::class => - \Magento\TestFramework\Db\ConnectionAdapter::class, - \Magento\Framework\Filesystem\DriverInterface::class => \Magento\Framework\Filesystem\Driver\File::class, - \Magento\Framework\App\Config\ScopeConfigInterface::class => \Magento\TestFramework\App\Config::class, - \Magento\Framework\App\ResourceConnection\ConfigInterface::class => - \Magento\Framework\App\ResourceConnection\Config::class, - \Magento\Framework\Lock\Backend\Database::class => - \Magento\TestFramework\Lock\Backend\DummyLocker::class, - \Magento\Framework\Session\SessionStartChecker::class => \Magento\TestFramework\Session\SessionStartChecker::class, - \Magento\Framework\HTTP\AsyncClientInterface::class => \Magento\TestFramework\HTTP\AsyncClientInterfaceMock::class + MF\Stdlib\CookieManagerInterface::class => TF\CookieManager::class, + MF\ObjectManager\DynamicConfigInterface::class => TF\ObjectManager\Configurator::class, + App\RequestInterface::class => TF\Request::class, + App\Request\Http::class => TF\Request::class, + App\ResponseInterface::class => TF\Response::class, + App\Response\Http::class => TF\Response::class, + MF\Interception\PluginListInterface::class => TF\Interception\PluginList::class, + MF\Interception\ObjectManager\ConfigInterface::class => TF\ObjectManager\Config::class, + MF\Interception\ObjectManager\Config\Developer::class => TF\ObjectManager\Config::class, + MF\View\LayoutInterface::class => TF\View\Layout::class, + App\ResourceConnection\ConnectionAdapterInterface::class => TF\Db\ConnectionAdapter::class, + MF\Filesystem\DriverInterface::class => MF\Filesystem\Driver\File::class, + App\Config\ScopeConfigInterface::class => TF\App\Config::class, + App\ResourceConnection\ConfigInterface::class => App\ResourceConnection\Config::class, + MF\Lock\Backend\Database::class => TF\Lock\Backend\DummyLocker::class, + MF\Session\SessionStartChecker::class => TF\Session\SessionStartChecker::class, + MF\HTTP\AsyncClientInterface::class => TF\HTTP\AsyncClientInterfaceMock::class ]; diff --git a/dev/tests/integration/framework/Magento/TestFramework/Annotation/AbstractDataFixture.php b/dev/tests/integration/framework/Magento/TestFramework/Annotation/AbstractDataFixture.php index 9172d7cf857e5..14799cd56e635 100644 --- a/dev/tests/integration/framework/Magento/TestFramework/Annotation/AbstractDataFixture.php +++ b/dev/tests/integration/framework/Magento/TestFramework/Annotation/AbstractDataFixture.php @@ -68,7 +68,7 @@ protected function _getFixtures(TestCase $test, $scope = null) protected function getAnnotations(TestCase $test): array { $annotations = $test->getAnnotations(); - return array_replace($annotations['class'], $annotations['method']); + return array_replace((array)$annotations['class'], (array)$annotations['method']); } /** diff --git a/dev/tests/integration/framework/Magento/TestFramework/Annotation/AppIsolation.php b/dev/tests/integration/framework/Magento/TestFramework/Annotation/AppIsolation.php index 7cb305bd525c7..ddc267a4d3ebd 100644 --- a/dev/tests/integration/framework/Magento/TestFramework/Annotation/AppIsolation.php +++ b/dev/tests/integration/framework/Magento/TestFramework/Annotation/AppIsolation.php @@ -9,6 +9,11 @@ */ namespace Magento\TestFramework\Annotation; +use Magento\Framework\Exception\LocalizedException; +use Magento\TestFramework\Application; +use Magento\TestFramework\TestCase\AbstractController; +use PHPUnit\Framework\TestCase; + class AppIsolation { /** @@ -16,12 +21,12 @@ class AppIsolation * * @var bool */ - private $_hasNonIsolatedTests = true; + private $hasNonIsolatedTests = true; /** - * @var \Magento\TestFramework\Application + * @var Application */ - private $_application; + private $application; /** * @var array @@ -31,11 +36,11 @@ class AppIsolation /** * Constructor * - * @param \Magento\TestFramework\Application $application + * @param Application $application */ - public function __construct(\Magento\TestFramework\Application $application) + public function __construct(Application $application) { - $this->_application = $application; + $this->application = $application; } /** @@ -43,12 +48,12 @@ public function __construct(\Magento\TestFramework\Application $application) */ protected function _isolateApp() { - if ($this->_hasNonIsolatedTests) { - $this->_application->reinitialize(); + if ($this->hasNonIsolatedTests) { + $this->application->reinitialize(); $_SESSION = []; $_COOKIE = []; session_write_close(); - $this->_hasNonIsolatedTests = false; + $this->hasNonIsolatedTests = false; } } @@ -72,31 +77,44 @@ public function endTestSuite() /** * Handler for 'endTest' event * - * @param \PHPUnit\Framework\TestCase $test - * @throws \Magento\Framework\Exception\LocalizedException + * @param TestCase $test + * @throws LocalizedException */ - public function endTest(\PHPUnit\Framework\TestCase $test) + public function endTest(TestCase $test) { - $this->_hasNonIsolatedTests = true; + $this->hasNonIsolatedTests = true; /* Determine an isolation from doc comment */ - $annotations = $test->getAnnotations(); - $annotations = array_replace((array) $annotations['class'], (array) $annotations['method']); + $annotations = $this->getAnnotations($test); if (isset($annotations['magentoAppIsolation'])) { $isolation = $annotations['magentoAppIsolation']; if ($isolation !== ['enabled'] && $isolation !== ['disabled']) { - throw new \Magento\Framework\Exception\LocalizedException( + throw new LocalizedException( __('Invalid "@magentoAppIsolation" annotation, can be "enabled" or "disabled" only.') ); } $isIsolationEnabled = $isolation === ['enabled']; } else { /* Controller tests should be isolated by default */ - $isIsolationEnabled = $test instanceof \Magento\TestFramework\TestCase\AbstractController; + $isIsolationEnabled = $test instanceof AbstractController; } if ($isIsolationEnabled) { $this->_isolateApp(); } } + + /** + * Get method annotations. + * + * Overwrites class-defined annotations. + * + * @param TestCase $test + * @return array + */ + private function getAnnotations(TestCase $test): array + { + $annotations = $test->getAnnotations(); + return array_replace((array)$annotations['class'], (array)$annotations['method']); + } } diff --git a/dev/tests/integration/framework/Magento/TestFramework/Annotation/DbIsolation.php b/dev/tests/integration/framework/Magento/TestFramework/Annotation/DbIsolation.php index e1ae5ab15b033..819d5ee4e57f2 100644 --- a/dev/tests/integration/framework/Magento/TestFramework/Annotation/DbIsolation.php +++ b/dev/tests/integration/framework/Magento/TestFramework/Annotation/DbIsolation.php @@ -5,6 +5,10 @@ */ namespace Magento\TestFramework\Annotation; +use Magento\Framework\Exception\LocalizedException; +use Magento\TestFramework\Event\Param\Transaction; +use PHPUnit\Framework\TestCase; + /** * Implementation of the @magentoDbIsolation DocBlock annotation */ @@ -20,13 +24,11 @@ class DbIsolation /** * Handler for 'startTestTransactionRequest' event * - * @param \PHPUnit\Framework\TestCase $test - * @param \Magento\TestFramework\Event\Param\Transaction $param + * @param TestCase $test + * @param Transaction $param */ - public function startTestTransactionRequest( - \PHPUnit\Framework\TestCase $test, - \Magento\TestFramework\Event\Param\Transaction $param - ) { + public function startTestTransactionRequest(TestCase $test, Transaction $param) + { $methodIsolation = $this->_getIsolation($test); if ($this->_isIsolationActive) { if ($methodIsolation === false) { @@ -40,13 +42,11 @@ public function startTestTransactionRequest( /** * Handler for 'endTestTransactionRequest' event * - * @param \PHPUnit\Framework\TestCase $test - * @param \Magento\TestFramework\Event\Param\Transaction $param + * @param TestCase $test + * @param Transaction $param */ - public function endTestTransactionRequest( - \PHPUnit\Framework\TestCase $test, - \Magento\TestFramework\Event\Param\Transaction $param - ) { + public function endTestTransactionRequest(TestCase $test, Transaction $param) + { if ($this->_isIsolationActive && $this->_getIsolation($test)) { $param->requestTransactionRollback(); } @@ -55,11 +55,11 @@ public function endTestTransactionRequest( /** * Handler for 'startTransaction' event * - * @param \PHPUnit\Framework\TestCase $test + * @param TestCase $test * * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ - public function startTransaction(\PHPUnit\Framework\TestCase $test) + public function startTransaction(TestCase $test) { $this->_isIsolationActive = true; } @@ -79,17 +79,17 @@ public function rollbackTransaction() * TRUE - annotation is defined as 'enabled' * FALSE - annotation is defined as 'disabled' * - * @param \PHPUnit\Framework\TestCase $test + * @param TestCase $test * @return bool|null Returns NULL, if isolation is not defined for the current scope - * @throws \Magento\Framework\Exception\LocalizedException + * @throws LocalizedException */ - protected function _getIsolation(\PHPUnit\Framework\TestCase $test) + protected function _getIsolation(TestCase $test) { $annotations = $this->getAnnotations($test); if (isset($annotations[self::MAGENTO_DB_ISOLATION])) { $isolation = $annotations[self::MAGENTO_DB_ISOLATION]; if ($isolation !== ['enabled'] && $isolation !== ['disabled']) { - throw new \Magento\Framework\Exception\LocalizedException( + throw new LocalizedException( __('Invalid "@magentoDbIsolation" annotation, can be "enabled" or "disabled" only.') ); } @@ -99,12 +99,16 @@ protected function _getIsolation(\PHPUnit\Framework\TestCase $test) } /** - * @param \PHPUnit\Framework\TestCase $test + * Get method annotations. + * + * Overwrites class-defined annotations. + * + * @param TestCase $test * @return array */ - private function getAnnotations(\PHPUnit\Framework\TestCase $test) + private function getAnnotations(TestCase $test) { $annotations = $test->getAnnotations(); - return array_replace($annotations['class'], $annotations['method']); + return array_replace((array)$annotations['class'], (array)$annotations['method']); } } diff --git a/dev/tests/integration/framework/Magento/TestFramework/Application.php b/dev/tests/integration/framework/Magento/TestFramework/Application.php index e39e7c0f05bd8..af2b72e03e9be 100644 --- a/dev/tests/integration/framework/Magento/TestFramework/Application.php +++ b/dev/tests/integration/framework/Magento/TestFramework/Application.php @@ -5,12 +5,15 @@ */ namespace Magento\TestFramework; -use Magento\Framework\Autoload\AutoloaderInterface; -use Magento\Framework\App\Filesystem\DirectoryList; use Magento\Framework\App\DeploymentConfig; -use Magento\Framework\Config\ConfigOptionsListConstants; use Magento\Framework\App\DeploymentConfig\Reader; +use Magento\Framework\App\Filesystem\DirectoryList; +use Magento\Framework\Autoload\AutoloaderInterface; +use Magento\Framework\Config\ConfigOptionsListConstants; use Magento\Framework\Filesystem\Glob; +use Magento\Framework\Mail; +use Magento\TestFramework; +use Psr\Log\LoggerInterface; /** * Encapsulates application installation, initialization and uninstall. @@ -28,7 +31,7 @@ class Application /** * DB vendor adapter instance. * - * @var \Magento\TestFramework\Db\AbstractDb + * @var TestFramework\Db\AbstractDb */ protected $_db; @@ -105,14 +108,14 @@ class Application /** * Object manager factory. * - * @var \Magento\TestFramework\ObjectManagerFactory + * @var TestFramework\ObjectManagerFactory */ protected $_factory; /** * Directory list. * - * @var \Magento\Framework\App\Filesystem\DirectoryList + * @var DirectoryList */ protected $dirList; @@ -180,7 +183,7 @@ public function __construct( $this->loadTestExtensionAttributes = $loadTestExtensionAttributes; $customDirs = $this->getCustomDirs(); - $this->dirList = new \Magento\Framework\App\Filesystem\DirectoryList(BP, $customDirs); + $this->dirList = new DirectoryList(BP, $customDirs); \Magento\Framework\Autoload\Populator::populateMappings( $autoloadWrapper, $this->dirList @@ -189,9 +192,9 @@ public function __construct( \Magento\Framework\App\Bootstrap::INIT_PARAM_FILESYSTEM_DIR_PATHS => $customDirs, \Magento\Framework\App\State::PARAM_MODE => $appMode ]; - $driverPool = new \Magento\Framework\Filesystem\DriverPool; - $configFilePool = new \Magento\Framework\Config\File\ConfigFilePool; - $this->_factory = new \Magento\TestFramework\ObjectManagerFactory($this->dirList, $driverPool, $configFilePool); + $driverPool = new \Magento\Framework\Filesystem\DriverPool(); + $configFilePool = new \Magento\Framework\Config\File\ConfigFilePool(); + $this->_factory = new TestFramework\ObjectManagerFactory($this->dirList, $driverPool, $configFilePool); $this->_configDir = $this->dirList->getPath(DirectoryList::CONFIG); $this->globalConfigFile = $globalConfigFile; @@ -200,7 +203,7 @@ public function __construct( /** * Retrieve the database adapter instance. * - * @return \Magento\TestFramework\Db\AbstractDb + * @return TestFramework\Db\AbstractDb */ public function getDbInstance() { @@ -310,7 +313,7 @@ private function initLogger() $objectManager = Helper\Bootstrap::getObjectManager(); /** @var \Psr\Log\LoggerInterface $logger */ $logger = $objectManager->create( - \Magento\TestFramework\ErrorLog\Logger::class, + TestFramework\ErrorLog\Logger::class, [ 'name' => 'integration-tests', 'handlers' => [ @@ -331,9 +334,8 @@ private function initLogger() ] ] ); - - $objectManager->removeSharedInstance(\Magento\Framework\Logger\Monolog::class); - $objectManager->addSharedInstance($logger, \Magento\Framework\Logger\Monolog::class); + $objectManager->removeSharedInstance(LoggerInterface::class, true); + $objectManager->addSharedInstance($logger, LoggerInterface::class, true); return $logger; } @@ -351,31 +353,35 @@ public function initialize($overriddenParams = []) ? $overriddenParams[\Magento\Framework\App\Bootstrap::INIT_PARAM_FILESYSTEM_DIR_PATHS] : []; $directoryList = new DirectoryList(BP, $directories); - /** @var \Magento\TestFramework\ObjectManager $objectManager */ + /** @var TestFramework\ObjectManager $objectManager */ $objectManager = Helper\Bootstrap::getObjectManager(); if (!$objectManager) { $objectManager = $this->_factory->create($overriddenParams); - $objectManager->addSharedInstance($directoryList, \Magento\Framework\App\Filesystem\DirectoryList::class); - $objectManager->addSharedInstance($directoryList, \Magento\Framework\Filesystem\DirectoryList::class); + $objectManager->addSharedInstance( + $directoryList, + DirectoryList::class + ); + $objectManager->addSharedInstance( + $directoryList, + \Magento\Framework\Filesystem\DirectoryList::class + ); } else { $objectManager = $this->_factory->restore($objectManager, $directoryList, $overriddenParams); } - /** @var \Magento\TestFramework\App\Filesystem $filesystem */ - $filesystem = $objectManager->get(\Magento\TestFramework\App\Filesystem::class); + /** @var TestFramework\App\Filesystem $filesystem */ + $filesystem = $objectManager->get(TestFramework\App\Filesystem::class); $objectManager->removeSharedInstance(\Magento\Framework\Filesystem::class); $objectManager->addSharedInstance($filesystem, \Magento\Framework\Filesystem::class); Helper\Bootstrap::setObjectManager($objectManager); $this->initLogger(); - $sequenceBuilder = $objectManager->get(\Magento\TestFramework\Db\Sequence\Builder::class); + $sequenceBuilder = $objectManager->get(TestFramework\Db\Sequence\Builder::class); $objectManager->addSharedInstance($sequenceBuilder, \Magento\SalesSequence\Model\Builder::class); $objectManagerConfiguration = [ 'preferences' => [ - \Magento\Framework\App\State::class => \Magento\TestFramework\App\State::class, - \Magento\Framework\Mail\TransportInterface::class => - \Magento\TestFramework\Mail\TransportInterfaceMock::class, - \Magento\Framework\Mail\Template\TransportBuilder::class - => \Magento\TestFramework\Mail\Template\TransportBuilderMock::class, + \Magento\Framework\App\State::class => TestFramework\App\State::class, + Mail\TransportInterface::class => TestFramework\Mail\TransportInterfaceMock::class, + Mail\Template\TransportBuilder::class => TestFramework\Mail\Template\TransportBuilderMock::class, ] ]; if ($this->loadTestExtensionAttributes) { @@ -385,7 +391,7 @@ public function initialize($overriddenParams = []) \Magento\Framework\Api\ExtensionAttribute\Config\Reader::class => [ 'arguments' => [ 'fileResolver' => [ - 'instance' => \Magento\TestFramework\Api\Config\Reader\FileResolver::class + 'instance' => TestFramework\Api\Config\Reader\FileResolver::class ], ], ], @@ -400,7 +406,7 @@ public function initialize($overriddenParams = []) [ 'core_app_init_current_store_after' => [ 'integration_tests' => [ - 'instance' => \Magento\TestFramework\Event\Magento::class, + 'instance' => TestFramework\Event\Magento::class, 'name' => 'integration_tests' ] ] @@ -408,10 +414,10 @@ public function initialize($overriddenParams = []) ); if ($this->canLoadArea) { - $this->loadArea(\Magento\TestFramework\Application::DEFAULT_APP_AREA); + $this->loadArea(TestFramework\Application::DEFAULT_APP_AREA); } - \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->configure( + TestFramework\Helper\Bootstrap::getObjectManager()->configure( $objectManager->get(\Magento\Framework\ObjectManager\DynamicConfigInterface::class)->getConfiguration() ); \Magento\Framework\Phrase::setRenderer( @@ -419,7 +425,7 @@ public function initialize($overriddenParams = []) ); if ($this->canInstallSequence) { - /** @var \Magento\TestFramework\Db\Sequence $sequence */ + /** @var TestFramework\Db\Sequence $sequence */ $sequence = $objectManager->get(\Magento\TestFramework\Db\Sequence::class); $sequence->generateSequences(); } @@ -649,7 +655,7 @@ protected function _ensureDirExists($dir) // phpcs:ignore Magento2.Functions.DiscouragedFunction mkdir($dir, 0777, true); umask($old); - // phpcs:ignore Magento2.Functions.DiscouragedFunction + // phpcs:ignore Magento2.Functions.DiscouragedFunction } elseif (!is_dir($dir)) { throw new \Magento\Framework\Exception\LocalizedException(__("'%1' is not a directory.", $dir)); } diff --git a/dev/tests/integration/framework/Magento/TestFramework/Catalog/Model/Indexer/Product/Flat/Action/Full.php b/dev/tests/integration/framework/Magento/TestFramework/Catalog/Model/Indexer/Product/Flat/Action/Full.php deleted file mode 100644 index 17ffb5cf2748a..0000000000000 --- a/dev/tests/integration/framework/Magento/TestFramework/Catalog/Model/Indexer/Product/Flat/Action/Full.php +++ /dev/null @@ -1,22 +0,0 @@ - %s' ); diff --git a/dev/tests/integration/framework/Magento/TestFramework/MysqlMq/DeleteTopicRelatedMessages.php b/dev/tests/integration/framework/Magento/TestFramework/MysqlMq/DeleteTopicRelatedMessages.php new file mode 100644 index 0000000000000..3b14cda3b0150 --- /dev/null +++ b/dev/tests/integration/framework/Magento/TestFramework/MysqlMq/DeleteTopicRelatedMessages.php @@ -0,0 +1,42 @@ +queueMessageResource = $queueMessageResource; + } + + /** + * Delete messages from queue + * + * @param string $topic + * @return void + */ + public function execute(string $topic): void + { + $connection = $this->queueMessageResource->getConnection(); + $condition = $connection->quoteInto(QueueManagement::MESSAGE_TOPIC . '= ?', $topic); + $connection->delete($this->queueMessageResource->getMainTable(), $condition); + } +} diff --git a/dev/tests/integration/framework/Magento/TestFramework/ObjectManager.php b/dev/tests/integration/framework/Magento/TestFramework/ObjectManager.php index 455d2e54c5b2d..64c7fbbacd915 100644 --- a/dev/tests/integration/framework/Magento/TestFramework/ObjectManager.php +++ b/dev/tests/integration/framework/Magento/TestFramework/ObjectManager.php @@ -85,10 +85,12 @@ private function clearMappedTableNames() * * @param mixed $instance * @param string $className + * @param bool $forPreference Resolve preference for class * @return void */ - public function addSharedInstance($instance, $className) + public function addSharedInstance($instance, $className, $forPreference = false) { + $className = $forPreference ? $this->_config->getPreference($className) : $className; $this->_sharedInstances[$className] = $instance; } @@ -96,10 +98,12 @@ public function addSharedInstance($instance, $className) * Remove shared instance. * * @param string $className + * @param bool $forPreference Resolve preference for class * @return void */ - public function removeSharedInstance($className) + public function removeSharedInstance($className, $forPreference = false) { + $className = $forPreference ? $this->_config->getPreference($className) : $className; unset($this->_sharedInstances[$className]); } @@ -115,6 +119,8 @@ public static function setInstance(\Magento\Framework\ObjectManagerInterface $ob } /** + * Get object factory + * * @return \Magento\Framework\ObjectManager\FactoryInterface|\Magento\Framework\ObjectManager\Factory\Factory */ public function getFactory() diff --git a/dev/tests/integration/framework/Magento/TestFramework/ObjectManagerFactory.php b/dev/tests/integration/framework/Magento/TestFramework/ObjectManagerFactory.php index cac62f89ebb50..3137096de6e84 100644 --- a/dev/tests/integration/framework/Magento/TestFramework/ObjectManagerFactory.php +++ b/dev/tests/integration/framework/Magento/TestFramework/ObjectManagerFactory.php @@ -5,12 +5,18 @@ */ namespace Magento\TestFramework; +use Magento\Framework\App\DeploymentConfig; use Magento\Framework\App\Filesystem\DirectoryList; +use Magento\Framework\App\ObjectManager\ConfigLoader; use Magento\Framework\Exception\LocalizedException; use Magento\Framework\Filesystem\DriverPool; +use Magento\Framework\Interception\PluginListInterface; +use Magento\Framework\ObjectManager\ConfigLoaderInterface; +use Magento\TestFramework\App\EnvironmentFactory; +use Magento\TestFramework\Db\ConnectionAdapter; /** - * Class ObjectManagerFactory + * Configure ObjectManagerFactory for testing purpose * * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ @@ -21,7 +27,7 @@ class ObjectManagerFactory extends \Magento\Framework\App\ObjectManagerFactory * * @var string */ - protected $_locatorClassName = \Magento\TestFramework\ObjectManager::class; + protected $_locatorClassName = ObjectManager::class; /** * Config class name @@ -33,7 +39,7 @@ class ObjectManagerFactory extends \Magento\Framework\App\ObjectManagerFactory /** * @var string */ - protected $envFactoryClassName = \Magento\TestFramework\App\EnvironmentFactory::class; + protected $envFactoryClassName = EnvironmentFactory::class; /** * @var array @@ -50,21 +56,25 @@ class ObjectManagerFactory extends \Magento\Framework\App\ObjectManagerFactory */ public function restore(ObjectManager $objectManager, $directoryList, array $arguments) { - \Magento\TestFramework\ObjectManager::setInstance($objectManager); + ObjectManager::setInstance($objectManager); $this->directoryList = $directoryList; $objectManager->configure($this->_primaryConfigData); - $objectManager->addSharedInstance($this->directoryList, \Magento\Framework\App\Filesystem\DirectoryList::class); - $objectManager->addSharedInstance($this->directoryList, \Magento\Framework\Filesystem\DirectoryList::class); + $objectManager->addSharedInstance($this->directoryList, DirectoryList::class); + $objectManager->addSharedInstance( + $this->directoryList, + \Magento\Framework\Filesystem\DirectoryList::class + ); $deploymentConfig = $this->createDeploymentConfig($directoryList, $this->configFilePool, $arguments); $this->factory->setArguments($arguments); - $objectManager->addSharedInstance($deploymentConfig, \Magento\Framework\App\DeploymentConfig::class); + $objectManager->addSharedInstance($deploymentConfig, DeploymentConfig::class); $objectManager->addSharedInstance( - $objectManager->get(\Magento\Framework\App\ObjectManager\ConfigLoader::class), - \Magento\Framework\ObjectManager\ConfigLoaderInterface::class + $objectManager->get(ConfigLoader::class), + ConfigLoaderInterface::class, + true ); - $objectManager->get(\Magento\Framework\Interception\PluginListInterface::class)->reset(); + $objectManager->get(PluginListInterface::class)->reset(); $objectManager->configure( - $objectManager->get(\Magento\Framework\App\ObjectManager\ConfigLoader::class)->load('global') + $objectManager->get(ConfigLoader::class)->load('global') ); return $objectManager; @@ -73,7 +83,7 @@ public function restore(ObjectManager $objectManager, $directoryList, array $arg /** * Load primary config * - * @param \Magento\Framework\App\Filesystem\DirectoryList $directoryList + * @param DirectoryList $directoryList * @param DriverPool $driverPool * @param mixed $argumentMapper * @param string $appMode @@ -85,7 +95,7 @@ protected function _loadPrimaryConfig(DirectoryList $directoryList, $driverPool, $this->_primaryConfigData = array_replace( parent::_loadPrimaryConfig($directoryList, $driverPool, $argumentMapper, $appMode), [ - 'default_setup' => ['type' => \Magento\TestFramework\Db\ConnectionAdapter::class] + 'default_setup' => ['type' => ConnectionAdapter::class] ] ); $diPreferences = []; diff --git a/dev/tests/integration/framework/Magento/TestFramework/Workaround/Override/Config/RelationsCollector.php b/dev/tests/integration/framework/Magento/TestFramework/Workaround/Override/Config/RelationsCollector.php index 2a17e7dba4904..af09f4cf16546 100644 --- a/dev/tests/integration/framework/Magento/TestFramework/Workaround/Override/Config/RelationsCollector.php +++ b/dev/tests/integration/framework/Magento/TestFramework/Workaround/Override/Config/RelationsCollector.php @@ -46,14 +46,14 @@ public function getParents(string $className): array */ private function getRelations(string $className): array { - $result = $this->getRelationsReader()->getParents($className); + $parents = $this->getRelationsReader()->getParents($className); + $result = [$parents]; - foreach ($result as $parent) { - // phpcs:ignore Magento2.Performance.ForeachArrayMerge - $result = array_merge($result, $this->getRelations($parent)); + foreach ($parents as $parent) { + $result[] = $this->getRelations($parent); } - return $result; + return array_merge([], ...$result); } /** diff --git a/dev/tests/integration/testsuite/Magento/Analytics/Controller/Adminhtml/Reports/ShowTest.php b/dev/tests/integration/testsuite/Magento/Analytics/Controller/Adminhtml/Reports/ShowTest.php index f492e2cd09570..779cd24791b8c 100644 --- a/dev/tests/integration/testsuite/Magento/Analytics/Controller/Adminhtml/Reports/ShowTest.php +++ b/dev/tests/integration/testsuite/Magento/Analytics/Controller/Adminhtml/Reports/ShowTest.php @@ -14,7 +14,7 @@ */ class ShowTest extends AbstractBackendController { - private const REPORT_HOST = 'advancedreporting.rjmetrics.com'; + private const REPORT_HOST = 'docs.magento.com'; /** * @inheritDoc */ diff --git a/dev/tests/integration/testsuite/Magento/Analytics/Cron/CollectDataTest.php b/dev/tests/integration/testsuite/Magento/Analytics/Cron/CollectDataTest.php new file mode 100644 index 0000000000000..a1d2b24d54b0e --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Analytics/Cron/CollectDataTest.php @@ -0,0 +1,91 @@ +objectManager = Bootstrap::getObjectManager(); + $this->collectDataService = $this->objectManager->get(CollectData::class); + $this->mediaDirectory = $this->objectManager->get(Filesystem::class)->getDirectoryWrite(DirectoryList::MEDIA); + $this->removeAnalyticsDirectory(); + } + + /** + * @inheritdoc + */ + protected function tearDown(): void + { + $this->removeAnalyticsDirectory(); + + parent::tearDown(); + } + + /** + * @magentoConfigFixture current_store analytics/subscription/enabled 1 + * @magentoConfigFixture default/analytics/general/token 123 + * + * @return void + */ + public function testExecute(): void + { + $this->collectDataService->execute(); + $this->assertTrue( + $this->mediaDirectory->isDirectory('analytics'), + 'Analytics was not created' + ); + $files = $this->mediaDirectory->getDriver() + ->readDirectoryRecursively($this->mediaDirectory->getAbsolutePath('analytics')); + $file = array_filter($files, function ($element) { + return substr($element, -8) === 'data.tgz'; + }); + $this->assertNotEmpty($file, 'File was not created'); + } + + /** + * Remove Analytics directory + * + * @return void + */ + private function removeAnalyticsDirectory(): void + { + $directoryToRemove = $this->mediaDirectory->getAbsolutePath('analytics'); + if ($this->mediaDirectory->isDirectory($directoryToRemove)) { + $this->mediaDirectory->delete($directoryToRemove); + } + } +} diff --git a/dev/tests/integration/testsuite/Magento/Backend/Block/Dashboard/Chart/PeriodTest.php b/dev/tests/integration/testsuite/Magento/Backend/Block/Dashboard/Chart/PeriodTest.php new file mode 100644 index 0000000000000..c9ad4827c2838 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Backend/Block/Dashboard/Chart/PeriodTest.php @@ -0,0 +1,66 @@ +objectManager = Bootstrap::getObjectManager(); + $this->layout = $this->objectManager->get(LayoutInterface::class); + $this->block = $this->layout->createBlock(Template::class); + $this->block->setTemplate("Magento_Backend::dashboard/chart/period.phtml"); + $this->block->setData('view_model', $this->objectManager->get(ChartsPeriod::class)); + } + + /** + * @return void + */ + public function testChartPeriodOptions(): void + { + $html = $this->block->toHtml(); + $dropDownList = [ + __('Last 24 Hours'), + __('Last 7 Days'), + __('Current Month'), + __('YTD'), + __('2YTD') + ]; + foreach ($dropDownList as $item) { + $xPath = "//select[@id='dashboard_chart_period']/option[normalize-space(text())='{$item}']"; + $this->assertEquals(1, Xpath::getElementsCountForXpath($xPath, $html)); + } + } +} diff --git a/dev/tests/integration/testsuite/Magento/Bundle/Model/Product/BundlePriceAbstract.php b/dev/tests/integration/testsuite/Magento/Bundle/Model/Product/BundlePriceAbstract.php index bf369ed28167b..a18f1e0799dfa 100644 --- a/dev/tests/integration/testsuite/Magento/Bundle/Model/Product/BundlePriceAbstract.php +++ b/dev/tests/integration/testsuite/Magento/Bundle/Model/Product/BundlePriceAbstract.php @@ -30,6 +30,11 @@ abstract class BundlePriceAbstract extends \PHPUnit\Framework\TestCase */ protected $productCollectionFactory; + /** + * @var \Magento\CatalogRule\Model\RuleFactory + */ + private $ruleFactory; + protected function setUp(): void { $this->objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); @@ -43,6 +48,7 @@ protected function setUp(): void true, \Magento\Store\Model\ScopeInterface::SCOPE_STORE ); + $this->ruleFactory = $this->objectManager->get(\Magento\CatalogRule\Model\RuleFactory::class); } /** @@ -62,6 +68,8 @@ abstract public function getTestCases(); */ protected function prepareFixture($strategyModifiers, $productSku) { + $this->ruleFactory->create()->clearPriceRulesData(); + $bundleProduct = $this->productRepository->get($productSku); foreach ($strategyModifiers as $modifier) { diff --git a/dev/tests/integration/testsuite/Magento/Bundle/Model/ResourceModel/Indexer/PriceTest.php b/dev/tests/integration/testsuite/Magento/Bundle/Model/ResourceModel/Indexer/PriceTest.php new file mode 100644 index 0000000000000..afb0e66558aaa --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Bundle/Model/ResourceModel/Indexer/PriceTest.php @@ -0,0 +1,113 @@ +objectManager = Bootstrap::getObjectManager(); + $this->indexer = $this->objectManager->get(Price::class); + $this->productRepository = $this->objectManager->get(ProductRepositoryInterface::class); + $this->getPriceIndexDataByProductId = $this->objectManager->get(GetPriceIndexDataByProductId::class); + $this->websiteRepository = $this->objectManager->get(WebsiteRepositoryInterface::class); + $this->stockIndexer = $this->objectManager->get(Stock::class); + } + + /** + * Test get bundle index price if enabled show out off stock + * + * @magentoDbIsolation disabled + * @magentoAppIsolation enabled + * @magentoDataFixture Magento/Bundle/_files/bundle_product_with_dynamic_price.php + * @magentoConfigFixture default_store cataloginventory/options/show_out_of_stock 1 + * + * @return void + */ + public function testExecuteRowWithShowOutOfStock(): void + { + + $expectedPrices = [ + 'price' => 0, + 'final_price' => 0, + 'min_price' => 15.99, + 'max_price' => 15.99, + 'tier_price' => null + ]; + $product = $this->productRepository->get('simple1'); + $product->setStockData(['qty' => 0]); + $this->productRepository->save($product); + $this->stockIndexer->executeRow($product->getId()); + $bundleProduct = $this->productRepository->get('bundle_product_with_dynamic_price'); + $this->indexer->executeRow($bundleProduct->getId()); + $this->assertIndexTableData($bundleProduct->getId(), $expectedPrices); + } + + /** + * Asserts price data in index table. + * + * @param int $productId + * @param array $expectedPrices + * @return void + */ + private function assertIndexTableData(int $productId, array $expectedPrices): void + { + $data = $this->getPriceIndexDataByProductId->execute( + $productId, + Group::NOT_LOGGED_IN_ID, + (int)$this->websiteRepository->get('base')->getId() + ); + $data = reset($data); + foreach ($expectedPrices as $column => $price) { + $this->assertEquals($price, $data[$column]); + } + } +} diff --git a/dev/tests/integration/testsuite/Magento/Bundle/_files/order_with_bundle_shipped_separately.php b/dev/tests/integration/testsuite/Magento/Bundle/_files/order_with_bundle_shipped_separately.php index e5f089ae9637c..b91d479cdf1ef 100644 --- a/dev/tests/integration/testsuite/Magento/Bundle/_files/order_with_bundle_shipped_separately.php +++ b/dev/tests/integration/testsuite/Magento/Bundle/_files/order_with_bundle_shipped_separately.php @@ -155,8 +155,6 @@ /** @var \Magento\Sales\Model\Order\Item $orderItem */ $orderItem = $objectManager->create(\Magento\Sales\Model\Order\Item::class); $orderItem->setProductId($product->getId()); -$orderItem->setSku($product->getSku()); -$orderItem->setName($product->getName()); $orderItem->setQtyOrdered(1); $orderItem->setBasePrice($product->getPrice()); $orderItem->setPrice($product->getPrice()); @@ -174,8 +172,6 @@ /** @var \Magento\Sales\Model\Order\Item $orderItem */ $orderItem = $objectManager->create(\Magento\Sales\Model\Order\Item::class); $orderItem->setProductId($productId); - $orderItem->setSku($selectedProduct->getSku()); - $orderItem->setName($selectedProduct->getName()); $orderItem->setQtyOrdered(1); $orderItem->setBasePrice($selectedProduct->getPrice()); $orderItem->setPrice($selectedProduct->getPrice()); diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Block/Adminhtml/Product/Helper/Form/Gallery/ContentTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Block/Adminhtml/Product/Helper/Form/Gallery/ContentTest.php index 7a999f1d205f2..7e94484961f9e 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/Block/Adminhtml/Product/Helper/Form/Gallery/ContentTest.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/Block/Adminhtml/Product/Helper/Form/Gallery/ContentTest.php @@ -73,11 +73,11 @@ public function testGetImagesJson(bool $isProductNew) $imagesJson = $this->block->getImagesJson(); $images = json_decode($imagesJson); $image = array_shift($images); - $this->assertMatchesRegularExpression('/\/m\/a\/magento_image/', $image->file); + $this->assertMatchesRegularExpression('~/m/a/magento_image~', $image->file); $this->assertSame('image', $image->media_type); $this->assertSame('Image Alt Text', $image->label); $this->assertSame('Image Alt Text', $image->label_default); - $this->assertMatchesRegularExpression('/\/pub\/media\/catalog\/product\/m\/a\/magento_image/', $image->url); + $this->assertMatchesRegularExpression('~/media/catalog/product/m/a/magento_image~', $image->url); } /** diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Block/Category/TitleTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Block/Category/TitleTest.php new file mode 100644 index 0000000000000..7bc359935bf60 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Catalog/Block/Category/TitleTest.php @@ -0,0 +1,136 @@ +objectManager = Bootstrap::getObjectManager(); + $this->categoryRepository = $this->objectManager->get(CategoryRepositoryInterface::class); + $this->storeManager = $this->objectManager->get(StoreManagerInterface::class); + $this->pageFactory = $this->objectManager->get(PageFactory::class); + $this->registry = $this->objectManager->get(Registry::class); + $this->executeInStoreContext = $this->objectManager->get(ExecuteInStoreContext::class); + } + + /** + * @inheritdoc + */ + protected function tearDown(): void + { + $this->registry->unregister('current_category'); + + parent::tearDown(); + } + + /** + * @magentoDbIsolation enabled + * @magentoDataFixture Magento/Catalog/_files/category.php + * @magentoDataFixture Magento/Store/_files/store.php + * @return void + */ + public function testCategoryNameOnStoreView(): void + { + $id = 333; + $categoryNameForSecondStore = 'Category Name For Second Store'; + $this->executeInStoreContext->execute( + 'test', + [$this, 'updateCategoryName'], + $this->categoryRepository->get($id), + $categoryNameForSecondStore + ); + $this->registerCategory($this->categoryRepository->get($id)); + $this->assertStringContainsString('Category 1', $this->getBlockTitle(), 'Wrong category name'); + $this->registerCategory($this->categoryRepository->get($id, $this->storeManager->getStore('test')->getId())); + $this->assertStringContainsString($categoryNameForSecondStore, $this->getBlockTitle(), 'Wrong category name'); + } + + /** + * Update category name + * + * @param CategoryInterface $category + * @param string $categoryName + * @return void + */ + public function updateCategoryName(CategoryInterface $category, string $categoryName): void + { + $category->setName($categoryName); + $this->categoryRepository->save($category); + } + + /** + * Get title block + * + * @return string + */ + private function getBlockTitle(): string + { + $page = $this->pageFactory->create(); + $page->addHandle([ + 'default', + 'catalog_category_view', + ]); + $page->getLayout()->generateXml(); + $block = $page->getLayout()->getBlock('page.main.title'); + $this->assertNotFalse($block); + + return $block->stripTags($block->toHtml()); + } + + /** + * Register category in registry + * + * @param CategoryInterface $category + * @return void + */ + private function registerCategory(CategoryInterface $category): void + { + $this->registry->unregister('current_category'); + $this->registry->register('current_category', $category); + } +} diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Block/Category/ViewTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Block/Category/ViewTest.php new file mode 100644 index 0000000000000..8ff4e29b46dde --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Catalog/Block/Category/ViewTest.php @@ -0,0 +1,97 @@ +objectManager = Bootstrap::getObjectManager(); + $this->registry = $this->objectManager->get(Registry::class); + $this->getCategoryByName = $this->objectManager->get(GetCategoryByName::class); + $this->categoryRepository = $this->objectManager->get(CategoryRepositoryInterface::class); + $this->layout = $this->objectManager->get(LayoutInterface::class); + $this->storeManager = $this->objectManager->get(StoreManagerInterface::class); + } + + /** + * @inheritdoc + */ + protected function tearDown(): void + { + $this->registry->unregister('current_category'); + + parent::tearDown(); + } + + /** + * @magentoDataFixture Magento/Catalog/_files/category_with_cms_block.php + * + * @return void + */ + public function testCmsBlockDisplayedOnCategory(): void + { + $storeId = (int)$this->storeManager->getStore('default')->getId(); + $categoryId = $this->getCategoryByName->execute('Category with cms block')->getId(); + $category = $this->categoryRepository->get($categoryId, $storeId); + $this->registerCategory($category); + $block = $this->layout->createBlock(View::class)->setTemplate('Magento_Catalog::category/cms.phtml'); + $this->assertStringContainsString('

Fixture Block Title

', $block->toHtml()); + } + + /** + * Register category in registry + * + * @param CategoryInterface $category + * @return void + */ + private function registerCategory(CategoryInterface $category): void + { + $this->registry->unregister('current_category'); + $this->registry->register('current_category', $category); + } +} diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Block/Product/ListProduct/SortingTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Block/Product/ListProduct/SortingTest.php index 52e2047917e8e..b88edc656176c 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/Block/Product/ListProduct/SortingTest.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/Block/Product/ListProduct/SortingTest.php @@ -439,7 +439,7 @@ public function productListWithOutOfStockSortOrderDataProvider(): array 'default_order_price_desc' => [ 'sort' => 'price', 'direction' => Collection::SORT_ORDER_DESC, - 'expectation' => ['simple3', 'simple2', 'simple1', 'configurable'], + 'expectation' => ['configurable', 'simple3', 'simple2', 'simple1'], ], ]; } diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Block/Product/View/GalleryTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Block/Product/View/GalleryTest.php index e5c6b1f8c1dd6..b57969280cdf3 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/Block/Product/View/GalleryTest.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/Block/Product/View/GalleryTest.php @@ -120,9 +120,23 @@ public function testGetGalleryImagesJsonWithoutImages(): void $this->assertImages(reset($result), $this->placeholderExpectation); } + /** + * @magentoDataFixture Magento/Catalog/_files/product_simple.php + * @magentoConfigFixture default/web/url/catalog_media_url_format image_optimization_parameters + * @magentoDbIsolation enabled + * @return void + */ + public function testGetGalleryImagesJsonWithoutImagesWithImageOptimizationParametersInUrl(): void + { + $this->block->setData('product', $this->getProduct()); + $result = $this->serializer->unserialize($this->block->getGalleryImagesJson()); + $this->assertImages(reset($result), $this->placeholderExpectation); + } + /** * @dataProvider galleryDisabledImagesDataProvider * @magentoDataFixture Magento/Catalog/_files/product_with_multiple_images.php + * @magentoConfigFixture default/web/url/catalog_media_url_format hash * @magentoDbIsolation enabled * @param array $images * @param array $expectation @@ -141,6 +155,7 @@ public function testGetGalleryImagesJsonWithDisabledImage(array $images, array $ * @dataProvider galleryDisabledImagesDataProvider * @magentoDataFixture Magento/Catalog/_files/product_with_multiple_images.php * @magentoDataFixture Magento/Store/_files/second_store.php + * @magentoConfigFixture default/web/url/catalog_media_url_format hash * @magentoDbIsolation disabled * @param array $images * @param array $expectation @@ -173,6 +188,8 @@ public function galleryDisabledImagesDataProvider(): array } /** + * Test default image generation format. + * * @dataProvider galleryImagesDataProvider * @magentoDataFixture Magento/Catalog/_files/product_with_multiple_images.php * @magentoDbIsolation enabled @@ -230,10 +247,95 @@ public function galleryImagesDataProvider(): array ]; } + /** + * @dataProvider galleryImagesWithImageOptimizationParametersInUrlDataProvider + * @magentoDataFixture Magento/Catalog/_files/product_with_multiple_images.php + * @magentoConfigFixture default/web/url/catalog_media_url_format image_optimization_parameters + * @magentoDbIsolation enabled + * @param array $images + * @param array $expectation + * @return void + */ + public function testGetGalleryImagesJsonWithImageOptimizationParametersInUrl( + array $images, + array $expectation + ): void { + $product = $this->getProduct(); + $this->setGalleryImages($product, $images); + $this->block->setData('product', $this->getProduct()); + [$firstImage, $secondImage] = $this->serializer->unserialize($this->block->getGalleryImagesJson()); + [$firstExpectedImage, $secondExpectedImage] = $expectation; + $this->assertImages($firstImage, $firstExpectedImage); + $this->assertImages($secondImage, $secondExpectedImage); + } + + /** + * @return array + */ + public function galleryImagesWithImageOptimizationParametersInUrlDataProvider(): array + { + + $imageExpectation = [ + 'thumb' => '/m/a/magento_image.jpg?width=88&height=110&store=default&image-type=thumbnail', + 'img' => '/m/a/magento_image.jpg?width=700&height=700&store=default&image-type=image', + 'full' => '/m/a/magento_image.jpg?store=default&image-type=image', + 'caption' => 'Image Alt Text', + 'position' => '1', + 'isMain' => false, + 'type' => 'image', + 'videoUrl' => null, + ]; + + $thumbnailExpectation = [ + 'thumb' => '/m/a/magento_thumbnail.jpg?width=88&height=110&store=default&image-type=thumbnail', + 'img' => '/m/a/magento_thumbnail.jpg?width=700&height=700&store=default&image-type=image', + 'full' => '/m/a/magento_thumbnail.jpg?store=default&image-type=image', + 'caption' => 'Thumbnail Image', + 'position' => '2', + 'isMain' => false, + 'type' => 'image', + 'videoUrl' => null, + ]; + + return [ + 'with_main_image' => [ + 'images' => [ + '/m/a/magento_image.jpg' => [], + '/m/a/magento_thumbnail.jpg' => ['main' => true], + ], + 'expectation' => [ + $imageExpectation, + array_merge($thumbnailExpectation, ['isMain' => true]), + ], + ], + 'without_main_image' => [ + 'images' => [ + '/m/a/magento_image.jpg' => [], + '/m/a/magento_thumbnail.jpg' => [], + ], + 'expectation' => [ + array_merge($imageExpectation, ['isMain' => true]), + $thumbnailExpectation, + ], + ], + 'with_changed_position' => [ + 'images' => [ + '/m/a/magento_image.jpg' => ['position' => '2'], + '/m/a/magento_thumbnail.jpg' => ['position' => '1'], + ], + 'expectation' => [ + array_merge($thumbnailExpectation, ['position' => '1']), + array_merge($imageExpectation, ['position' => '2', 'isMain' => true]), + ], + ], + ]; + } + /** * @dataProvider galleryImagesOnStoreViewDataProvider * @magentoDataFixture Magento/Catalog/_files/product_with_multiple_images.php * @magentoDataFixture Magento/Store/_files/second_store.php + * @magentoConfigFixture default/web/url/catalog_media_url_format hash * @magentoDbIsolation disabled * @param array $images * @param array $expectation diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Controller/Adminhtml/Category/Save/SaveCategoryTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Controller/Adminhtml/Category/Save/SaveCategoryTest.php new file mode 100644 index 0000000000000..dc74a2c2cba7b --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Catalog/Controller/Adminhtml/Category/Save/SaveCategoryTest.php @@ -0,0 +1,90 @@ +categoryRepository = $this->_objectManager->get(CategoryRepositoryInterface::class); + $this->getBlockByIdentifier = $this->_objectManager->get(GetBlockByIdentifierInterface::class); + $this->storeManager = $this->_objectManager->get(StoreManagerInterface::class); + } + + /** + * @inheritdoc + */ + protected function tearDown(): void + { + if (!empty($this->createdCategoryId)) { + try { + $this->categoryRepository->deleteByIdentifier($this->createdCategoryId); + } catch (NoSuchEntityException $e) { + //Category already deleted. + } + $this->createdCategoryId = null; + } + + parent::tearDown(); + } + + /** + * @magentoDataFixture Magento/Cms/_files/block.php + * + * @return void + */ + public function testCreateCategoryWithCmsBlock(): void + { + $storeId = (int)$this->storeManager->getStore('default')->getId(); + $blockId = $this->getBlockByIdentifier->execute('fixture_block', $storeId)->getId(); + $postData = [ + CategoryInterface::KEY_NAME => 'Category with cms block', + CategoryInterface::KEY_IS_ACTIVE => 1, + CategoryInterface::KEY_INCLUDE_IN_MENU => 1, + 'display_mode' => Category::DM_MIXED, + 'landing_page' => $blockId, + CategoryInterface::KEY_AVAILABLE_SORT_BY => ['position'], + 'default_sort_by' => 'position', + ]; + $responseData = $this->performSaveCategoryRequest($postData); + $this->assertRequestIsSuccessfullyPerformed($responseData); + $this->createdCategoryId = $responseData['category']['entity_id']; + $category = $this->categoryRepository->get($this->createdCategoryId); + $this->assertEquals($blockId, $category->getLandingPage()); + } +} diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Controller/Adminhtml/Category/Save/UpdateCategoryTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Controller/Adminhtml/Category/Save/UpdateCategoryTest.php new file mode 100644 index 0000000000000..75b96a1af3b09 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Catalog/Controller/Adminhtml/Category/Save/UpdateCategoryTest.php @@ -0,0 +1,114 @@ +categoryRepository = $this->_objectManager->get(CategoryRepositoryInterface::class); + $this->storeManager = $this->_objectManager->get(StoreManagerInterface::class); + } + + /** + * @dataProvider categoryDataProvider + * @magentoDataFixture Magento/Store/_files/second_store.php + * @magentoDataFixture Magento/Catalog/_files/category.php + * + * @param array $postData + * @return void + */ + public function testUpdateCategoryForDefaultStoreView(array $postData): void + { + $storeId = (int)$this->storeManager->getStore('default')->getId(); + $postData = array_merge($postData, ['store_id' => $storeId]); + $responseData = $this->performSaveCategoryRequest($postData); + $this->assertRequestIsSuccessfullyPerformed($responseData); + $category = $this->categoryRepository->get($postData['entity_id'], $postData['store_id']); + unset($postData['use_default']); + unset($postData['use_config']); + foreach ($postData as $key => $value) { + $this->assertEquals($value, $category->getData($key)); + } + } + + /** + * @return array + */ + public function categoryDataProvider(): array + { + return [ + [ + 'post_data' => [ + 'entity_id' => 333, + CategoryInterface::KEY_IS_ACTIVE => '0', + CategoryInterface::KEY_INCLUDE_IN_MENU => '0', + CategoryInterface::KEY_NAME => 'Category default store', + 'description' => 'Description for default store', + 'landing_page' => '', + 'display_mode' => Category::DM_MIXED, + CategoryInterface::KEY_AVAILABLE_SORT_BY => ['name', 'price'], + 'default_sort_by' => 'price', + 'filter_price_range' => 5, + 'url_key' => 'default-store-category', + 'meta_title' => 'meta_title default store', + 'meta_keywords' => 'meta_keywords default store', + 'meta_description' => 'meta_description default store', + 'custom_use_parent_settings' => '0', + 'custom_design' => '2', + 'page_layout' => '2columns-right', + 'custom_apply_to_products' => '1', + 'use_default' => [ + CategoryInterface::KEY_NAME => '0', + CategoryInterface::KEY_IS_ACTIVE => '0', + CategoryInterface::KEY_INCLUDE_IN_MENU => '0', + 'url_key' => '0', + 'meta_title' => '0', + 'custom_use_parent_settings' => '0', + 'custom_apply_to_products' => '0', + 'description' => '0', + 'landing_page' => '0', + 'display_mode' => '0', + 'custom_design' => '0', + 'page_layout' => '0', + 'meta_keywords' => '0', + 'meta_description' => '0', + 'custom_layout_update' => '0', + ], + 'use_config' => [ + CategoryInterface::KEY_AVAILABLE_SORT_BY => false, + 'default_sort_by' => false, + 'filter_price_range' => false, + ], + ], + ], + ]; + } +} diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Controller/Adminhtml/CategoryTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Controller/Adminhtml/CategoryTest.php index 6245e4e9f8de7..cd58cd2ac3819 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/Controller/Adminhtml/CategoryTest.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/Controller/Adminhtml/CategoryTest.php @@ -10,6 +10,8 @@ use Magento\Framework\Acl\Builder; use Magento\Backend\App\Area\FrontNameResolver; use Magento\Catalog\Api\CategoryRepositoryInterface; +use Magento\Framework\App\ProductMetadata; +use Magento\Framework\App\ProductMetadataInterface; use Magento\Framework\App\Request\Http as HttpRequest; use Magento\Framework\Message\MessageInterface; use Magento\Framework\Registry; @@ -270,7 +272,7 @@ public function testSuggestCategoriesActionNoSuggestions(): void */ public function saveActionDataProvider(): array { - return [ + $result = [ 'default values' => [ [ 'id' => '2', @@ -390,6 +392,20 @@ public function saveActionDataProvider(): array ], ], ]; + + $productMetadataInterface = Bootstrap::getObjectManager()->get(ProductMetadataInterface::class); + if ($productMetadataInterface->getEdition() !== ProductMetadata::EDITION_NAME) { + /** + * Skip save custom_design_from and custom_design_to attributes, + * because this logic is rewritten on EE by Catalog Schedule + */ + foreach (array_keys($result['custom values']) as $index) { + unset($result['custom values'][$index]['custom_design_from']); + unset($result['custom values'][$index]['custom_design_to']); + } + } + + return $result; } /** @@ -398,6 +414,11 @@ public function saveActionDataProvider(): array */ public function testIncorrectDateFrom(): void { + $productMetadataInterface = Bootstrap::getObjectManager()->get(ProductMetadataInterface::class); + if ($productMetadataInterface->getEdition() !== ProductMetadata::EDITION_NAME) { + $this->markTestSkipped('Skipped, because this logic is rewritten on EE by Catalog Schedule'); + } + $data = [ 'name' => 'Test Category', 'attribute_set_id' => '3', diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Controller/Adminhtml/Product/Gallery/UploadTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Controller/Adminhtml/Product/Gallery/UploadTest.php index b88980181fb63..283a3834eab59 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/Controller/Adminhtml/Product/Gallery/UploadTest.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/Controller/Adminhtml/Product/Gallery/UploadTest.php @@ -108,7 +108,7 @@ public function uploadActionDataProvider(): array 'name' => 'magento_image.jpg', 'type' => 'image/jpeg', 'file' => '/m/a/magento_image.jpg.tmp', - 'url' => 'http://localhost/pub/media/tmp/catalog/product/m/a/magento_image.jpg', + 'url' => 'http://localhost/media/tmp/catalog/product/m/a/magento_image.jpg', 'tmp_media_path' => '/m/a/magento_image.jpg', ], ], @@ -122,7 +122,7 @@ public function uploadActionDataProvider(): array 'name' => 'product_image.png', 'type' => 'image/png', 'file' => '/p/r/product_image.png.tmp', - 'url' => 'http://localhost/pub/media/tmp/catalog/product/p/r/product_image.png', + 'url' => 'http://localhost/media/tmp/catalog/product/p/r/product_image.png', 'tmp_media_path' => '/p/r/product_image.png', ], ], @@ -136,7 +136,7 @@ public function uploadActionDataProvider(): array 'name' => 'magento_image.gif', 'type' => 'image/gif', 'file' => '/m/a/magento_image.gif.tmp', - 'url' => 'http://localhost/pub/media/tmp/catalog/product/m/a/magento_image.gif', + 'url' => 'http://localhost/media/tmp/catalog/product/m/a/magento_image.gif', 'tmp_media_path' => '/m/a/magento_image.gif', ], ], diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Controller/Adminhtml/Product/Set/SaveTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Controller/Adminhtml/Product/Set/SaveTest.php index cdbc9bc90362f..523a1abd2e645 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/Controller/Adminhtml/Product/Set/SaveTest.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/Controller/Adminhtml/Product/Set/SaveTest.php @@ -25,12 +25,14 @@ use Magento\Framework\Serialize\Serializer\Json; use Magento\TestFramework\Helper\Bootstrap; use Magento\TestFramework\TestCase\AbstractBackendController; +use Psr\Log\LoggerInterface; /** * Testing for saving an existing or creating a new attribute set. * * @SuppressWarnings(PHPMD.CouplingBetweenObjects) * @magentoAppArea adminhtml + * @magentoAppIsolation enabled */ class SaveTest extends AbstractBackendController { @@ -90,7 +92,7 @@ class SaveTest extends AbstractBackendController protected function setUp(): void { parent::setUp(); - $this->logger = $this->_objectManager->get(Monolog::class); + $this->logger = $this->_objectManager->get(LoggerInterface::class); $this->syslogHandler = $this->_objectManager->create( Syslog::class, [ diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Controller/Category/CategoryUrlRewriteTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Controller/Category/CategoryUrlRewriteTest.php index ad62a4ec2df29..931bbf835521e 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/Controller/Category/CategoryUrlRewriteTest.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/Controller/Category/CategoryUrlRewriteTest.php @@ -7,16 +7,25 @@ namespace Magento\Catalog\Controller\Category; +use Magento\Catalog\Api\CategoryRepositoryInterface; +use Magento\Catalog\Model\Layer\Category; +use Magento\Catalog\Model\Layer\Resolver; +use Magento\Catalog\Model\Session as CatalogSession; use Magento\CatalogUrlRewrite\Model\CategoryUrlPathGenerator; use Magento\Framework\App\Config\ScopeConfigInterface; use Magento\Framework\App\Response\Http; use Magento\Framework\Registry; use Magento\Store\Model\ScopeInterface; +use Magento\Store\Model\StoreManagerInterface; +use Magento\TestFramework\Request; +use Magento\TestFramework\Response; +use Magento\TestFramework\Store\ExecuteInStoreContext; use Magento\TestFramework\TestCase\AbstractController; /** * Checks category availability on storefront by url rewrite * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) * @magentoConfigFixture default/catalog/seo/generate_category_product_rewrites 1 * @magentoDbIsolation enabled */ @@ -31,6 +40,18 @@ class CategoryUrlRewriteTest extends AbstractController /** @var string */ private $categoryUrlSuffix; + /** @var StoreManagerInterface */ + private $storeManager; + + /** @var CategoryRepositoryInterface */ + private $categoryRepository; + + /** @var CatalogSession */ + private $catalogSession; + + /** @var ExecuteInStoreContext */ + private $executeInStoreContext; + /** * @inheritdoc */ @@ -44,6 +65,10 @@ protected function setUp(): void CategoryUrlPathGenerator::XML_PATH_CATEGORY_URL_SUFFIX, ScopeInterface::SCOPE_STORE ); + $this->storeManager = $this->_objectManager->get(StoreManagerInterface::class); + $this->categoryRepository = $this->_objectManager->get(CategoryRepositoryInterface::class); + $this->catalogSession = $this->_objectManager->get(CatalogSession::class); + $this->executeInStoreContext = $this->_objectManager->get(ExecuteInStoreContext::class); } /** @@ -87,4 +112,87 @@ public function categoryRewriteProvider(): array ], ]; } + + /** + * Test category url on different store view + * + * @magentoDataFixture Magento/Catalog/_files/category.php + * @magentoDataFixture Magento/Store/_files/store.php + * @return void + */ + public function testCategoryUrlOnStoreView(): void + { + $id = 333; + $secondStoreUrlKey = 'category-1-second'; + $currentStore = $this->storeManager->getStore(); + $secondStore = $this->storeManager->getStore('test'); + $this->executeInStoreContext->execute( + $secondStore, + [$this, 'updateCategoryUrlKey'], + $id, + (int)$secondStore->getId(), + $secondStoreUrlKey + ); + $url = sprintf('/' . $secondStoreUrlKey . '%s', $this->categoryUrlSuffix); + $this->executeInStoreContext->execute($secondStore, [$this, 'dispatch'], $url); + $this->assertCategoryIsVisible(); + $this->assertEquals( + $secondStoreUrlKey, + $this->categoryRepository->get($id, (int)$secondStore->getId())->getUrlKey(), + 'Wrong category is registered' + ); + $this->cleanUpCachedObjects(); + $defaultStoreUrlKey = $this->categoryRepository->get($id, $currentStore->getId())->getUrlKey(); + $this->dispatch(sprintf($defaultStoreUrlKey . '%s', $this->categoryUrlSuffix)); + $this->assertCategoryIsVisible(); + } + + /** + * Assert that category is available in storefront + * + * @return void + */ + private function assertCategoryIsVisible(): void + { + $this->assertEquals( + Response::STATUS_CODE_200, + $this->getResponse()->getHttpResponseCode(), + 'Wrong response code is returned' + ); + $this->assertNotNull((int)$this->catalogSession->getData('last_viewed_category_id')); + } + + /** + * Clean up cached objects + * + * @return void + */ + private function cleanUpCachedObjects(): void + { + $this->catalogSession->clearStorage(); + $this->registry->unregister('current_category'); + $this->registry->unregister('category'); + $this->_objectManager->removeSharedInstance(Request::class); + $this->_objectManager->removeSharedInstance(Response::class); + $this->_objectManager->removeSharedInstance(Resolver::class); + $this->_objectManager->removeSharedInstance(Category::class); + $this->_objectManager->removeSharedInstance('categoryFilterList'); + $this->_response = null; + $this->_request = null; + } + + /** + * Update category url key + * + * @param int $id + * @param int $storeId + * @param string $categoryUrlKey + * @return void + */ + public function updateCategoryUrlKey(int $id, int $storeId, string $categoryUrlKey): void + { + $category = $this->categoryRepository->get($id, $storeId); + $category->setUrlKey($categoryUrlKey); + $this->categoryRepository->save($category); + } } diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Controller/Product/ViewTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Controller/Product/ViewTest.php index e8f9607530fba..0a6bf201538e2 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/Controller/Product/ViewTest.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/Controller/Product/ViewTest.php @@ -30,6 +30,7 @@ /** * Integration test for product view front action. * + * @magentoAppIsolation enabled * @magentoAppArea frontend * @magentoDbIsolation enabled * @SuppressWarnings(PHPMD.CouplingBetweenObjects) @@ -362,7 +363,7 @@ private function setupLoggerMock(): MockObject $logger = $this->getMockBuilder(LoggerInterface::class) ->disableOriginalConstructor() ->getMockForAbstractClass(); - $this->_objectManager->addSharedInstance($logger, MagentoMonologLogger::class); + $this->_objectManager->addSharedInstance($logger, LoggerInterface::class, true); return $logger; } diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Controller/ProductTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Controller/ProductTest.php index 3f9f788dc28c7..a02a2b7aeef92 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/Controller/ProductTest.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/Controller/ProductTest.php @@ -170,7 +170,7 @@ public function testGalleryAction(): void $this->dispatch(sprintf('catalog/product/gallery/id/%s', $product->getEntityId())); $this->assertStringContainsString( - 'http://localhost/pub/media/catalog/product/', + 'http://localhost/media/catalog/product/', $this->getResponse()->getBody() ); $this->assertStringContainsString($this->getProductImageFile(), $this->getResponse()->getBody()); diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Helper/ProductTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Helper/ProductTest.php index 98f623e5f193b..4c0f74f009330 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/Helper/ProductTest.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/Helper/ProductTest.php @@ -5,34 +5,69 @@ */ namespace Magento\Catalog\Helper; -class ProductTest extends \PHPUnit\Framework\TestCase +use Exception; +use Magento\Catalog\Api\Data\CategoryInterfaceFactory; +use Magento\Catalog\Api\Data\ProductInterfaceFactory; +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Catalog\Model\Category; +use Magento\Catalog\Model\Product; +use Magento\Catalog\Model\Product\Attribute\Source\Status; +use Magento\Catalog\Model\Product\Visibility; +use Magento\Catalog\Model\Session; +use Magento\Catalog\Helper\Product as ProductHelper; +use Magento\Framework\DataObject; +use Magento\Framework\ObjectManagerInterface; +use Magento\Framework\Registry; +use Magento\TestFramework\Helper\Bootstrap; +use PHPUnit\Framework\TestCase; + +/** + * @magentoAppIsolation enabled + * @magentoAppArea frontend + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ +class ProductTest extends TestCase { /** - * @var \Magento\Catalog\Helper\Product + * @var ProductHelper */ protected $helper; /** - * @var \Magento\Catalog\Api\ProductRepositoryInterface + * @var ProductRepositoryInterface */ protected $productRepository; + /** + * @var ObjectManagerInterface + */ + private $objectManager; + + /** + * @var ProductInterfaceFactory + */ + private $productFactory; + + /** + * @var Registry + */ + private $registry; + + /** + * @inheridoc + */ protected function setUp(): void { - \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->get(\Magento\Framework\App\State::class) - ->setAreaCode('frontend'); - $this->helper = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->get( - \Magento\Catalog\Helper\Product::class - ); - - /** @var \Magento\Catalog\Api\ProductRepositoryInterface $productRepository */ - $this->productRepository = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() - ->create(\Magento\Catalog\Api\ProductRepositoryInterface::class); + $this->objectManager = Bootstrap::getObjectManager(); + $this->helper = $this->objectManager->get(ProductHelper::class); + /** @var ProductInterfaceFactory $productInterfaceFactory */ + $this->productFactory = $this->objectManager->get(ProductInterfaceFactory::class); + $this->productRepository = $this->objectManager->get(ProductRepositoryInterface::class); + $this->registry = $this->objectManager->get(Registry::class); } /** * @magentoDataFixture Magento/CatalogUrlRewrite/_files/product_simple.php - * @magentoAppIsolation enabled */ public function testGetProductUrl() { @@ -46,20 +81,16 @@ public function testGetProductUrl() public function testGetPrice() { - /** @var $product \Magento\Catalog\Model\Product */ - $product = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\Product::class - ); + /** @var $product Product */ + $product = $this->productFactory->create(); $product->setPrice(49.95); $this->assertEquals(49.95, $this->helper->getPrice($product)); } public function testGetFinalPrice() { - /** @var $product \Magento\Catalog\Model\Product */ - $product = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\Product::class - ); + /** @var $product Product */ + $product = $this->productFactory->create(); $product->setPrice(49.95); $product->setFinalPrice(49.95); $this->assertEquals(49.95, $this->helper->getFinalPrice($product)); @@ -67,10 +98,8 @@ public function testGetFinalPrice() public function testGetImageUrl() { - /** @var $product \Magento\Catalog\Model\Product */ - $product = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\Product::class - ); + /** @var $product Product */ + $product = $this->productFactory->create(); $this->assertStringEndsWith('placeholder/image.jpg', $this->helper->getImageUrl($product)); $product->setImage('test_image.png'); @@ -79,10 +108,8 @@ public function testGetImageUrl() public function testGetSmallImageUrl() { - /** @var $product \Magento\Catalog\Model\Product */ - $product = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\Product::class - ); + /** @var $product Product */ + $product = $this->productFactory->create(); $this->assertStringEndsWith('placeholder/small_image.jpg', $this->helper->getSmallImageUrl($product)); $product->setSmallImage('test_image.png'); @@ -91,10 +118,8 @@ public function testGetSmallImageUrl() public function testGetThumbnailUrl() { - /** @var $product \Magento\Catalog\Model\Product */ - $product = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\Product::class - ); + /** @var $product Product */ + $product = $this->productFactory->create(); $this->assertStringEndsWith('placeholder/thumbnail.jpg', $this->helper->getThumbnailUrl($product)); $product->setThumbnail('test_image.png'); $this->assertStringEndsWith('/test_image.png', $this->helper->getThumbnailUrl($product)); @@ -102,26 +127,20 @@ public function testGetThumbnailUrl() public function testGetEmailToFriendUrl() { - $product = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\Product::class - ); + $product = $this->productFactory->create(); $product->setId(100); - $category = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\Category::class - ); + $category = $this->objectManager->create(CategoryInterfaceFactory::class)->create(); $category->setId(10); - /** @var $objectManager \Magento\TestFramework\ObjectManager */ - $objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); - $objectManager->get(\Magento\Framework\Registry::class)->register('current_category', $category); + $this->registry->register('current_category', $category); try { $this->assertStringEndsWith( 'sendfriend/product/send/id/100/cat_id/10/', $this->helper->getEmailToFriendUrl($product) ); - $objectManager->get(\Magento\Framework\Registry::class)->unregister('current_category'); - } catch (\Exception $e) { - $objectManager->get(\Magento\Framework\Registry::class)->unregister('current_category'); + $this->registry->unregister('current_category'); + } catch (Exception $e) { + $this->registry->unregister('current_category'); throw $e; } } @@ -137,17 +156,15 @@ public function testGetStatuses() public function testCanShow() { // non-visible or disabled - /** @var $product \Magento\Catalog\Model\Product */ - $product = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\Product::class - ); + /** @var $product Product */ + $product = $this->productFactory->create(); $this->assertFalse($this->helper->canShow($product)); $existingProduct = $this->productRepository->get('simple'); // enabled and visible $product->setId($existingProduct->getId()); - $product->setStatus(\Magento\Catalog\Model\Product\Attribute\Source\Status::STATUS_ENABLED); - $product->setVisibility(\Magento\Catalog\Model\Product\Visibility::VISIBILITY_BOTH); + $product->setStatus(Status::STATUS_ENABLED); + $product->setVisibility(Visibility::VISIBILITY_BOTH); $this->assertTrue($this->helper->canShow($product)); $this->assertTrue($this->helper->canShow((int)$product->getId())); @@ -193,39 +210,27 @@ public function testGetAttributeSourceModelByInputType() } /** - * @magentoDataFixture Magento/Catalog/_files/categories.php * @magentoDbIsolation enabled - * @magentoAppIsolation enabled + * @magentoDataFixture Magento/Catalog/_files/categories.php */ public function testInitProduct() { - /** @var $objectManager \Magento\TestFramework\ObjectManager */ - $objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); - - $objectManager->get(\Magento\Catalog\Model\Session::class)->setLastVisitedCategoryId(2); + $this->objectManager->get(Session::class)->setLastVisitedCategoryId(2); $product = $this->productRepository->get('simple'); $this->helper->initProduct($product->getId(), 'view'); - $this->assertInstanceOf( - \Magento\Catalog\Model\Product::class, - $objectManager->get(\Magento\Framework\Registry::class)->registry('current_product') - ); - $this->assertInstanceOf( - \Magento\Catalog\Model\Category::class, - $objectManager->get(\Magento\Framework\Registry::class)->registry('current_category') - ); + $this->assertInstanceOf(Product::class, $this->registry->registry('current_product')); + $this->assertInstanceOf(Category::class, $this->registry->registry('current_category')); } public function testPrepareProductOptions() { - /** @var $product \Magento\Catalog\Model\Product */ - $product = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\Product::class - ); - $buyRequest = new \Magento\Framework\DataObject(['qty' => 100, 'options' => ['option' => 'value']]); + /** @var $product Product */ + $product = $this->productFactory->create(); + $buyRequest = new DataObject(['qty' => 100, 'options' => ['option' => 'value']]); $this->helper->prepareProductOptions($product, $buyRequest); $result = $product->getPreconfiguredValues(); - $this->assertInstanceOf(\Magento\Framework\DataObject::class, $result); + $this->assertInstanceOf(DataObject::class, $result); $this->assertEquals(100, $result->getQty()); $this->assertEquals(['option' => 'value'], $result->getOptions()); } diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Model/Attribute/Backend/ConsumerTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Model/Attribute/Backend/ConsumerTest.php new file mode 100644 index 0000000000000..8dffcdbdd4582 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Catalog/Model/Attribute/Backend/ConsumerTest.php @@ -0,0 +1,163 @@ +objectManager = Bootstrap::getObjectManager(); + $this->publisherMock = $this->getMockForAbstractClass(BulkPublisherInterface::class); + + $this->bulkManagement = $this->objectManager->create( + BulkManagement::class, + [ + 'publisher' => $this->publisherMock + ] + ); + $this->bulkStatus = $this->objectManager->get(BulkStatus::class); + $catalogProductMock = $this->createMock(Product::class); + $productFlatIndexerProcessorMock = $this->createMock( + FlatProcessor::class + ); + $productPriceIndexerProcessorMock = $this->createMock( + PriceProcessor::class + ); + $operationManagementMock = $this->createMock( + OperationManagementInterface::class + ); + $actionMock = $this->createMock(Action::class); + $loggerMock = $this->createMock(LoggerInterface::class); + $this->serializer = $this->objectManager->get(SerializerInterface::class); + $entityManager = $this->objectManager->get(EntityManager::class); + $this->model = $this->objectManager->create( + Consumer::class, + [ + 'catalogProduct' => $catalogProductMock, + 'productFlatIndexerProcessor' => $productFlatIndexerProcessorMock, + 'productPriceIndexerProcessor' => $productPriceIndexerProcessorMock, + 'operationManagement' => $operationManagementMock, + 'action' => $actionMock, + 'logger' => $loggerMock, + 'serializer' => $this->serializer, + 'entityManager' => $entityManager + ] + ); + + parent::setUp(); + } + + /** + * Testing saving bulk operation during processing operation by attribute backend consumer + */ + public function testSaveOperationDuringProcess() + { + $operation = $this->prepareUpdateAttributesBulkAndOperation(); + try { + $this->model->process($operation); + } catch (\Exception $e) { + $this->fail(sprintf('Operation save process failed.: %s', $e->getMessage())); + } + $operationStatus = $operation->getStatus(); + $this->assertEquals( + 1, + $this->bulkStatus->getOperationsCountByBulkIdAndStatus(self::BULK_UUID, $operationStatus) + ); + } + + /** + * Schedules test bulk and returns operation + * @return OperationInterface + */ + private function prepareUpdateAttributesBulkAndOperation(): OperationInterface + { + // general bulk information + $bulkUuid = self::BULK_UUID; + $bulkDescription = 'Update attributes for 2 selected products'; + $topicName = 'product_action_attribute.update'; + $userId = 1; + /** @var OperationInterfaceFactory $operationFactory */ + $operationFactory = $this->objectManager->get(OperationInterfaceFactory::class); + $operation = $operationFactory->create(); + $operation->setBulkUuid($bulkUuid) + ->setTopicName($topicName) + ->setSerializedData($this->serializer->serialize( + ['product_ids' => [1,3], 'attributes' => [], 'store_id' => '0'] + )); + $this->bulkManagement->scheduleBulk($bulkUuid, [$operation], $bulkDescription, $userId); + return $operation; + } + + /** + * Clear created bulk and operation + */ + protected function tearDown(): void + { + $this->bulkManagement->deleteBulk(self::BULK_UUID); + parent::tearDown(); + } +} diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Model/Category/DataProviderTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Model/Category/DataProviderTest.php index 1d846fc154fc0..6ae6669956f62 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/Model/Category/DataProviderTest.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/Model/Category/DataProviderTest.php @@ -3,14 +3,17 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); namespace Magento\Catalog\Model\Category; +use Magento\Catalog\Api\CategoryRepositoryInterface; +use Magento\Catalog\Api\Data\CategoryInterface; use Magento\Catalog\Model\Category; use Magento\Catalog\Model\Category\Attribute\Backend\LayoutUpdate; +use Magento\Catalog\Model\Category\Attribute\LayoutUpdateManager; use Magento\Catalog\Model\CategoryFactory; use Magento\Framework\App\Config\ScopeConfigInterface; -use Magento\Framework\Exception\NoSuchEntityException; use Magento\Framework\Registry; use Magento\Store\Model\ScopeInterface; use Magento\Store\Model\StoreManagerInterface; @@ -19,42 +22,36 @@ use PHPUnit\Framework\TestCase; /** + * Testing category form data provider. + * * @magentoDbIsolation enabled * @magentoAppIsolation enabled * @magentoAppArea adminhtml + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class DataProviderTest extends TestCase { - /** - * @var DataProvider - */ + /** @var DataProvider */ private $dataProvider; - /** - * @var Registry - */ + /** @var Registry */ private $registry; - /** - * @var CategoryFactory - */ + /** @var CategoryFactory */ private $categoryFactory; - /** - * @var CategoryLayoutUpdateManager - */ + /** @var CategoryLayoutUpdateManager */ private $fakeFiles; - /** - * @var ScopeConfigInterface - */ + /** @var ScopeConfigInterface */ private $scopeConfig; - /** - * @var StoreManagerInterface - */ + /** @var StoreManagerInterface */ private $storeManager; + /** @var CategoryRepositoryInterface */ + private $categoryRepository; + /** * Create subject instance. * @@ -80,8 +77,7 @@ protected function setUp(): void $objectManager = Bootstrap::getObjectManager(); $objectManager->configure([ 'preferences' => [ - \Magento\Catalog\Model\Category\Attribute\LayoutUpdateManager::class - => \Magento\TestFramework\Catalog\Model\CategoryLayoutUpdateManager::class + LayoutUpdateManager::class => CategoryLayoutUpdateManager::class ] ]); parent::setUp(); @@ -91,6 +87,15 @@ protected function setUp(): void $this->fakeFiles = $objectManager->get(CategoryLayoutUpdateManager::class); $this->scopeConfig = $objectManager->get(ScopeConfigInterface::class); $this->storeManager = $objectManager->get(StoreManagerInterface::class); + $this->categoryRepository = $objectManager->get(CategoryRepositoryInterface::class); + } + + /** + * @inheritdoc + */ + protected function tearDown(): void + { + $this->registry->unregister('category'); } /** @@ -267,7 +272,7 @@ public function testExistingCategoryLayoutUnaffectedByDefaults(): void /** * Check if category page layout default value setting will apply to the new category during it's creation * - * @throws NoSuchEntityException + * @return void */ public function testNewCategoryLayoutMatchesDefault(): void { @@ -288,4 +293,32 @@ public function testNewCategoryLayoutMatchesDefault(): void $this->assertEquals($categoryDefaultPageLayout, $categoryPageLayout); } + + /** + * @magentoDataFixture Magento/Catalog/_files/category_on_second_store.php + * @return void + */ + public function testCategoryStoreView(): void + { + $id = 333; + $secondStore = $this->storeManager->getStore('test'); + $category = $this->categoryRepository->get($id, $secondStore->getId()); + $this->registerCategory($category); + $data = $this->dataProvider->getData(); + $this->assertNotEmpty($data); + $this->assertEquals('Category 1 Second', $data[$id]['name']); + $this->assertEquals('category-1-second-url-key', $data[$id]['url_key']); + } + + /** + * Register category in registry + * + * @param CategoryInterface $category + * @return void + */ + private function registerCategory(CategoryInterface $category): void + { + $this->registry->unregister('category'); + $this->registry->register('category', $category); + } } diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Model/CategoryRepositoryTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Model/CategoryRepositoryTest.php index 7fd7627c738d6..e829801d60e1a 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/Model/CategoryRepositoryTest.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/Model/CategoryRepositoryTest.php @@ -8,93 +8,83 @@ namespace Magento\Catalog\Model; use Magento\Catalog\Api\CategoryRepositoryInterface; -use Magento\Catalog\Api\CategoryRepositoryInterfaceFactory; +use Magento\Catalog\Api\Data\CategoryInterface; use Magento\Catalog\Model\ResourceModel\Category\CollectionFactory as CategoryCollectionFactory; use Magento\Catalog\Model\ResourceModel\Product\CollectionFactory; +use Magento\Cms\Api\GetBlockByIdentifierInterface; use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\ObjectManagerInterface; +use Magento\Store\Api\StoreManagementInterface; +use Magento\Store\Model\StoreManagerInterface; use Magento\TestFramework\Catalog\Model\CategoryLayoutUpdateManager; use Magento\TestFramework\Helper\Bootstrap; use PHPUnit\Framework\TestCase; /** * Provide tests for CategoryRepository model. + * + * @magentoDbIsolation enabled + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class CategoryRepositoryTest extends TestCase { - private const FIXTURE_CATEGORY_ID = 333; - private const FIXTURE_TWO_STORES_CATEGORY_ID = 555; - private const FIXTURE_SECOND_STORE_CODE = 'fixturestore'; - private const FIXTURE_FIRST_STORE_CODE = 'default'; + /** @var ObjectManagerInterface */ + private $objectManager; - /** - * @var CategoryLayoutUpdateManager - */ + /** @var CategoryLayoutUpdateManager */ private $layoutManager; - /** - * @var CategoryRepositoryInterfaceFactory - */ - private $repositoryFactory; + /** @var CategoryRepositoryInterface */ + private $categoryRepository; - /** - * @var CollectionFactory - */ + /** @var CollectionFactory */ private $productCollectionFactory; - /** - * @var CategoryCollectionFactory - */ + /** @var CategoryCollectionFactory */ private $categoryCollectionFactory; + /** @var StoreManagementInterface */ + private $storeManager; + + /** @var GetBlockByIdentifierInterface */ + private $getBlockByIdentifier; + /** - * Sets up common objects. - * - * @inheritDoc + * @inheritdoc */ protected function setUp(): void { - Bootstrap::getObjectManager()->configure([ + $this->objectManager = Bootstrap::getObjectManager(); + $this->objectManager->configure([ 'preferences' => [ \Magento\Catalog\Model\Category\Attribute\LayoutUpdateManager::class => \Magento\TestFramework\Catalog\Model\CategoryLayoutUpdateManager::class ] ]); - $this->repositoryFactory = Bootstrap::getObjectManager()->get(CategoryRepositoryInterfaceFactory::class); - $this->layoutManager = Bootstrap::getObjectManager()->get(CategoryLayoutUpdateManager::class); - $this->productCollectionFactory = Bootstrap::getObjectManager()->get(CollectionFactory::class); - $this->categoryCollectionFactory = Bootstrap::getObjectManager()->create(CategoryCollectionFactory::class); - } - - /** - * Create subject object. - * - * @return CategoryRepositoryInterface - */ - private function createRepo(): CategoryRepositoryInterface - { - return $this->repositoryFactory->create(); + $this->layoutManager = $this->objectManager->get(CategoryLayoutUpdateManager::class); + $this->productCollectionFactory = $this->objectManager->get(CollectionFactory::class); + $this->categoryCollectionFactory = $this->objectManager->get(CategoryCollectionFactory::class); + $this->categoryRepository = $this->objectManager->get(CategoryRepositoryInterface::class); + $this->storeManager = $this->objectManager->get(StoreManagerInterface::class); + $this->getBlockByIdentifier = $this->objectManager->get(GetBlockByIdentifierInterface::class); } /** * Test that custom layout file attribute is saved. * - * @return void - * @throws \Throwable * @magentoDataFixture Magento/Catalog/_files/category.php - * @magentoDbIsolation enabled * @magentoAppIsolation enabled + * + * @return void */ public function testCustomLayout(): void { - //New valid value - $repo = $this->createRepo(); - $category = $repo->get(self::FIXTURE_CATEGORY_ID); + $category = $this->categoryRepository->get(333); $newFile = 'test'; - $this->layoutManager->setCategoryFakeFiles(self::FIXTURE_CATEGORY_ID, [$newFile]); + $this->layoutManager->setCategoryFakeFiles(333, [$newFile]); $category->setCustomAttribute('custom_layout_update_file', $newFile); - $repo->save($category); - $repo = $this->createRepo(); - $category = $repo->get(self::FIXTURE_CATEGORY_ID); + $this->categoryRepository->save($category); + $category = $this->categoryRepository->get(333); $this->assertEquals($newFile, $category->getCustomAttribute('custom_layout_update_file')->getValue()); //Setting non-existent value @@ -102,7 +92,7 @@ public function testCustomLayout(): void $category->setCustomAttribute('custom_layout_update_file', $newFile); $caughtException = false; try { - $repo->save($category); + $this->categoryRepository->save($category); } catch (LocalizedException $exception) { $caughtException = true; } @@ -112,9 +102,9 @@ public function testCustomLayout(): void /** * Test removal of categories. * - * @magentoDbIsolation enabled * @magentoDataFixture Magento/Catalog/_files/categories.php * @magentoAppArea adminhtml + * * @return void */ public function testCategoryBehaviourAfterDelete(): void @@ -122,7 +112,7 @@ public function testCategoryBehaviourAfterDelete(): void $productCollection = $this->productCollectionFactory->create(); $deletedCategories = ['3', '4', '5', '13']; $categoryCollectionIds = $this->categoryCollectionFactory->create()->getAllIds(); - $this->createRepo()->deleteByIdentifier(3); + $this->categoryRepository->deleteByIdentifier(3); $this->assertEquals( 0, $productCollection->addCategoriesFilter(['in' => $deletedCategories])->getSize(), @@ -131,42 +121,87 @@ public function testCategoryBehaviourAfterDelete(): void $newCategoryCollectionIds = $this->categoryCollectionFactory->create()->getAllIds(); $difference = array_diff($categoryCollectionIds, $newCategoryCollectionIds); sort($difference); - $this->assertEquals( - $deletedCategories, - $difference, - 'Wrong categories was deleted' - ); + $this->assertEquals($deletedCategories, $difference, 'Wrong categories was deleted'); } /** * Verifies whether `get()` method `$storeId` attribute works as expected. * - * @magentoDbIsolation enabled * @magentoDataFixture Magento/Store/_files/core_fixturestore.php * @magentoDataFixture Magento/Catalog/_files/category_with_two_stores.php + * + * @return void */ - public function testGetCategoryForProvidedStore() + public function testGetCategoryForProvidedStore(): void { - $categoryRepository = $this->repositoryFactory->create(); - - $categoryDefault = $categoryRepository->get( - self::FIXTURE_TWO_STORES_CATEGORY_ID - ); - + $categoryId = 555; + $categoryDefault = $this->categoryRepository->get($categoryId); $this->assertSame('category-defaultstore', $categoryDefault->getUrlKey()); - - $categoryFirstStore = $categoryRepository->get( - self::FIXTURE_TWO_STORES_CATEGORY_ID, - self::FIXTURE_FIRST_STORE_CODE - ); - + $defaultStoreId = $this->storeManager->getStore('default')->getId(); + $categoryFirstStore = $this->categoryRepository->get($categoryId, $defaultStoreId); $this->assertSame('category-defaultstore', $categoryFirstStore->getUrlKey()); + $fixtureStoreId = $this->storeManager->getStore('fixturestore')->getId(); + $categorySecondStore = $this->categoryRepository->get($categoryId, $fixtureStoreId); + $this->assertSame('category-fixturestore', $categorySecondStore->getUrlKey()); + } - $categorySecondStore = $categoryRepository->get( - self::FIXTURE_TWO_STORES_CATEGORY_ID, - self::FIXTURE_SECOND_STORE_CODE - ); + /** + * @magentoDataFixture Magento/Store/_files/second_store.php + * @magentoDataFixture Magento/Catalog/_files/category.php + * @magentoDataFixture Magento/Cms/_files/block.php + * + * @return void + */ + public function testUpdateCategoryDefaultStoreView(): void + { + $categoryId = 333; + $defaultStoreId = (int)$this->storeManager->getStore('default')->getId(); + $secondStoreId = (int)$this->storeManager->getStore('fixture_second_store')->getId(); + $blockId = $this->getBlockByIdentifier->execute('fixture_block', $defaultStoreId)->getId(); + $origData = $this->categoryRepository->get($categoryId)->getData(); + unset($origData[CategoryInterface::KEY_UPDATED_AT]); + $category = $this->categoryRepository->get($categoryId, $defaultStoreId); + $dataForDefaultStore = [ + CategoryInterface::KEY_IS_ACTIVE => 0, + CategoryInterface::KEY_INCLUDE_IN_MENU => 0, + CategoryInterface::KEY_NAME => 'Category default store', + 'image' => 'test.png', + 'description' => 'Description for default store', + 'landing_page' => $blockId, + 'display_mode' => Category::DM_MIXED, + CategoryInterface::KEY_AVAILABLE_SORT_BY => ['name', 'price'], + 'default_sort_by' => 'price', + 'filter_price_range' => 5, + 'url_key' => 'default-store-category', + 'meta_title' => 'meta_title default store', + 'meta_keywords' => 'meta_keywords default store', + 'meta_description' => 'meta_description default store', + 'custom_use_parent_settings' => '0', + 'custom_design' => '2', + 'page_layout' => '2columns-right', + 'custom_apply_to_products' => '1', + ]; + $category->addData($dataForDefaultStore); + $updatedCategory = $this->categoryRepository->save($category); + $this->assertCategoryData($dataForDefaultStore, $updatedCategory); + $categorySecondStore = $this->categoryRepository->get($categoryId, $secondStoreId); + $this->assertCategoryData($origData, $categorySecondStore); + foreach ($dataForDefaultStore as $key => $value) { + $this->assertNotEquals($value, $categorySecondStore->getData($key)); + } + } - $this->assertSame('category-fixturestore', $categorySecondStore->getUrlKey()); + /** + * Assert category data. + * + * @param array $expectedData + * @param CategoryInterface $category + * @return void + */ + private function assertCategoryData(array $expectedData, CategoryInterface $category): void + { + foreach ($expectedData as $key => $value) { + $this->assertEquals($value, $category->getData($key)); + } } } diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Model/ImageUploaderTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Model/ImageUploaderTest.php index 51ebc4b03310e..09d08d23cf3e3 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/Model/ImageUploaderTest.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/Model/ImageUploaderTest.php @@ -8,47 +8,61 @@ namespace Magento\Catalog\Model; use Magento\Framework\App\Filesystem\DirectoryList; +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\Filesystem; +use Magento\Framework\Filesystem\Directory\WriteInterface; +use Magento\Framework\ObjectManagerInterface; +use Magento\TestFramework\Helper\Bootstrap; +use PHPUnit\Framework\TestCase; /** * Tests for the \Magento\Catalog\Model\ImageUploader class */ -class ImageUploaderTest extends \PHPUnit\Framework\TestCase +class ImageUploaderTest extends TestCase { + private const BASE_TMP_PATH = 'catalog/tmp/category'; + + private const BASE_PATH = 'catalog/category'; + /** - * @var \Magento\Framework\ObjectManagerInterface + * @var ObjectManagerInterface */ private $objectManager; /** - * @var \Magento\Catalog\Model\ImageUploader + * @var ImageUploader */ private $imageUploader; /** - * @var \Magento\Framework\Filesystem + * @var Filesystem */ private $filesystem; /** - * @var \Magento\Framework\Filesystem\Directory\WriteInterface + * @var WriteInterface */ private $mediaDirectory; + /** + * @var WriteInterface + */ + private $tmpDirectory; + /** * @inheritdoc */ protected function setUp(): void { - $this->objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); - /** @var \Magento\Framework\Filesystem $filesystem */ - $this->filesystem = $this->objectManager->get(\Magento\Framework\Filesystem::class); + $this->objectManager = Bootstrap::getObjectManager(); + $this->filesystem = $this->objectManager->get(Filesystem::class); $this->mediaDirectory = $this->filesystem->getDirectoryWrite(DirectoryList::MEDIA); - /** @var $uploader \Magento\MediaStorage\Model\File\Uploader */ + $this->tmpDirectory = $this->filesystem->getDirectoryWrite(DirectoryList::SYS_TMP); $this->imageUploader = $this->objectManager->create( - \Magento\Catalog\Model\ImageUploader::class, + ImageUploader::class, [ - 'baseTmpPath' => 'catalog/tmp/category', - 'basePath' => 'catalog/category', + 'baseTmpPath' => self::BASE_TMP_PATH, + 'basePath' => self::BASE_PATH, 'allowedExtensions' => ['jpg', 'jpeg', 'gif', 'png'], 'allowedMimeTypes' => ['image/jpg', 'image/jpeg', 'image/gif', 'image/png'] ] @@ -56,14 +70,15 @@ protected function setUp(): void } /** + * @dataProvider saveFileToTmpDirProvider + * @param string $fileName + * @param string $expectedName * @return void */ - public function testSaveFileToTmpDir(): void + public function testSaveFileToTmpDir(string $fileName, string $expectedName): void { - $fileName = 'magento_small_image.jpg'; - $tmpDirectory = $this->filesystem->getDirectoryWrite(\Magento\Framework\App\Filesystem\DirectoryList::SYS_TMP); $fixtureDir = realpath(__DIR__ . '/../_files'); - $filePath = $tmpDirectory->getAbsolutePath($fileName); + $filePath = $this->tmpDirectory->getAbsolutePath($fileName); copy($fixtureDir . DIRECTORY_SEPARATOR . $fileName, $filePath); $_FILES['image'] = [ @@ -75,10 +90,27 @@ public function testSaveFileToTmpDir(): void ]; $this->imageUploader->saveFileToTmpDir('image'); - $filePath = $this->imageUploader->getBaseTmpPath() . DIRECTORY_SEPARATOR. $fileName; + $filePath = $this->imageUploader->getBaseTmpPath() . DIRECTORY_SEPARATOR . $expectedName; $this->assertTrue(is_file($this->mediaDirectory->getAbsolutePath($filePath))); } + /** + * @return array + */ + public function saveFileToTmpDirProvider(): array + { + return [ + 'image_default_name' => [ + 'file_name' => 'magento_small_image.jpg', + 'expected_name' => 'magento_small_image.jpg', + ], + 'image_with_space_in_name' => [ + 'file_name' => 'magento_image with space in name.jpg', + 'expected_name' => 'magento_image_with_space_in_name.jpg', + ], + ]; + } + /** * Test that method rename files when move it with the same name into base directory. * @@ -90,7 +122,7 @@ public function testMoveFileFromTmp(): void { $expectedFilePath = $this->imageUploader->getBasePath() . DIRECTORY_SEPARATOR . 'magento_small_image_1.jpg'; - $this->assertFileNotExists($this->mediaDirectory->getAbsolutePath($expectedFilePath)); + $this->assertFileDoesNotExist($this->mediaDirectory->getAbsolutePath($expectedFilePath)); $this->imageUploader->moveFileFromTmp('magento_small_image.jpg'); @@ -102,12 +134,11 @@ public function testMoveFileFromTmp(): void */ public function testSaveFileToTmpDirWithWrongExtension(): void { - $this->expectException(\Magento\Framework\Exception\LocalizedException::class); + $this->expectException(LocalizedException::class); $this->expectExceptionMessage('File validation failed.'); $fileName = 'text.txt'; - $tmpDirectory = $this->filesystem->getDirectoryWrite(\Magento\Framework\App\Filesystem\DirectoryList::SYS_TMP); - $filePath = $tmpDirectory->getAbsolutePath($fileName); + $filePath = $this->tmpDirectory->getAbsolutePath($fileName); $file = fopen($filePath, "wb"); fwrite($file, 'just a text'); @@ -120,7 +151,7 @@ public function testSaveFileToTmpDirWithWrongExtension(): void ]; $this->imageUploader->saveFileToTmpDir('image'); - $filePath = $this->imageUploader->getBaseTmpPath() . DIRECTORY_SEPARATOR. $fileName; + $filePath = $this->imageUploader->getBaseTmpPath() . DIRECTORY_SEPARATOR . $fileName; $this->assertFalse(is_file($this->mediaDirectory->getAbsolutePath($filePath))); } @@ -129,12 +160,11 @@ public function testSaveFileToTmpDirWithWrongExtension(): void */ public function testSaveFileToTmpDirWithWrongFile(): void { - $this->expectException(\Magento\Framework\Exception\LocalizedException::class); + $this->expectException(LocalizedException::class); $this->expectExceptionMessage('File validation failed.'); $fileName = 'file.gif'; - $tmpDirectory = $this->filesystem->getDirectoryWrite(\Magento\Framework\App\Filesystem\DirectoryList::SYS_TMP); - $filePath = $tmpDirectory->getAbsolutePath($fileName); + $filePath = $this->tmpDirectory->getAbsolutePath($fileName); $file = fopen($filePath, "wb"); fwrite($file, 'just a text'); @@ -147,7 +177,7 @@ public function testSaveFileToTmpDirWithWrongFile(): void ]; $this->imageUploader->saveFileToTmpDir('image'); - $filePath = $this->imageUploader->getBaseTmpPath() . DIRECTORY_SEPARATOR. $fileName; + $filePath = $this->imageUploader->getBaseTmpPath() . DIRECTORY_SEPARATOR . $fileName; $this->assertFalse(is_file($this->mediaDirectory->getAbsolutePath($filePath))); } @@ -157,11 +187,10 @@ public function testSaveFileToTmpDirWithWrongFile(): void public static function tearDownAfterClass(): void { parent::tearDownAfterClass(); - $filesystem = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->get( - \Magento\Framework\Filesystem::class - ); - /** @var \Magento\Framework\Filesystem\Directory\WriteInterface $mediaDirectory */ + $filesystem = Bootstrap::getObjectManager()->get(Filesystem::class); + /** @var WriteInterface $mediaDirectory */ $mediaDirectory = $filesystem->getDirectoryWrite(DirectoryList::MEDIA); - $mediaDirectory->delete('tmp'); + $mediaDirectory->delete(self::BASE_TMP_PATH); + $mediaDirectory->delete(self::BASE_PATH); } } diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Model/Indexer/Product/Flat/Action/RelationTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Model/Indexer/Product/Flat/Action/RelationTest.php index e3b5bc8d5fd0d..c9ad7ad720daa 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/Model/Indexer/Product/Flat/Action/RelationTest.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/Model/Indexer/Product/Flat/Action/RelationTest.php @@ -11,7 +11,10 @@ use Magento\Framework\App\ResourceConnection; use Magento\Framework\DB\Adapter\AdapterInterface; use Magento\Store\Model\StoreManagerInterface; -use Magento\TestFramework\Catalog\Model\Indexer\Product\Flat\Action\Full as FlatIndexerFull; +use Magento\Catalog\Model\Indexer\Product\Flat\Action\Full as FlatIndexerFull; +use Magento\Catalog\Helper\Product\Flat\Indexer; +use Magento\Framework\Exception\LocalizedException; +use Magento\TestFramework\Helper\Bootstrap; /** * Test relation customization @@ -42,36 +45,76 @@ class RelationTest extends \Magento\TestFramework\Indexer\TestCase */ private $flatUpdated = []; + /** + * @var Indexer + */ + private $productIndexerHelper; + /** * @inheritdoc */ protected function setUp(): void { - $objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); - - $tableBuilderMock = $this->createMock(\Magento\Catalog\Model\Indexer\Product\Flat\TableBuilder::class); - $flatTableBuilderMock = $this->createMock(\Magento\Catalog\Model\Indexer\Product\Flat\FlatTableBuilder::class); + $objectManager = Bootstrap::getObjectManager(); - $productIndexerHelper = $objectManager->create( - \Magento\Catalog\Helper\Product\Flat\Indexer::class, - ['addChildData' => 1] + $this->productIndexerHelper = $objectManager->create( + Indexer::class, + ['addChildData' => true] ); $this->indexer = $objectManager->create( FlatIndexerFull::class, [ - 'productHelper' => $productIndexerHelper, - 'tableBuilder' => $tableBuilderMock, - 'flatTableBuilder' => $flatTableBuilderMock + 'productHelper' => $this->productIndexerHelper, ] ); - $this->storeManager = $objectManager->create(StoreManagerInterface::class); + $this->storeManager = $objectManager->get(StoreManagerInterface::class); $this->connection = $objectManager->get(ResourceConnection::class)->getConnection(); + } + + /** + * @inheritdoc + */ + protected function tearDown(): void + { + foreach ($this->flatUpdated as $flatTable) { + $this->connection->dropColumn($flatTable, 'child_id'); + $this->connection->dropColumn($flatTable, 'is_child'); + } + } + + /** + * Test that SQL generated for relation customization is valid + * + * @return void + * @throws LocalizedException + * @throws \Exception + */ + public function testExecute() : void + { + $this->addChildColumns(); + try { + $result = $this->indexer->execute(); + } catch (LocalizedException $e) { + if ($e->getPrevious() instanceof \Zend_Db_Statement_Exception) { + $this->fail($e->getMessage()); + } + throw $e; + } + $this->assertInstanceOf(FlatIndexerFull::class, $result); + } + /** + * Add child columns to tables if needed + * + * @return void + */ + private function addChildColumns(): void + { foreach ($this->storeManager->getStores() as $store) { - $flatTable = $productIndexerHelper->getFlatTableName($store->getId()); - if ($this->connection->isTableExists($flatTable) && - !$this->connection->tableColumnExists($flatTable, 'child_id') && - !$this->connection->tableColumnExists($flatTable, 'is_child') + $flatTable = $this->productIndexerHelper->getFlatTableName($store->getId()); + if ($this->connection->isTableExists($flatTable) + && !$this->connection->tableColumnExists($flatTable, 'child_id') + && !$this->connection->tableColumnExists($flatTable, 'is_child') ) { $this->connection->addColumn( $flatTable, @@ -103,35 +146,4 @@ protected function setUp(): void } } } - - /** - * @inheritdoc - */ - protected function tearDown(): void - { - foreach ($this->flatUpdated as $flatTable) { - $this->connection->dropColumn($flatTable, 'child_id'); - $this->connection->dropColumn($flatTable, 'is_child'); - } - } - - /** - * Test that SQL generated for relation customization is valid - * - * @return void - * @throws \Magento\Framework\Exception\LocalizedException - * @throws \Exception - */ - public function testExecute() : void - { - $this->markTestSkipped('MC-19675'); - try { - $this->indexer->execute(); - } catch (\Magento\Framework\Exception\LocalizedException $e) { - if ($e->getPrevious() instanceof \Zend_Db_Statement_Exception) { - $this->fail($e->getMessage()); - } - throw $e; - } - } } diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/AuthorizationTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/AuthorizationTest.php new file mode 100644 index 0000000000000..e2b80a975502f --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/AuthorizationTest.php @@ -0,0 +1,171 @@ +objectManager = Bootstrap::getObjectManager(); + $this->initializationHelper = $this->objectManager->get(Helper::class); + $this->productRepository = $this->objectManager->get(ProductRepositoryInterface::class); + $this->request = $this->objectManager->get(HttpRequest::class); + } + + /** + * Verify AuthorizedSavingOf + * + * @magentoDataFixture Magento/Catalog/_files/product_simple.php + * @param array $data + * + * @dataProvider postRequestData + */ + public function testAuthorizedSavingOf(array $data): void + { + $this->request->setPost(new Parameters($data)); + + /** @var Product $product */ + $product = $this->productRepository->get('simple'); + + $product = $this->initializationHelper->initialize($product); + $this->assertEquals('simple_new', $product->getName()); + $this->assertEquals( + 'container2', + $product->getCustomAttribute('options_container')->getValue() + ); + } + + /** + * @return array + */ + public function postRequestData(): array + { + return [ + [ + [ + 'product' => [ + 'name' => 'simple_new', + 'custom_design' => '', + 'page_layout' => '', + 'options_container' => 'container2', + 'custom_layout_update' => '', + 'custom_design_from' => '', + 'custom_design_to' => '', + 'custom_layout_update_file' => '', + ], + 'use_default' => [ + 'custom_design' => '1', + 'page_layout' => '1', + 'options_container' => '1', + 'custom_layout' => '1', + 'custom_design_from' => '1', + 'custom_design_to' => '1', + 'custom_layout_update_file' => '1', + ], + ], + ], + [ + [ + 'product' => [ + 'name' => 'simple_new', + 'page_layout' => '', + 'options_container' => 'container2', + 'custom_design' => '', + 'custom_design_from' => '', + 'custom_design_to' => '', + 'custom_layout' => '', + 'custom_layout_update_file' => '__no_update__', + ], + 'use_default' => null, + ], + ], + ]; + } + + /** + * Verify AuthorizedSavingOf when change design attributes + * + * @magentoDataFixture Magento/Catalog/_files/product_simple.php + * @param array $data + * + * @dataProvider postRequestDataException + */ + public function testAuthorizedSavingOfWithException(array $data): void + { + $this->expectException(AuthorizationException::class); + $this->expectErrorMessage('Not allowed to edit the product\'s design attributes'); + $this->request->setPost(new Parameters($data)); + + /** @var Product $product */ + $product = $this->productRepository->get('simple'); + + $this->initializationHelper->initialize($product); + } + + /** + * @return array + */ + public function postRequestDataException(): array + { + return [ + [ + [ + 'product' => [ + 'name' => 'simple_new', + 'page_layout' => '1column', + 'options_container' => 'container2', + 'custom_design' => '', + 'custom_design_from' => '', + 'custom_design_to' => '', + 'custom_layout' => '', + 'custom_layout_update_file' => '__no_update__', + ], + 'use_default' => null, + ], + ], + ]; + } +} diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/Gallery/ProcessorTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/Gallery/ProcessorTest.php index f836fe9cbb96a..fb384253e27a7 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/Gallery/ProcessorTest.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/Gallery/ProcessorTest.php @@ -46,9 +46,10 @@ public static function setUpBeforeClass(): void $mediaDirectory->create($config->getBaseTmpMediaPath()); $mediaDirectory->create($config->getBaseMediaPath()); - copy($fixtureDir . "/magento_image.jpg", self::$_mediaTmpDir . "/magento_image.jpg"); - copy($fixtureDir . "/magento_image.jpg", self::$_mediaDir . "/magento_image.jpg"); - copy($fixtureDir . "/magento_small_image.jpg", self::$_mediaTmpDir . "/magento_small_image.jpg"); + $mediaDirectory->getDriver()->filePutContents(self::$_mediaTmpDir . "/magento_image.jpg", file_get_contents($fixtureDir . "/magento_image.jpg")); + $mediaDirectory->getDriver()->filePutContents(self::$_mediaDir . "/magento_image.jpg", file_get_contents($fixtureDir . "/magento_image.jpg")); + $mediaDirectory->getDriver()->filePutContents(self::$_mediaTmpDir . "/magento_small_image.jpg", file_get_contents($fixtureDir . "/magento_small_image.jpg")); + } public static function tearDownAfterClass(): void diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/Gallery/UpdateHandlerTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/Gallery/UpdateHandlerTest.php index 2659f14c07c7a..481ec6aeac0f2 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/Gallery/UpdateHandlerTest.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/Gallery/UpdateHandlerTest.php @@ -8,6 +8,7 @@ namespace Magento\Catalog\Model\Product\Gallery; +use Magento\Catalog\Api\Data\ProductAttributeMediaGalleryEntryInterface; use Magento\Catalog\Api\Data\ProductInterface; use Magento\Catalog\Api\ProductRepositoryInterface; use Magento\Catalog\Model\Product; @@ -15,6 +16,7 @@ use Magento\Catalog\Model\ResourceModel\Product as ProductResource; use Magento\Catalog\Model\ResourceModel\Product\Gallery; use Magento\Framework\App\Filesystem\DirectoryList; +use Magento\Framework\EntityManager\MetadataPool; use Magento\Framework\Filesystem; use Magento\Framework\Filesystem\Directory\WriteInterface; use Magento\Framework\ObjectManagerInterface; @@ -79,16 +81,26 @@ class UpdateHandlerTest extends \PHPUnit\Framework\TestCase * @var int */ private $mediaAttributeId; + /** + * @var \Magento\Eav\Model\ResourceModel\UpdateHandler + */ + private $eavUpdateHandler; /** * @var StoreManagerInterface */ private $storeManager; + /** * @var int */ private $currentStoreId; + /** + * @var MetadataPool + */ + private $metadataPool; + /** * @inheritdoc */ @@ -108,6 +120,9 @@ protected function setUp(): void $this->mediaDirectory = $this->objectManager->get(Filesystem::class) ->getDirectoryWrite(DirectoryList::MEDIA); $this->mediaDirectory->writeFile($this->fileName, 'Test'); + $this->updateHandler = $this->objectManager->create(UpdateHandler::class); + $this->eavUpdateHandler = $this->objectManager->create(\Magento\Eav\Model\ResourceModel\UpdateHandler::class); + $this->metadataPool = $this->objectManager->get(MetadataPool::class); } /** @@ -197,6 +212,15 @@ public function testExecuteWithTwoImagesAndDifferentRolesOnStoreView(array $role $secondStoreId = (int)$this->storeRepository->get('fixture_second_store')->getId(); $imageRoles = ['image', 'small_image', 'thumbnail', 'swatch_image']; $product = $this->getProduct($secondStoreId); + $entityIdField = $product->getResource()->getLinkField(); + $entityData = []; + $entityData['store_id'] = $product->getStoreId(); + $entityData[$entityIdField] = $product->getData($entityIdField); + $entityData = array_merge($entityData, $roles); + $this->eavUpdateHandler->execute( + \Magento\Catalog\Api\Data\ProductInterface::class, + $entityData + ); $product->addData($roles); $this->updateHandler->execute($product); @@ -351,6 +375,23 @@ public function testExecuteWithTwoImagesOnStoreView(): void } } + /** + * @magentoDataFixture Magento/Catalog/_files/product_with_image.php + * @magentoDataFixture Magento/Catalog/_files/second_product_simple.php + * + * @return void + */ + public function testDeleteSharedImage(): void + { + $product = $this->getProduct(null, 'simple'); + $this->duplicateMediaGalleryForProduct('/m/a/magento_image.jpg', 'simple2'); + $secondProduct = $this->getProduct(null, 'simple2'); + $this->updateHandler->execute($this->prepareRemoveImage($product), []); + $product = $this->getProduct(null, 'simple'); + $this->assertEmpty($product->getMediaGalleryImages()->getItems()); + $this->checkProductImageExist($secondProduct, '/m/a/magento_image.jpg'); + } + /** * @inheritdoc */ @@ -371,11 +412,13 @@ protected function tearDown(): void * Returns current product. * * @param int|null $storeId + * @param string|null $sku * @return ProductInterface|Product */ - private function getProduct(?int $storeId = null): ProductInterface + private function getProduct(?int $storeId = null, ?string $sku = null): ProductInterface { - return $this->productRepository->get('simple', false, $storeId, true); + $sku = $sku ?: 'simple'; + return $this->productRepository->get($sku, false, $storeId, true); } /** @@ -416,7 +459,7 @@ public function testDeleteWithMultiWebsites(): void $product->setWebsiteIds([$defaultWebsiteId, $secondWebsiteId]); $this->productRepository->save($product); // Assert that product image has roles in global scope only - $imageRolesPerStore = $this->getProductStoreImageRoles($product); + $imageRolesPerStore = $this->getProductStoreImageRoles($product, $imageRoles); $this->assertEquals($image, $imageRolesPerStore[$globalScopeId]['image']); $this->assertEquals($image, $imageRolesPerStore[$globalScopeId]['small_image']); $this->assertEquals($image, $imageRolesPerStore[$globalScopeId]['thumbnail']); @@ -428,7 +471,7 @@ public function testDeleteWithMultiWebsites(): void $product->addData(array_fill_keys($imageRoles, $image)); $this->productRepository->save($product); // Assert that roles are assigned to product image for second store - $imageRolesPerStore = $this->getProductStoreImageRoles($product); + $imageRolesPerStore = $this->getProductStoreImageRoles($product, $imageRoles); $this->assertEquals($image, $imageRolesPerStore[$globalScopeId]['image']); $this->assertEquals($image, $imageRolesPerStore[$globalScopeId]['small_image']); $this->assertEquals($image, $imageRolesPerStore[$globalScopeId]['thumbnail']); @@ -454,7 +497,7 @@ public function testDeleteWithMultiWebsites(): void $this->assertEmpty($product->getMediaGalleryEntries()); $this->assertFileDoesNotExist($path); // Load image roles - $imageRolesPerStore = $this->getProductStoreImageRoles($product); + $imageRolesPerStore = $this->getProductStoreImageRoles($product, $imageRoles); // Assert that image roles are reset on global scope and removed on second store // as the product is no longer assigned to second website $this->assertEquals('no_selection', $imageRolesPerStore[$globalScopeId]['image']); @@ -464,16 +507,185 @@ public function testDeleteWithMultiWebsites(): void $this->assertArrayNotHasKey($secondStoreId, $imageRolesPerStore); } + /** + * Check that product images should be updated successfully regardless if the existing images exist or not + * + * @magentoDataFixture Magento/Catalog/_files/product_with_image.php + * @dataProvider updateImageDataProvider + * @param string $newFile + * @param string $expectedFile + * @param bool $exist + * @return void + */ + public function testUpdateImage(string $newFile, string $expectedFile, bool $exist): void + { + $product = $this->getProduct(Store::DEFAULT_STORE_ID); + $images = $product->getData('media_gallery')['images']; + $this->assertCount(1, $images); + $oldImage = reset($images) ?: []; + $this->assertEquals($oldImage['file'], $product->getImage()); + $this->assertEquals($oldImage['file'], $product->getSmallImage()); + $this->assertEquals($oldImage['file'], $product->getThumbnail()); + $path = $this->mediaDirectory->getAbsolutePath($this->config->getBaseMediaPath() . $oldImage['file']); + $tmpPath = $this->mediaDirectory->getAbsolutePath($this->config->getBaseTmpMediaPath() . $oldImage['file']); + $this->assertFileExists($path); + $this->mediaDirectory->getDriver()->copy($path, $tmpPath); + if (!$exist) { + $this->mediaDirectory->getDriver()->deleteFile($path); + $this->assertFileDoesNotExist($path); + } + // delete old image + $oldImage['removed'] = 1; + $newImage = [ + 'file' => $newFile, + 'position' => 1, + 'label' => 'New Image Alt Text', + 'disabled' => 0, + 'media_type' => 'image' + ]; + $newImageRoles = [ + 'image' => $newFile, + 'small_image' => 'no_selection', + 'thumbnail' => 'no_selection', + ]; + $product->setData('media_gallery', ['images' => [$oldImage, $newImage]]); + $product->addData($newImageRoles); + $this->updateHandler->execute($product); + $product = $this->getProduct(Store::DEFAULT_STORE_ID); + $images = $product->getData('media_gallery')['images']; + $this->assertCount(1, $images); + $image = reset($images) ?: []; + $this->assertEquals($newImage['label'], $image['label']); + $this->assertEquals($expectedFile, $product->getImage()); + $this->assertEquals($newImageRoles['small_image'], $product->getSmallImage()); + $this->assertEquals($newImageRoles['thumbnail'], $product->getThumbnail()); + $path = $this->mediaDirectory->getAbsolutePath($this->config->getBaseMediaPath() . $product->getImage()); + // Assert that the image exists on disk. + $this->assertFileExists($path); + } + + /** + * @return array[] + */ + public function updateImageDataProvider(): array + { + return [ + [ + '/m/a/magento_image.jpg', + '/m/a/magento_image_1.jpg', + true + ], + [ + '/m/a/magento_image.jpg', + '/m/a/magento_image.jpg', + false + ], + [ + '/m/a/magento_small_image.jpg', + '/m/a/magento_small_image.jpg', + true + ], + [ + '/m/a/magento_small_image.jpg', + '/m/a/magento_small_image.jpg', + false + ] + ]; + } + + /** + * Check product image link and product image exist + * + * @param ProductInterface $product + * @param string $imagePath + * @return void + */ + private function checkProductImageExist(ProductInterface $product, string $imagePath): void + { + $productImageItem = $product->getMediaGalleryImages()->getFirstItem(); + $this->assertEquals($imagePath, $productImageItem->getFile()); + $productImageFile = $productImageItem->getPath(); + $this->assertNotEmpty($productImageFile); + $this->assertTrue($this->mediaDirectory->getDriver()->isExists($productImageFile)); + $this->fileName = $productImageFile; + } + + /** + * Prepare the product to remove image + * + * @param ProductInterface $product + * @return ProductInterface + */ + private function prepareRemoveImage(ProductInterface $product): ProductInterface + { + $item = $product->getMediaGalleryImages()->getFirstItem(); + $item->setRemoved('1'); + $galleryData = [ + 'images' => [ + (int)$item->getValueId() => $item->getData(), + ] + ]; + $product->setData(ProductInterface::MEDIA_GALLERY, $galleryData); + $product->setStoreId(0); + + return $product; + } + + /** + * Duplicate media gallery entries for a product + * + * @param string $imagePath + * @param string $productSku + * @return void + */ + private function duplicateMediaGalleryForProduct(string $imagePath, string $productSku): void + { + $product = $this->getProduct(null, $productSku); + $connect = $this->galleryResource->getConnection(); + $select = $connect->select()->from($this->galleryResource->getMainTable())->where('value = ?', $imagePath); + $result = $connect->fetchRow($select); + $value_id = $result['value_id']; + unset($result['value_id']); + $rows = [ + 'attribute_id' => $result['attribute_id'], + 'value' => $result['value'], + ProductAttributeMediaGalleryEntryInterface::MEDIA_TYPE => $result['media_type'], + ProductAttributeMediaGalleryEntryInterface::DISABLED => $result['disabled'], + ]; + $connect->insert($this->galleryResource->getMainTable(), $rows); + $select = $connect->select() + ->from($this->galleryResource->getTable(Gallery::GALLERY_VALUE_TABLE)) + ->where('value_id = ?', $value_id); + $result = $connect->fetchRow($select); + $newValueId = (int)$value_id + 1; + $metadata = $this->metadataPool->getMetadata(ProductInterface::class); + $linkField = $metadata->getLinkField(); + $rows = [ + 'value_id' => $newValueId, + 'store_id' => $result['store_id'], + ProductAttributeMediaGalleryEntryInterface::LABEL => $result['label'], + ProductAttributeMediaGalleryEntryInterface::POSITION => $result['position'], + ProductAttributeMediaGalleryEntryInterface::DISABLED => $result['disabled'], + $linkField => $product->getData($linkField), + ]; + $connect->insert($this->galleryResource->getTable(Gallery::GALLERY_VALUE_TABLE), $rows); + $rows = ['value_id' => $newValueId, $linkField => $product->getData($linkField)]; + $connect->insert($this->galleryResource->getTable(Gallery::GALLERY_VALUE_TO_ENTITY_TABLE), $rows); + } + /** * @param Product $product + * @param array $roles * @return array */ - private function getProductStoreImageRoles(Product $product): array + private function getProductStoreImageRoles(Product $product, array $roles = []): array { $imageRolesPerStore = []; $stores = array_keys($this->storeManager->getStores(true)); foreach ($this->galleryResource->getProductImages($product, $stores) as $role) { - $imageRolesPerStore[$role['store_id']][$role['attribute_code']] = $role['filepath']; + if (empty($roles) || in_array($role['attribute_code'], $roles)) { + $imageRolesPerStore[$role['store_id']][$role['attribute_code']] = $role['filepath']; + } } return $imageRolesPerStore; } diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/ImageTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/ImageTest.php index 1c9b8f2ce1918..b741285ebb6f1 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/ImageTest.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/ImageTest.php @@ -52,7 +52,7 @@ public function testSaveFilePlaceholder($model) public function testGetUrlPlaceholder($model) { $this->assertStringMatchesFormat( - 'http://localhost/pub/static/%s/frontend/%s/Magento_Catalog/images/product/placeholder/image.jpg', + 'http://localhost/static/%s/frontend/%s/Magento_Catalog/images/product/placeholder/image.jpg', $model->getUrl() ); } diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/Option/Type/File/ValidatorFileTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/Option/Type/File/ValidatorFileTest.php index 0be889f546a2b..64b009b5b8d13 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/Option/Type/File/ValidatorFileTest.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/Option/Type/File/ValidatorFileTest.php @@ -362,8 +362,8 @@ protected function expectedValidate() return [ 'type' => 'image/jpeg', 'title' => 'test.jpg', - 'quote_path' => 'custom_options/quote/t/e/RandomString', - 'order_path' => 'custom_options/order/t/e/RandomString', + 'quote_path' => 'custom_options/quote/R/a/RandomString', + 'order_path' => 'custom_options/order/R/a/RandomString', 'size' => '3046', 'width' => 136, 'height' => 131, diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/Type/AbstractTypeTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/Type/AbstractTypeTest.php index c72e7e0e1d078..4cd9d74e58418 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/Type/AbstractTypeTest.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/Type/AbstractTypeTest.php @@ -3,41 +3,60 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); namespace Magento\Catalog\Model\Product\Type; +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Catalog\Model\Product; +use Magento\Catalog\Model\Product\Option; +use Magento\Catalog\Model\Product\Type; +use Magento\Catalog\Model\ProductRepository; +use Magento\Catalog\Model\ResourceModel\Eav\Attribute; +use Magento\Eav\Model\Config; +use Magento\Framework\DataObject; +use Magento\Framework\Event\ManagerInterface; +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\Filesystem; +use Magento\Framework\Registry; +use Magento\Framework\Serialize\Serializer\Json; +use Magento\MediaStorage\Helper\File\Storage\Database; +use Magento\TestFramework\Helper\Bootstrap; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ -class AbstractTypeTest extends \PHPUnit\Framework\TestCase +class AbstractTypeTest extends TestCase { /** - * @var \Magento\Catalog\Model\Product\Type\AbstractType + * @var AbstractType */ protected $_model; protected function setUp(): void { - $productRepository = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->get( - \Magento\Catalog\Api\ProductRepositoryInterface::class + $productRepository = Bootstrap::getObjectManager()->get( + ProductRepositoryInterface::class ); - $catalogProductOption = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->get( - \Magento\Catalog\Model\Product\Option::class + $catalogProductOption = Bootstrap::getObjectManager()->get( + Option::class ); - $catalogProductType = $this->createMock(\Magento\Catalog\Model\Product\Type::class); - $eventManager = $this->createPartialMock(\Magento\Framework\Event\ManagerInterface::class, ['dispatch']); - $fileStorageDb = $this->createMock(\Magento\MediaStorage\Helper\File\Storage\Database::class); - $filesystem = $this->createMock(\Magento\Framework\Filesystem::class); - $registry = $this->createMock(\Magento\Framework\Registry::class); - $logger = $this->createMock(\Psr\Log\LoggerInterface::class); - $serializer = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->get( - \Magento\Framework\Serialize\Serializer\Json::class + $catalogProductType = $this->createMock(Type::class); + $eventManager = $this->createPartialMock(ManagerInterface::class, ['dispatch']); + $fileStorageDb = $this->createMock(Database::class); + $filesystem = $this->createMock(Filesystem::class); + $registry = $this->createMock(Registry::class); + $logger = $this->createMock(LoggerInterface::class); + $serializer = Bootstrap::getObjectManager()->get( + Json::class ); $this->_model = $this->getMockForAbstractClass( - \Magento\Catalog\Model\Product\Type\AbstractType::class, + AbstractType::class, [ $catalogProductOption, - \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->get(\Magento\Eav\Model\Config::class), + Bootstrap::getObjectManager()->get(Config::class), $catalogProductType, $eventManager, $fileStorageDb, @@ -53,7 +72,7 @@ protected function setUp(): void public function testGetRelationInfo() { $info = $this->_model->getRelationInfo(); - $this->assertInstanceOf(\Magento\Framework\DataObject::class, $info); + $this->assertInstanceOf(DataObject::class, $info); $this->assertNotSame($info, $this->_model->getRelationInfo()); } @@ -72,8 +91,8 @@ public function testGetParentIdsByChild() */ public function testGetSetAttributes() { - $repository = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\ProductRepository::class + $repository = Bootstrap::getObjectManager()->create( + ProductRepository::class ); $product = $repository->get('simple'); // fixture @@ -85,7 +104,7 @@ public function testGetSetAttributes() $this->assertArrayHasKey('name', $attributes); $isTypeExists = false; foreach ($attributes as $attribute) { - $this->assertInstanceOf(\Magento\Catalog\Model\ResourceModel\Eav\Attribute::class, $attribute); + $this->assertInstanceOf(Attribute::class, $attribute); $applyTo = $attribute->getApplyTo(); if (count($applyTo) > 0 && !in_array('simple', $applyTo)) { $isTypeExists = true; @@ -97,9 +116,9 @@ public function testGetSetAttributes() public function testAttributesCompare() { - $attribute[1] = new \Magento\Framework\DataObject(['group_sort_path' => 1, 'sort_path' => 10]); - $attribute[2] = new \Magento\Framework\DataObject(['group_sort_path' => 1, 'sort_path' => 5]); - $attribute[3] = new \Magento\Framework\DataObject(['group_sort_path' => 2, 'sort_path' => 10]); + $attribute[1] = new DataObject(['group_sort_path' => 1, 'sort_path' => 10]); + $attribute[2] = new DataObject(['group_sort_path' => 1, 'sort_path' => 5]); + $attribute[3] = new DataObject(['group_sort_path' => 2, 'sort_path' => 10]); $this->assertEquals(1, $this->_model->attributesCompare($attribute[1], $attribute[2])); $this->assertEquals(-1, $this->_model->attributesCompare($attribute[2], $attribute[1])); $this->assertEquals(-1, $this->_model->attributesCompare($attribute[1], $attribute[3])); @@ -110,9 +129,9 @@ public function testAttributesCompare() public function testGetAttributeById() { - /** @var $product \Magento\Catalog\Model\Product */ - $product = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\Product::class + /** @var $product Product */ + $product = Bootstrap::getObjectManager()->create( + Product::class )->load( 1 ); @@ -120,8 +139,8 @@ public function testGetAttributeById() $this->assertNull($this->_model->getAttributeById(-1, $product)); $this->assertNull($this->_model->getAttributeById(null, $product)); - $sku = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->get( - \Magento\Eav\Model\Config::class + $sku = Bootstrap::getObjectManager()->get( + Config::class )->getAttribute( 'catalog_product', 'sku' @@ -140,8 +159,8 @@ public function testGetAttributeById() */ public function testIsVirtual() { - $product = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\Product::class + $product = Bootstrap::getObjectManager()->create( + Product::class ); $this->assertFalse($this->_model->isVirtual($product)); } @@ -151,8 +170,8 @@ public function testIsVirtual() */ public function testIsSalable() { - $product = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\Product::class + $product = Bootstrap::getObjectManager()->create( + Product::class ); $this->assertTrue($this->_model->isSalable($product)); @@ -169,20 +188,20 @@ public function testIsSalable() */ public function testPrepareForCart() { - /** @var $product \Magento\Catalog\Model\Product */ - $product = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\Product::class + /** @var $product Product */ + $product = Bootstrap::getObjectManager()->create( + Product::class ); $product->load(10); // fixture $this->assertEmpty($product->getCustomOption('info_buyRequest')); $requestData = ['qty' => 5]; - $result = $this->_model->prepareForCart(new \Magento\Framework\DataObject($requestData), $product); + $result = $this->_model->prepareForCart(new DataObject($requestData), $product); $this->assertArrayHasKey(0, $result); $this->assertSame($product, $result[0]); $buyRequest = $product->getCustomOption('info_buyRequest'); - $this->assertInstanceOf(\Magento\Framework\DataObject::class, $buyRequest); + $this->assertInstanceOf(DataObject::class, $buyRequest); $this->assertEquals($product->getId(), $buyRequest->getProductId()); $this->assertSame($product, $buyRequest->getProduct()); $this->assertEquals(json_encode($requestData), $buyRequest->getValue()); @@ -193,15 +212,15 @@ public function testPrepareForCart() */ public function testPrepareForCartOptionsException() { - $repository = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\ProductRepository::class + $repository = Bootstrap::getObjectManager()->create( + ProductRepository::class ); $product = $repository->get('simple'); // fixture $this->assertStringContainsString( "The product's required option(s) weren't entered. Make sure the options are entered and try again.", - $this->_model->prepareForCart(new \Magento\Framework\DataObject(), $product) + $this->_model->prepareForCart(new DataObject(), $product) ); } @@ -215,9 +234,9 @@ public function testGetSpecifyOptionMessage() public function testCheckProductBuyState() { - /** @var $product \Magento\Catalog\Model\Product */ - $product = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\Product::class + /** @var $product Product */ + $product = Bootstrap::getObjectManager()->create( + Product::class ); $product->setSkipCheckRequiredOption('_'); $this->_model->checkProductBuyState($product); @@ -228,10 +247,10 @@ public function testCheckProductBuyState() */ public function testCheckProductBuyStateException() { - $this->expectException(\Magento\Framework\Exception\LocalizedException::class); + $this->expectException(LocalizedException::class); - $repository = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\ProductRepository::class + $repository = Bootstrap::getObjectManager()->create( + ProductRepository::class ); $product = $repository->get('simple'); // fixture @@ -243,9 +262,9 @@ public function testCheckProductBuyStateException() */ public function testGetOrderOptions() { - /** @var $product \Magento\Catalog\Model\Product */ - $product = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\Product::class + /** @var $product Product */ + $product = Bootstrap::getObjectManager()->create( + Product::class ); $this->assertEquals([], $this->_model->getOrderOptions($product)); @@ -283,8 +302,8 @@ public function testGetOrderOptions() */ public function testBeforeSave() { - $repository = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\ProductRepository::class + $repository = Bootstrap::getObjectManager()->create( + ProductRepository::class ); $product = $repository->get('simple'); // fixture @@ -299,8 +318,8 @@ public function testBeforeSave() */ public function testGetSku() { - $repository = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\ProductRepository::class + $repository = Bootstrap::getObjectManager()->create( + ProductRepository::class ); $product = $repository->get('simple'); // fixture @@ -312,9 +331,9 @@ public function testGetSku() */ public function testGetOptionSku() { - /** @var $product \Magento\Catalog\Model\Product */ - $product = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\Product::class + /** @var $product Product */ + $product = Bootstrap::getObjectManager()->create( + Product::class ); $this->assertEmpty($this->_model->getOptionSku($product)); @@ -336,7 +355,7 @@ public function testGetOptionSku() public function testGetWeight() { - $product = new \Magento\Framework\DataObject(); + $product = new DataObject(); $this->assertEmpty($this->_model->getWeight($product)); $product->setWeight('value'); $this->assertEquals('value', $this->_model->getWeight($product)); @@ -346,16 +365,16 @@ public function testHasOptions() { $this->markTestIncomplete('Bug MAGE-2814'); - $product = new \Magento\Framework\DataObject(); + $product = new DataObject(); $this->assertFalse($this->_model->hasOptions($product)); - $product = new \Magento\Framework\DataObject(['has_options' => true]); + $product = new DataObject(['has_options' => true]); $this->assertTrue($this->_model->hasOptions($product)); } public function testHasRequiredOptions() { - $product = new \Magento\Framework\DataObject(); + $product = new DataObject(); $this->assertFalse($this->_model->hasRequiredOptions($product)); $product->setRequiredOptions(1); $this->assertTrue($this->_model->hasRequiredOptions($product)); @@ -363,7 +382,7 @@ public function testHasRequiredOptions() public function testGetSetStoreFilter() { - $product = new \Magento\Framework\DataObject(); + $product = new DataObject(); $this->assertNull($this->_model->getStoreFilter($product)); $store = new \StdClass(); $this->_model->setStoreFilter($store, $product); @@ -374,8 +393,8 @@ public function testGetForceChildItemQtyChanges() { $this->assertFalse( $this->_model->getForceChildItemQtyChanges( - \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\Product::class + Bootstrap::getObjectManager()->create( + Product::class ) ) ); @@ -387,8 +406,8 @@ public function testPrepareQuoteItemQty() 3.0, $this->_model->prepareQuoteItemQty( 3, - \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\Product::class + Bootstrap::getObjectManager()->create( + Product::class ) ) ); @@ -396,12 +415,12 @@ public function testPrepareQuoteItemQty() public function testAssignProductToOption() { - $product = new \Magento\Framework\DataObject(); - $option = new \Magento\Framework\DataObject(); + $product = new DataObject(); + $option = new DataObject(); $this->_model->assignProductToOption($product, $option, $product); $this->assertSame($product, $option->getProduct()); - $option = new \Magento\Framework\DataObject(); + $option = new DataObject(); $this->_model->assignProductToOption(null, $option, $product); $this->assertSame($product, $option->getProduct()); } @@ -415,8 +434,8 @@ public function testSetConfig() { $this->assertFalse( $this->_model->isComposite( - \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\Product::class + Bootstrap::getObjectManager()->create( + Product::class ) ) ); @@ -425,8 +444,8 @@ public function testSetConfig() $this->_model->setConfig($config); $this->assertTrue( $this->_model->isComposite( - \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\Product::class + Bootstrap::getObjectManager()->create( + Product::class ) ) ); @@ -438,8 +457,8 @@ public function testSetConfig() */ public function testGetSearchableData() { - $product = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\Product::class + $product = Bootstrap::getObjectManager()->create( + Product::class ); $product->load(1); // fixture @@ -467,10 +486,41 @@ public function testProcessBuyRequest() public function testCheckProductConfiguration() { - $product = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\Product::class + $product = Bootstrap::getObjectManager()->create( + Product::class ); - $buyRequest = new \Magento\Framework\DataObject(['qty' => 5]); + $buyRequest = new DataObject(['qty' => 5]); $this->_model->checkProductConfiguration($product, $buyRequest); } + + /** + * Test that only one exception appears instead of multiple identical exceptions + * + * @magentoDataFixture Magento/Catalog/_files/product_simple.php + * + * @return void + */ + public function testPrepareOptions(): void + { + $exceptionMessage = + "The product's required option(s) weren't entered. Make sure the options are entered and try again."; + $product = Bootstrap::getObjectManager()->create( + Product::class + ); + $product->load(1); + $buyRequest = new DataObject(['product' => 1]); + $method = new \ReflectionMethod( + AbstractType::class, + '_prepareOptions' + ); + $method->setAccessible(true); + $exceptionIsThrown = false; + try { + $method->invoke($this->_model, $buyRequest, $product, 'full'); + } catch (LocalizedException $exception) { + $this->assertEquals($exceptionMessage, $exception->getMessage()); + $exceptionIsThrown = true; + } + $this->assertTrue($exceptionIsThrown); + } } diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/UpdateProductWebsiteTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/UpdateProductWebsiteTest.php index c63a3c8249e77..e973a25d07354 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/UpdateProductWebsiteTest.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/UpdateProductWebsiteTest.php @@ -10,7 +10,7 @@ use Magento\Catalog\Api\Data\ProductInterface; use Magento\Catalog\Api\ProductRepositoryInterface; use Magento\Catalog\Model\ResourceModel\Product\Website\Link; -use Magento\Framework\Exception\NoSuchEntityException; +use Magento\Framework\Exception\CouldNotSaveException; use Magento\Framework\ObjectManagerInterface; use Magento\Store\Api\WebsiteRepositoryInterface; use Magento\TestFramework\Helper\Bootstrap; @@ -82,10 +82,10 @@ public function testUnassignProductFromWebsite(): void */ public function testAssignNonExistingWebsite(): void { - $messageFormat = 'The website with id %s that was requested wasn\'t found. Verify the website and try again.'; + $messageFormat = 'The product was unable to be saved. Please try again.'; $nonExistingWebsiteId = 921564; - $this->expectException(NoSuchEntityException::class); - $this->expectExceptionMessage((string)__(sprintf($messageFormat, $nonExistingWebsiteId))); + $this->expectException(CouldNotSaveException::class); + $this->expectExceptionMessage((string)__($messageFormat)); $this->updateProductWebsites('simple2', [$nonExistingWebsiteId]); } diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Model/ProductRepositoryTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Model/ProductRepositoryTest.php index 8908561702dd0..f1d1352bcb05b 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/Model/ProductRepositoryTest.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/Model/ProductRepositoryTest.php @@ -33,6 +33,12 @@ */ class ProductRepositoryTest extends TestCase { + private const STUB_STORE_ID = 1; + private const STUB_STORE_ID_GLOBAL = 0; + private const STUB_PRODUCT_NAME = 'Simple Product'; + private const STUB_UPDATED_PRODUCT_NAME = 'updated'; + private const STUB_PRODUCT_SKU = 'simple'; + /** * @var ObjectManagerInterface */ @@ -273,4 +279,55 @@ private function assertProductNotExist(string $sku): void )); $this->productRepository->get($sku); } + + /** + * Tests product repository update + * + * @dataProvider productUpdateDataProvider + * @magentoDataFixture Magento/Catalog/_files/product_simple.php + * @param int $storeId + * @param int $checkStoreId + * @param string $expectedNameStore + * @param string $expectedNameCheckedStore + */ + public function testProductUpdate( + int $storeId, + int $checkStoreId, + string $expectedNameStore, + string $expectedNameCheckedStore + ): void { + $sku = self::STUB_PRODUCT_SKU; + + $product = $this->productRepository->get($sku, false, $storeId); + $product->setName(self::STUB_UPDATED_PRODUCT_NAME); + $this->productRepository->save($product); + $productNameStoreId = $this->productRepository->get($sku, false, $storeId)->getName(); + $productNameCheckedStoreId = $this->productRepository->get($sku, false, $checkStoreId)->getName(); + + $this->assertEquals($expectedNameStore, $productNameStoreId); + $this->assertEquals($expectedNameCheckedStore, $productNameCheckedStoreId); + } + + /** + * Product update data provider + * + * @return array + */ + public function productUpdateDataProvider(): array + { + return [ + 'Updating for global store' => [ + self::STUB_STORE_ID_GLOBAL, + self::STUB_STORE_ID, + self::STUB_UPDATED_PRODUCT_NAME, + self::STUB_UPDATED_PRODUCT_NAME, + ], + 'Updating for store' => [ + self::STUB_STORE_ID, + self::STUB_STORE_ID_GLOBAL, + self::STUB_UPDATED_PRODUCT_NAME, + self::STUB_PRODUCT_NAME, + ], + ]; + } } diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Model/ProductTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Model/ProductTest.php index b0f36f250991b..8acb243a706c2 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/Model/ProductTest.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/Model/ProductTest.php @@ -246,7 +246,7 @@ protected function _copyFileToBaseTmpMediaPath($sourceFile) $mediaDirectory->create($config->getBaseTmpMediaPath()); $targetFile = $config->getTmpMediaPath(basename($sourceFile)); - copy($sourceFile, $mediaDirectory->getAbsolutePath($targetFile)); + $mediaDirectory->getDriver()->filePutContents($mediaDirectory->getAbsolutePath($targetFile), file_get_contents($sourceFile)); return $targetFile; } diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Model/ResourceModel/CategoryTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Model/ResourceModel/CategoryTest.php new file mode 100644 index 0000000000000..c57e981f772de --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Catalog/Model/ResourceModel/CategoryTest.php @@ -0,0 +1,130 @@ +objectManager = Bootstrap::getObjectManager(); + $this->categoryRepository = $this->objectManager->get(CategoryRepositoryInterface::class); + $this->categoryResource = $this->objectManager->get(CategoryResource::class); + $this->storeManager = $this->objectManager->get(StoreManagerInterface::class); + $this->categoryCollection = $this->objectManager->get(CategoryCollectionFactory::class)->create(); + $this->filesystem = $this->objectManager->get(Filesystem::class); + $this->mediaDirectory = $this->filesystem->getDirectoryWrite(DirectoryList::MEDIA); + } + + /** + * @inheritdoc + */ + protected function tearDown(): void + { + $this->mediaDirectory->delete(self::BASE_PATH); + + parent::tearDown(); + } + + /** + * @magentoDataFixture Magento/Catalog/_files/category.php + * @magentoDataFixture Magento/Catalog/_files/catalog_tmp_category_image.php + * @magentoDbIsolation disabled + * @return void + */ + public function testAddImageForCategory(): void + { + $dataImage = [ + 'name' => 'magento_small_image.jpg', + 'type' => 'image/jpg', + 'tmp_name' => '/tmp/phpDstnAx', + 'file' => 'magento_small_image.jpg', + 'url' => $this->prepareDataImageUrl('magento_small_image.jpg'), + ]; + $imageRelativePath = self::BASE_PATH . DIRECTORY_SEPARATOR . $dataImage['file']; + $expectedImage = DIRECTORY_SEPARATOR . $this->storeManager->getStore()->getBaseMediaDir() + . DIRECTORY_SEPARATOR . $imageRelativePath; + /** @var CategoryModel $category */ + $category = $this->categoryRepository->get(333); + $category->setImage([$dataImage]); + + $this->categoryResource->save($category); + + $categoryModel = $this->categoryCollection + ->addAttributeToSelect('image') + ->addIdFilter([$category->getId()]) + ->getFirstItem(); + $this->assertEquals( + $expectedImage, + $categoryModel->getImage(), + 'The path of the expected image does not match the path to the actual image.' + ); + $this->assertFileExists($this->mediaDirectory->getAbsolutePath($imageRelativePath)); + } + + /** + * Prepare image url for image data + * + * @param string $file + * @return string + */ + private function prepareDataImageUrl(string $file): string + { + return $this->storeManager->getStore()->getBaseUrl(UrlInterface::URL_TYPE_MEDIA) + . self::BASE_TMP_PATH . DIRECTORY_SEPARATOR . $file; + } +} diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/catalog_category_image.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/catalog_category_image.php index 3491065323c9f..7a2ad0fefac8a 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/_files/catalog_category_image.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/catalog_category_image.php @@ -10,13 +10,14 @@ $objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); /** @var $mediaDirectory \Magento\Framework\Filesystem\Directory\WriteInterface */ -$mediaDirectory = $objectManager->get(\Magento\Framework\Filesystem::class) - ->getDirectoryWrite(DirectoryList::MEDIA); +$mediaDirectory = $objectManager->get(\Magento\Framework\Filesystem::class)->getDirectoryWrite(DirectoryList::MEDIA); $fileName = 'magento_small_image.jpg'; $fileNameLong = 'magento_long_image_name_magento_long_image_name_magento_long_image_name.jpg'; $filePath = 'catalog/category/' . $fileName; $filePathLong = 'catalog/category/' . $fileNameLong; $mediaDirectory->create('catalog/category'); - -copy(__DIR__ . DIRECTORY_SEPARATOR . $fileName, $mediaDirectory->getAbsolutePath($filePath)); -copy(__DIR__ . DIRECTORY_SEPARATOR . $fileNameLong, $mediaDirectory->getAbsolutePath($filePathLong)); +$shortImageContent = file_get_contents(__DIR__ . DIRECTORY_SEPARATOR . $fileName); +$longImageContent = file_get_contents(__DIR__ . DIRECTORY_SEPARATOR . $fileNameLong); +$mediaDirectory->getDriver()->filePutContents($mediaDirectory->getAbsolutePath($filePath), $shortImageContent); +$mediaDirectory->getDriver()->filePutContents($mediaDirectory->getAbsolutePath($filePathLong), $longImageContent); +unset($shortImageContent, $longImageContent); diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/catalog_tmp_category_image.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/catalog_tmp_category_image.php index 2562acdda2dc3..ce688f38ed1ec 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/_files/catalog_tmp_category_image.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/catalog_tmp_category_image.php @@ -15,5 +15,4 @@ $fileName = 'magento_small_image.jpg'; $tmpFilePath = 'catalog/tmp/category/' . $fileName; $mediaDirectory->create('catalog/tmp/category'); - -copy(__DIR__ . DIRECTORY_SEPARATOR . $fileName, $mediaDirectory->getAbsolutePath($tmpFilePath)); +$mediaDirectory->getDriver()->filePutContents($mediaDirectory->getAbsolutePath($tmpFilePath), file_get_contents(__DIR__ . DIRECTORY_SEPARATOR . $fileName)); diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/categories.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/categories.php index 4255d7d3c98e5..9b743542b8573 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/_files/categories.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/categories.php @@ -3,29 +3,48 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ -$objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); -$defaultAttributeSet = $objectManager->get(Magento\Eav\Model\Config::class) - ->getEntityType('catalog_product') - ->getDefaultAttributeSetId(); +use Magento\Catalog\Api\CategoryLinkManagementInterface; +use Magento\Catalog\Api\CategoryLinkRepositoryInterface; +use Magento\Catalog\Api\Data\CategoryInterfaceFactory; +use Magento\Catalog\Api\Data\ProductInterfaceFactory; +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Catalog\Model\Product; +use Magento\Catalog\Model\Product\Attribute\Source\Status; +use Magento\Catalog\Model\Product\Type; +use Magento\Catalog\Model\Product\Visibility; +use Magento\Eav\Model\Config; +use Magento\Store\Api\WebsiteRepositoryInterface; +use Magento\Store\Model\StoreManagerInterface; +use Magento\TestFramework\Helper\Bootstrap; -$productRepository = $objectManager->create( - \Magento\Catalog\Api\ProductRepositoryInterface::class -); +$objectManager = Bootstrap::getObjectManager(); + +/** @var WebsiteRepositoryInterface $websiteRepository */ +$websiteRepository = $objectManager->get(WebsiteRepositoryInterface::class); +$baseWebsite = $websiteRepository->get('base'); +$rootCategoryId = $baseWebsite->getDefaultStore()->getRootCategoryId(); + +/** @var StoreManagerInterface $storeManager */ +$storeManager = $objectManager->get(StoreManagerInterface::class); + +$defaultAttributeSet = $objectManager->get(Config::class)->getEntityType(Product::ENTITY)->getDefaultAttributeSetId(); +$productRepository = $objectManager->get(ProductRepositoryInterface::class); +$categoryFactory = $objectManager->get(CategoryInterfaceFactory::class); $categoryLinkRepository = $objectManager->create( - \Magento\Catalog\Api\CategoryLinkRepositoryInterface::class, + CategoryLinkRepositoryInterface::class, [ - 'productRepository' => $productRepository + 'productRepository' => $productRepository, ] ); /** @var Magento\Catalog\Api\CategoryLinkManagementInterface $categoryLinkManagement */ -$categoryLinkManagement = $objectManager->create(\Magento\Catalog\Api\CategoryLinkManagementInterface::class); +$categoryLinkManagement = $objectManager->get(CategoryLinkManagementInterface::class); $reflectionClass = new \ReflectionClass(get_class($categoryLinkManagement)); $properties = [ 'productRepository' => $productRepository, - 'categoryLinkRepository' => $categoryLinkRepository + 'categoryLinkRepository' => $categoryLinkRepository, ]; foreach ($properties as $key => $value) { if ($reflectionClass->hasProperty($key)) { @@ -39,7 +58,7 @@ * After installation system has two categories: root one with ID:1 and Default category with ID:2 */ /** @var $category \Magento\Catalog\Model\Category */ -$category = $objectManager->create(\Magento\Catalog\Model\Category::class); +$category = $categoryFactory->create(); $category->isObjectNew(true); $category->setId(3) ->setName('Category 1') @@ -52,7 +71,7 @@ ->setPosition(1) ->save(); -$category = $objectManager->create(\Magento\Catalog\Model\Category::class); +$category = $categoryFactory->create(); $category->isObjectNew(true); $category->setId(4) ->setName('Category 1.1') @@ -67,7 +86,7 @@ ->setDescription('Category 1.1 description.') ->save(); -$category = $objectManager->create(\Magento\Catalog\Model\Category::class); +$category = $categoryFactory->create(); $category->isObjectNew(true); $category->setId(5) ->setName('Category 1.1.1') @@ -83,7 +102,7 @@ ->setDescription('This is the description for Category 1.1.1') ->save(); -$category = $objectManager->create(\Magento\Catalog\Model\Category::class); +$category = $categoryFactory->create(); $category->isObjectNew(true); $category->setId(6) ->setName('Category 2') @@ -96,7 +115,7 @@ ->setPosition(2) ->save(); -$category = $objectManager->create(\Magento\Catalog\Model\Category::class); +$category = $categoryFactory->create(); $category->isObjectNew(true); $category->setId(7) ->setName('Movable') @@ -109,7 +128,7 @@ ->setPosition(3) ->save(); -$category = $objectManager->create(\Magento\Catalog\Model\Category::class); +$category = $categoryFactory->create(); $category->isObjectNew(true); $category->setId(8) ->setName('Inactive') @@ -122,7 +141,7 @@ ->setPosition(4) ->save(); -$category = $objectManager->create(\Magento\Catalog\Model\Category::class); +$category = $categoryFactory->create(); $category->isObjectNew(true); $category->setId(9) ->setName('Movable Position 1') @@ -135,7 +154,7 @@ ->setPosition(5) ->save(); -$category = $objectManager->create(\Magento\Catalog\Model\Category::class); +$category = $categoryFactory->create(); $category->isObjectNew(true); $category->setId(10) ->setName('Movable Position 2') @@ -148,7 +167,7 @@ ->setPosition(6) ->save(); -$category = $objectManager->create(\Magento\Catalog\Model\Category::class); +$category = $categoryFactory->create(); $category->isObjectNew(true); $category->setId(11) ->setName('Movable Position 3') @@ -161,7 +180,7 @@ ->setPosition(7) ->save(); -$category = $objectManager->create(\Magento\Catalog\Model\Category::class); +$category = $categoryFactory->create(); $category->isObjectNew(true); $category->setId(12) ->setName('Category 12') @@ -174,7 +193,7 @@ ->setPosition(8) ->save(); -$category = $objectManager->create(\Magento\Catalog\Model\Category::class); +$category = $categoryFactory->create(); $category->isObjectNew(true); $category->setId(13) ->setName('Category 1.2') @@ -189,84 +208,86 @@ ->setPosition(2) ->save(); -/** @var $product \Magento\Catalog\Model\Product */ -$product = $objectManager->create(\Magento\Catalog\Model\Product::class); -$product->isObjectNew(true); -$product->setTypeId(\Magento\Catalog\Model\Product\Type::TYPE_SIMPLE) +/** @var ProductInterfaceFactory $productInterfaceFactory */ +$productInterfaceFactory = $objectManager->get(ProductInterfaceFactory::class); + +/** @var Product $product */ +$product = $productInterfaceFactory->create(); +$product->setTypeId(Type::TYPE_SIMPLE) ->setAttributeSetId($defaultAttributeSet) - ->setStoreId(1) - ->setWebsiteIds([1]) + ->setStoreId($storeManager->getDefaultStoreView()->getId()) + ->setWebsiteIds([$baseWebsite->getId()]) ->setName('Simple Product') ->setSku('simple') ->setPrice(10) ->setWeight(18) ->setStockData(['use_config_manage_stock' => 0]) - ->setVisibility(\Magento\Catalog\Model\Product\Visibility::VISIBILITY_BOTH) - ->setStatus(\Magento\Catalog\Model\Product\Attribute\Source\Status::STATUS_ENABLED) - ->save(); + ->setVisibility(Visibility::VISIBILITY_BOTH) + ->setStatus(Status::STATUS_ENABLED); + +$simple1 = $productRepository->save($product); $categoryLinkManagement->assignProductToCategories( - $product->getSku(), + $simple1->getSku(), [2, 3, 4, 13] ); -$product = $objectManager->create(\Magento\Catalog\Model\Product::class); -$product->isObjectNew(true); -$product->setTypeId(\Magento\Catalog\Model\Product\Type::TYPE_SIMPLE) +$product = $productInterfaceFactory->create(); +$product->setTypeId(Type::TYPE_SIMPLE) ->setAttributeSetId($defaultAttributeSet) - ->setStoreId(1) - ->setWebsiteIds([1]) + ->setStoreId($storeManager->getDefaultStoreView()->getId()) + ->setWebsiteIds([$baseWebsite->getId()]) ->setName('Simple Product Two') ->setSku('12345') // SKU intentionally contains digits only ->setPrice(45.67) ->setWeight(56) ->setStockData(['use_config_manage_stock' => 0]) - ->setVisibility(\Magento\Catalog\Model\Product\Visibility::VISIBILITY_BOTH) - ->setStatus(\Magento\Catalog\Model\Product\Attribute\Source\Status::STATUS_ENABLED) - ->save(); + ->setVisibility(Visibility::VISIBILITY_BOTH) + ->setStatus(Status::STATUS_ENABLED); + +$simple2 = $productRepository->save($product); $categoryLinkManagement->assignProductToCategories( - $product->getSku(), + $simple2->getSku(), [5, 4] ); -$product = $objectManager->create(\Magento\Catalog\Model\Product::class); -$product->isObjectNew(true); -$product->setTypeId(\Magento\Catalog\Model\Product\Type::TYPE_SIMPLE) +$product = $productInterfaceFactory->create(); +$product->setTypeId(Type::TYPE_SIMPLE) ->setAttributeSetId($defaultAttributeSet) - ->setStoreId(1) - ->setWebsiteIds([1]) + ->setStoreId($storeManager->getDefaultStoreView()->getId()) + ->setWebsiteIds([$baseWebsite->getId()]) ->setName('Simple Product Not Visible On Storefront') ->setSku('simple-3') ->setPrice(15) ->setWeight(2) ->setStockData(['use_config_manage_stock' => 0]) - ->setVisibility(\Magento\Catalog\Model\Product\Visibility::VISIBILITY_NOT_VISIBLE) - ->setStatus(\Magento\Catalog\Model\Product\Attribute\Source\Status::STATUS_ENABLED) - ->save(); + ->setVisibility(Visibility::VISIBILITY_NOT_VISIBLE) + ->setStatus(Status::STATUS_ENABLED); + +$simple3 = $productRepository->save($product); $categoryLinkManagement->assignProductToCategories( - $product->getSku(), + $simple3->getSku(), [10, 11, 12] ); -/** @var $product \Magento\Catalog\Model\Product */ -$product = $objectManager->create(\Magento\Catalog\Model\Product::class); -$product->isObjectNew(true); -$product->setTypeId(\Magento\Catalog\Model\Product\Type::TYPE_SIMPLE) +$product = $productInterfaceFactory->create(); +$product->setTypeId(Type::TYPE_SIMPLE) ->setAttributeSetId($defaultAttributeSet) - ->setStoreId(1) - ->setWebsiteIds([1]) + ->setStoreId($storeManager->getDefaultStoreView()->getId()) + ->setWebsiteIds([$baseWebsite->getId()]) ->setName('Simple Product Three') ->setSku('simple-4') ->setPrice(10) ->setWeight(18) ->setStockData(['use_config_manage_stock' => 0]) - ->setVisibility(\Magento\Catalog\Model\Product\Visibility::VISIBILITY_BOTH) - ->setStatus(\Magento\Catalog\Model\Product\Attribute\Source\Status::STATUS_ENABLED) - ->save(); + ->setVisibility(Visibility::VISIBILITY_BOTH) + ->setStatus(Status::STATUS_ENABLED); + +$simple4 = $productRepository->save($product); $categoryLinkManagement->assignProductToCategories( - $product->getSku(), + $simple4->getSku(), [10, 11, 12, 13] ); diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/category_on_second_store.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/category_on_second_store.php new file mode 100644 index 0000000000000..0b094ba29290e --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/category_on_second_store.php @@ -0,0 +1,29 @@ +requireDataFixture('Magento/Catalog/_files/category.php'); +Resolver::getInstance()->requireDataFixture('Magento/Store/_files/store.php'); + +$objectManager = Bootstrap::getObjectManager(); +$storeManager = $objectManager->get(StoreManagerInterface::class); +$categoryRepository = $objectManager->get(CategoryRepositoryInterface::class); +$executeInStoreContext = $objectManager->get(ExecuteInStoreContext::class); + +$currentStore = $storeManager->getStore(); +$secondStore = $storeManager->getStore('test'); +$category = $categoryRepository->get(333); +$category->setName('Category 1 Second'); +$category->setUrlKey('category-1-second-url-key'); +$executeInStoreContext->execute($secondStore, function ($categoryRepository, $category) { + $categoryRepository->save($category); +}, $categoryRepository, $category); diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/category_on_second_store_rollback.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/category_on_second_store_rollback.php new file mode 100644 index 0000000000000..b7b8491612fec --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/category_on_second_store_rollback.php @@ -0,0 +1,11 @@ +requireDataFixture('Magento/Catalog/_files/category_rollback.php'); +Resolver::getInstance()->requireDataFixture('Magento/Store/_files/store_rollback.php'); diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/category_with_cms_block.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/category_with_cms_block.php new file mode 100644 index 0000000000000..417b791eb376a --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/category_with_cms_block.php @@ -0,0 +1,49 @@ +requireDataFixture('Magento/Cms/_files/block.php'); + +$objectManager = Bootstrap::getObjectManager(); +/** @var StoreManagerInterface $storeManager */ +$storeManager = $objectManager->get(StoreManagerInterface::class); +/** @var CategoryInterfaceFactory $categoryFactory */ +$categoryFactory = $objectManager->get(CategoryInterfaceFactory::class); +/** @var CategoryRepositoryInterface $categoryRepository */ +$categoryRepository = $objectManager->get(CategoryRepositoryInterface::class); +/** @var DefaultCategory $categoryHelper */ +$categoryHelper = $objectManager->get(DefaultCategory::class); +$currentStoreId = (int)$storeManager->getStore()->getId(); +/** @var GetBlockByIdentifierInterface $getBlockByIdentifierInterface */ +$getBlockByIdentifier = $objectManager->get(GetBlockByIdentifierInterface::class); +$block = $getBlockByIdentifier->execute('fixture_block', $currentStoreId); + +$category = $categoryFactory->create(); +$category->setName('Category with cms block') + ->setParentId($categoryHelper->getId()) + ->setLevel(2) + ->setAvailableSortBy('name') + ->setDefaultSortBy('name') + ->setIsActive(true) + ->setPosition(1) + ->setDisplayMode(Category::DM_MIXED) + ->setLandingPage($block->getId()); +try { + $storeManager->setCurrentStore(Store::DEFAULT_STORE_ID); + $categoryRepository->save($category); +} finally { + $storeManager->setCurrentStore($currentStoreId); +} diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/category_with_cms_block_rollback.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/category_with_cms_block_rollback.php new file mode 100644 index 0000000000000..4725fde47818c --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/category_with_cms_block_rollback.php @@ -0,0 +1,32 @@ +get(Registry::class); +/** @var CategoryRepositoryInterface $categoryRepository */ +$categoryRepository = $objectManager->get(CategoryRepositoryInterface::class); +/** @var GetCategoryByName $getCategoryByName */ +$getCategoryByName = $objectManager->get(GetCategoryByName::class); +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', true); + +$category = $getCategoryByName->execute('Category with cms block'); +if ($category->getId()) { + $categoryRepository->delete($category); +} + +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', false); + +Resolver::getInstance()->requireDataFixture('Magento/Cms/_files/block_rollback.php'); diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/configurable_products_with_custom_attribute_layered_navigation.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/configurable_products_with_custom_attribute_layered_navigation.php index c2c3782c8cd23..6737aef1eb487 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/_files/configurable_products_with_custom_attribute_layered_navigation.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/configurable_products_with_custom_attribute_layered_navigation.php @@ -5,29 +5,26 @@ */ declare(strict_types=1); -use Magento\TestFramework\Workaround\Override\Fixture\Resolver; - -Resolver::getInstance()->requireDataFixture('Magento/ConfigurableProduct/_files/configurable_products.php'); - +use Magento\Catalog\Api\ProductAttributeRepositoryInterface; +use Magento\Catalog\Model\ResourceModel\Eav\Attribute; use Magento\TestFramework\Helper\Bootstrap; -use Magento\Eav\Api\AttributeRepositoryInterface; use Magento\TestFramework\Helper\CacheCleaner; +use Magento\TestFramework\Workaround\Override\Fixture\Resolver; -$eavConfig = Bootstrap::getObjectManager()->get(\Magento\Eav\Model\Config::class); +Resolver::getInstance()->requireDataFixture('Magento/ConfigurableProduct/_files/configurable_products.php'); -/** @var $attribute \Magento\Catalog\Model\ResourceModel\Eav\Attribute */ -$attribute = $eavConfig->getAttribute('catalog_product', 'test_configurable'); +$objectManager = Bootstrap::getObjectManager(); -$eavConfig->clear(); +/** @var ProductAttributeRepositoryInterface $attributeRepository */ +$attributeRepository = $objectManager->get(ProductAttributeRepositoryInterface::class); +/** @var $attribute Attribute */ +$attribute = $attributeRepository->get('test_configurable'); $attribute->setIsSearchable(1) - ->setIsVisibleInAdvancedSearch(1) - ->setIsFilterable(true) - ->setIsFilterableInSearch(true) + ->setIsVisibleInAdvancedSearch(1) + ->setIsFilterable(true) + ->setIsFilterableInSearch(true) ->setIsVisibleOnFront(1); -/** @var AttributeRepositoryInterface $attributeRepository */ -$attributeRepository = Bootstrap::getObjectManager()->create(AttributeRepositoryInterface::class); $attributeRepository->save($attribute); - CacheCleaner::cleanAll(); diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/magento_image with space in name.jpg b/dev/tests/integration/testsuite/Magento/Catalog/_files/magento_image with space in name.jpg new file mode 100644 index 0000000000000..bed66dfbcb1c3 Binary files /dev/null and b/dev/tests/integration/testsuite/Magento/Catalog/_files/magento_image with space in name.jpg differ diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/out_of_stock_product_with_category.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/out_of_stock_product_with_category.php index dfaee4e8efdba..c38b77d886bfc 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/_files/out_of_stock_product_with_category.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/out_of_stock_product_with_category.php @@ -50,5 +50,5 @@ ->setCanSaveCustomOptions(true) ->setHasOptions(true); /** @var ProductRepositoryInterface $productRepositoryFactory */ -$productRepository = $objectManager->create(ProductRepositoryInterface::class); +$productRepository = $objectManager->get(ProductRepositoryInterface::class); $productRepository->save($product); diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/out_of_stock_product_with_category_rollback.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/out_of_stock_product_with_category_rollback.php index ee25a9c29ded1..e4669597479b6 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/_files/out_of_stock_product_with_category_rollback.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/out_of_stock_product_with_category_rollback.php @@ -20,7 +20,7 @@ $registry->unregister('isSecureArea'); $registry->register('isSecureArea', true); /** @var ProductRepositoryInterface $productRepository */ -$productRepository = $objectManager->create(ProductRepositoryInterface::class); +$productRepository = $objectManager->get(ProductRepositoryInterface::class); try { $productRepository->deleteById('out-of-stock-product'); diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/product_boolean_attribute.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/product_boolean_attribute.php index 57b918fb5e663..6f81d6b659996 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/_files/product_boolean_attribute.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/product_boolean_attribute.php @@ -5,51 +5,56 @@ */ declare(strict_types=1); +use Magento\Catalog\Api\Data\ProductAttributeInterface; +use Magento\Catalog\Api\Data\ProductAttributeInterfaceFactory; +use Magento\Catalog\Api\Data\ProductInterfaceFactory; +use Magento\Catalog\Api\ProductAttributeRepositoryInterface; +use Magento\Catalog\Model\Product; use Magento\Catalog\Setup\CategorySetup; -use Magento\Eav\Api\AttributeRepositoryInterface; -use Magento\Catalog\Model\ResourceModel\Eav\Attribute; use Magento\Eav\Model\Entity\Attribute\Source\Boolean; -use Magento\Framework\Exception\NoSuchEntityException; use Magento\TestFramework\Helper\Bootstrap; $objectManager = Bootstrap::getObjectManager(); -/** @var AttributeRepositoryInterface $attributeRepository */ -$attributeRepository = $objectManager->get(AttributeRepositoryInterface::class); -/** @var Attribute $attribute */ -$attribute = $objectManager->create(Attribute::class); -/** @var $installer CategorySetup */ -$installer = $objectManager->create(CategorySetup::class); -try { - $attributeRepository->get(CategorySetup::CATALOG_PRODUCT_ENTITY_TYPE_ID, 'boolean_attribute'); -} catch (NoSuchEntityException $e) { - $attribute->setData( - [ - 'attribute_code' => 'boolean_attribute', - 'entity_type_id' => CategorySetup::CATALOG_PRODUCT_ENTITY_TYPE_ID, - 'is_global' => 0, - 'is_user_defined' => 1, - 'frontend_input' => 'boolean', - 'is_unique' => 0, - 'is_required' => 0, - 'is_searchable' => 1, - 'is_visible_in_advanced_search' => 1, - 'is_comparable' => 0, - 'is_filterable' => 1, - 'is_filterable_in_search' => 1, - 'is_used_for_promo_rules' => 0, - 'is_html_allowed_on_front' => 1, - 'is_visible_on_front' => 1, - 'used_in_product_listing' => 1, - 'used_for_sort_by' => 0, - 'frontend_label' => ['Boolean Attribute'], - 'backend_type' => 'int', - 'source_model' => Boolean::class - ] - ); +/** @var ProductAttributeRepositoryInterface $attributeRepository */ +$attributeRepository = $objectManager->get(ProductAttributeRepositoryInterface::class); +/** @var ProductAttributeInterfaceFactory $attributeFactory */ +$attributeFactory = $objectManager->get(ProductAttributeInterfaceFactory::class); + +/** @var ProductInterfaceFactory $productInterfaceFactory */ +$productInterfaceFactory = $objectManager->get(ProductInterfaceFactory::class); + +/** @var $installer CategorySetup */ +$installer = $objectManager->get(CategorySetup::class); +$attributeSetId = $installer->getAttributeSetId(Product::ENTITY, 'Default'); +$groupId = $installer->getDefaultAttributeGroupId(Product::ENTITY, $attributeSetId); - $attributeRepository->save($attribute); +/** @var ProductAttributeInterface $attributeModel */ +$attributeModel = $attributeFactory->create(); +$attributeModel->setData( + [ + 'attribute_code' => 'boolean_attribute', + 'entity_type_id' => CategorySetup::CATALOG_PRODUCT_ENTITY_TYPE_ID, + 'is_global' => 0, + 'is_user_defined' => 1, + 'frontend_input' => 'boolean', + 'is_unique' => 0, + 'is_required' => 0, + 'is_searchable' => 1, + 'is_visible_in_advanced_search' => 1, + 'is_comparable' => 0, + 'is_filterable' => 1, + 'is_filterable_in_search' => 1, + 'is_used_for_promo_rules' => 0, + 'is_html_allowed_on_front' => 1, + 'is_visible_on_front' => 1, + 'used_in_product_listing' => 1, + 'used_for_sort_by' => 0, + 'frontend_label' => ['Boolean Attribute'], + 'backend_type' => 'int', + 'source_model' => Boolean::class + ] +); +$attribute = $attributeRepository->save($attributeModel); - /* Assign attribute to attribute set */ - $installer->addAttributeToGroup('catalog_product', 'Default', 'Attributes', $attribute->getId()); -} +$installer->addAttributeToGroup(Product::ENTITY, $attributeSetId, $groupId, $attribute->getId()); diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/product_image.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/product_image.php index 962a66f11f532..1794530832d04 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/_files/product_image.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/product_image.php @@ -24,12 +24,11 @@ $images = ['magento_image.jpg', 'magento_small_image.jpg', 'magento_thumbnail.jpg']; foreach ($images as $image) { - $targetTmpFilePath = $mediaDirectory->getAbsolutePath() . DIRECTORY_SEPARATOR . $targetTmpDirPath - . DIRECTORY_SEPARATOR . $image; + $targetTmpFilePath = $mediaDirectory->getAbsolutePath() . $targetTmpDirPath . $image; $sourceFilePath = __DIR__ . DIRECTORY_SEPARATOR . $image; + $mediaDirectory->getDriver()->filePutContents($targetTmpFilePath, file_get_contents($sourceFilePath)); - copy($sourceFilePath, $targetTmpFilePath); // Copying the image to target dir is not necessary because during product save, it will be moved there from tmp dir $database->saveFile($targetTmpFilePath); } diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/product_simple_with_image.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/product_simple_with_image.php index 252f99c97b787..688a3bd199570 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/_files/product_simple_with_image.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/product_simple_with_image.php @@ -38,7 +38,7 @@ $mediaDirectory->create($targetTmpDirPath); $dist = $mediaDirectory->getAbsolutePath($mediaConfig->getBaseMediaPath() . DIRECTORY_SEPARATOR . 'magento_image.jpg'); -copy(__DIR__ . '/magento_image.jpg', $dist); +$mediaDirectory->getDriver()->filePutContents($dist, file_get_contents(__DIR__ . '/magento_image.jpg')); /** @var \Magento\Catalog\Api\ProductRepositoryInterface $productRepository */ $productRepository = $objectManager->create(\Magento\Catalog\Api\ProductRepositoryInterface::class); diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/products_new.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/products_new.php index 2cd0dd2c77560..e08ded4da7b5d 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/_files/products_new.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/products_new.php @@ -4,19 +4,36 @@ * See COPYING.txt for license details. */ -/** @var $product \Magento\Catalog\Model\Product */ -$product = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create(\Magento\Catalog\Model\Product::class); -$product->setTypeId(\Magento\Catalog\Model\Product\Type::TYPE_SIMPLE) - ->setAttributeSetId(4) +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Catalog\Model\Product; +use Magento\Catalog\Model\Product\Attribute\Source\Status; +use Magento\Catalog\Model\Product\Type; +use Magento\Catalog\Model\Product\Visibility; +use Magento\Catalog\Model\ProductFactory; +use Magento\Framework\ObjectManagerInterface; +use Magento\TestFramework\Helper\Bootstrap; + +/** @var ObjectManagerInterface $objectManager */ +$objectManager = Bootstrap::getObjectManager(); +/** @var ProductFactory $productFactory */ +$productFactory = $objectManager->get(ProductFactory::class); +/** @var ProductRepositoryInterface $productRepository */ +$productRepository = $objectManager->get(ProductRepositoryInterface::class); + +/** @var Product $product */ +$product = $productFactory->create(); +$product->setTypeId(Type::TYPE_SIMPLE) + ->setAttributeSetId($product->getDefaultAttributeSetId()) ->setName('New Product') ->setSku('simple') ->setPrice(10) - ->setVisibility(\Magento\Catalog\Model\Product\Visibility::VISIBILITY_BOTH) - ->setStatus(\Magento\Catalog\Model\Product\Attribute\Source\Status::STATUS_ENABLED) + ->setVisibility(Visibility::VISIBILITY_BOTH) + ->setStatus(Status::STATUS_ENABLED) ->setWebsiteIds([1]) ->setStockData(['qty' => 100, 'is_in_stock' => 1, 'manage_stock' => 1]) ->setNewsFromDate(date('Y-m-d H:i:s', strtotime('-2 day'))) ->setNewsToDate(date('Y-m-d H:i:s', strtotime('+2 day'))) ->setDescription('description') - ->setShortDescription('short desc') - ->save(); + ->setShortDescription('short desc'); + +$productRepository->save($product); diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/products_new_rollback.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/products_new_rollback.php index 08451b6fccaa3..4b39f975373f3 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/_files/products_new_rollback.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/products_new_rollback.php @@ -4,21 +4,26 @@ * See COPYING.txt for license details. */ -$objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Framework\Exception\NoSuchEntityException; +use Magento\Framework\ObjectManagerInterface; +use Magento\Framework\Registry; +use Magento\TestFramework\Helper\Bootstrap; -/** @var \Magento\Framework\Registry $registry */ -$registry = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->get(\Magento\Framework\Registry::class); +/** @var ObjectManagerInterface $objectManager */ +$objectManager = Bootstrap::getObjectManager(); +/** @var Registry $registry */ +$registry = $objectManager->get(Registry::class); $registry->unregister('isSecureArea'); $registry->register('isSecureArea', true); -/** @var \Magento\Catalog\Api\ProductRepositoryInterface $productRepository */ -$productRepository = $objectManager->create(\Magento\Catalog\Api\ProductRepositoryInterface::class); +/** @var ProductRepositoryInterface $productRepository */ +$productRepository = $objectManager->get(ProductRepositoryInterface::class); try { - $firstProduct = $productRepository->get('simple', false, null, true); - $productRepository->delete($firstProduct); -} catch (\Magento\Framework\Exception\NoSuchEntityException $exception) { + $productRepository->deleteById('simple'); +} catch (NoSuchEntityException $exception) { //Product already removed } diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/products_with_layered_navigation_attribute.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/products_with_layered_navigation_attribute.php index 29812aa942ab5..3bc3fef56e32e 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/_files/products_with_layered_navigation_attribute.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/products_with_layered_navigation_attribute.php @@ -5,125 +5,135 @@ */ declare(strict_types=1); +use Magento\Catalog\Api\Data\CategoryInterfaceFactory; +use Magento\Catalog\Api\Data\ProductAttributeInterfaceFactory; +use Magento\Catalog\Api\Data\ProductInterfaceFactory; +use Magento\Catalog\Api\ProductAttributeRepositoryInterface; +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Catalog\Model\Product; +use Magento\Catalog\Model\Product\Attribute\Source\Status; +use Magento\Catalog\Model\Product\Type; +use Magento\Catalog\Model\Product\Visibility; +use Magento\Eav\Model\Config; +use Magento\Eav\Setup\EavSetup; +use Magento\Indexer\Model\Indexer; +use Magento\Indexer\Model\Indexer\Collection; +use Magento\Msrp\Model\Product\Attribute\Source\Type as SourceType; +use Magento\Store\Api\WebsiteRepositoryInterface; use Magento\TestFramework\Helper\Bootstrap; -use Magento\Eav\Api\AttributeRepositoryInterface; use Magento\TestFramework\Helper\CacheCleaner; -$eavConfig = Bootstrap::getObjectManager()->get(\Magento\Eav\Model\Config::class); -$attribute = $eavConfig->getAttribute('catalog_product', 'test_configurable'); - +$objectManager = Bootstrap::getObjectManager(); + +/** @var Config $eavConfig */ +$eavConfig = $objectManager->get(Config::class); + +/** @var ProductAttributeRepositoryInterface $attributeRepository */ +$attributeRepository = $objectManager->get(ProductAttributeRepositoryInterface::class); +/** @var ProductAttributeInterfaceFactory $attributeFactory */ +$attributeFactory = $objectManager->get(ProductAttributeInterfaceFactory::class); + +/** @var $installer EavSetup */ +$installer = $objectManager->get(EavSetup::class); +$attributeSetId = $installer->getAttributeSetId(Product::ENTITY, 'Default'); +$groupId = $installer->getDefaultAttributeGroupId(Product::ENTITY, $attributeSetId); + +/** @var WebsiteRepositoryInterface $websiteRepository */ +$websiteRepository = $objectManager->get(WebsiteRepositoryInterface::class); +$baseWebsite = $websiteRepository->get('base'); + +$attributeModel = $attributeFactory->create(); +$attributeModel->setData( + [ + 'attribute_code' => 'test_configurable', + 'entity_type_id' => $installer->getEntityTypeId(Product::ENTITY), + 'is_global' => 1, + 'is_user_defined' => 1, + 'frontend_input' => 'select', + 'is_unique' => 0, + 'is_required' => 0, + 'is_searchable' => 1, + 'is_visible_in_advanced_search' => 1, + 'is_comparable' => 1, + 'is_filterable' => 1, + 'is_filterable_in_search' => 1, + 'is_used_for_promo_rules' => 0, + 'is_html_allowed_on_front' => 1, + 'is_visible_on_front' => 1, + 'used_in_product_listing' => 1, + 'used_for_sort_by' => 1, + 'frontend_label' => ['Test Configurable'], + 'backend_type' => 'int', + 'option' => [ + 'value' => ['option_0' => ['Option 1'], 'option_1' => ['Option 2']], + 'order' => ['option_0' => 1, 'option_1' => 2], + ], + 'default' => ['option_0'] + ] +); +$attribute = $attributeRepository->save($attributeModel); + +$installer->addAttributeToGroup(Product::ENTITY, $attributeSetId, $groupId, $attribute->getId()); +CacheCleaner::cleanAll(); $eavConfig->clear(); -/** @var $installer \Magento\Catalog\Setup\CategorySetup */ -$installer = Bootstrap::getObjectManager()->create(\Magento\Catalog\Setup\CategorySetup::class); - -if (!$attribute->getId()) { - - /** @var $attribute \Magento\Catalog\Model\ResourceModel\Eav\Attribute */ - $attribute = Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\ResourceModel\Eav\Attribute::class - ); - - /** @var AttributeRepositoryInterface $attributeRepository */ - $attributeRepository = Bootstrap::getObjectManager()->create(AttributeRepositoryInterface::class); - - $attribute->setData( - [ - 'attribute_code' => 'test_configurable', - 'entity_type_id' => $installer->getEntityTypeId('catalog_product'), - 'is_global' => 1, - 'is_user_defined' => 1, - 'frontend_input' => 'select', - 'is_unique' => 0, - 'is_required' => 0, - 'is_searchable' => 1, - 'is_visible_in_advanced_search' => 1, - 'is_comparable' => 1, - 'is_filterable' => 1, - 'is_filterable_in_search' => 1, - 'is_used_for_promo_rules' => 0, - 'is_html_allowed_on_front' => 1, - 'is_visible_on_front' => 1, - 'used_in_product_listing' => 1, - 'used_for_sort_by' => 1, - 'frontend_label' => ['Test Configurable'], - 'backend_type' => 'int', - 'option' => [ - 'value' => ['option_0' => ['Option 1'], 'option_1' => ['Option 2']], - 'order' => ['option_0' => 1, 'option_1' => 2], - ], - 'default' => ['option_0'] - ] - ); - - $attributeRepository->save($attribute); +/** @var ProductRepositoryInterface $productRepository */ +$productRepository = $objectManager->get(ProductRepositoryInterface::class); +/** @var ProductInterfaceFactory $productInterfaceFactory */ +$productInterfaceFactory = $objectManager->get(ProductInterfaceFactory::class); - /* Assign attribute to attribute set */ - $installer->addAttributeToGroup('catalog_product', 'Default', 'General', $attribute->getId()); - CacheCleaner::cleanAll(); -} - -$eavConfig->clear(); - -/** @var \Magento\Framework\ObjectManagerInterface $objectManager */ -$objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); - -/** @var $product \Magento\Catalog\Model\Product */ -$product = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create(\Magento\Catalog\Model\Product::class); -$product->isObjectNew(true); -$product->setTypeId(\Magento\Catalog\Model\Product\Type::TYPE_SIMPLE) - ->setId(10) - ->setAttributeSetId(4) +/** @var Product $product */ +$product = $productInterfaceFactory->create(); +$product->setTypeId(Type::TYPE_SIMPLE) + ->setAttributeSetId($product->getDefaultAttributeSetId()) ->setName('Simple Product1') ->setSku('simple1') ->setTaxClassId('none') ->setDescription('description') ->setShortDescription('short description') ->setOptionsContainer('container1') - ->setMsrpDisplayActualPriceType(\Magento\Msrp\Model\Product\Attribute\Source\Type::TYPE_IN_CART) + ->setMsrpDisplayActualPriceType(SourceType::TYPE_IN_CART) ->setPrice(10) ->setWeight(1) ->setMetaTitle('meta title') ->setMetaKeyword('meta keyword') ->setMetaDescription('meta description') - ->setVisibility(\Magento\Catalog\Model\Product\Visibility::VISIBILITY_BOTH) - ->setStatus(\Magento\Catalog\Model\Product\Attribute\Source\Status::STATUS_ENABLED) - ->setWebsiteIds([1]) + ->setVisibility(Visibility::VISIBILITY_BOTH) + ->setStatus(Status::STATUS_ENABLED) + ->setWebsiteIds([$baseWebsite->getId()]) ->setCategoryIds([]) ->setStockData(['use_config_manage_stock' => 1, 'qty' => 100, 'is_qty_decimal' => 0, 'is_in_stock' => 1]) - ->setSpecialPrice('5.99') - ->save(); + ->setSpecialPrice('5.99'); +$simple1 = $productRepository->save($product); -$product = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create(\Magento\Catalog\Model\Product::class); -$product->isObjectNew(true); -$product->setTypeId(\Magento\Catalog\Model\Product\Type::TYPE_SIMPLE) - ->setId(11) - ->setAttributeSetId(4) +/** @var Product $product */ +$product = $productInterfaceFactory->create(); +$product->setTypeId(Type::TYPE_SIMPLE) + ->setAttributeSetId($product->getDefaultAttributeSetId()) ->setName('Simple Product2') ->setSku('simple2') ->setTaxClassId('none') ->setDescription('description') ->setShortDescription('short description') ->setOptionsContainer('container1') - ->setMsrpDisplayActualPriceType(\Magento\Msrp\Model\Product\Attribute\Source\Type::TYPE_ON_GESTURE) + ->setMsrpDisplayActualPriceType(SourceType::TYPE_ON_GESTURE) ->setPrice(20) ->setWeight(1) ->setMetaTitle('meta title') ->setMetaKeyword('meta keyword') ->setMetaDescription('meta description') - ->setVisibility(\Magento\Catalog\Model\Product\Visibility::VISIBILITY_BOTH) - ->setStatus(\Magento\Catalog\Model\Product\Attribute\Source\Status::STATUS_ENABLED) - ->setWebsiteIds([1]) + ->setVisibility(Visibility::VISIBILITY_BOTH) + ->setStatus(Status::STATUS_ENABLED) + ->setWebsiteIds([$baseWebsite->getId()]) ->setCategoryIds([]) ->setStockData(['use_config_manage_stock' => 1, 'qty' => 50, 'is_qty_decimal' => 0, 'is_in_stock' => 1]) - ->setSpecialPrice('15.99') - ->save(); + ->setSpecialPrice('15.99'); +$simple2 = $productRepository->save($product); -$product = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create(\Magento\Catalog\Model\Product::class); -$product->isObjectNew(true); -$product->setTypeId(\Magento\Catalog\Model\Product\Type::TYPE_SIMPLE) - ->setId(12) - ->setAttributeSetId(4) +/** @var Product $product */ +$product = $productInterfaceFactory->create(); +$product->setTypeId(Type::TYPE_SIMPLE) + ->setAttributeSetId($product->getDefaultAttributeSetId()) ->setName('Simple Product3') ->setSku('simple3') ->setTaxClassId('none') @@ -131,44 +141,42 @@ ->setShortDescription('short description') ->setPrice(30) ->setWeight(1) - ->setVisibility(\Magento\Catalog\Model\Product\Visibility::VISIBILITY_BOTH) - ->setStatus(\Magento\Catalog\Model\Product\Attribute\Source\Status::STATUS_DISABLED) - ->setWebsiteIds([1]) + ->setVisibility(Visibility::VISIBILITY_BOTH) + ->setStatus(Status::STATUS_DISABLED) + ->setWebsiteIds([$baseWebsite->getId()]) ->setCategoryIds([]) ->setStockData(['use_config_manage_stock' => 1, 'qty' => 140, 'is_qty_decimal' => 0, 'is_in_stock' => 1]) - ->setSpecialPrice('25.99') - ->save(); + ->setSpecialPrice('25.99'); +$simple3 = $productRepository->save($product); -$category = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create(\Magento\Catalog\Model\Category::class); +/** @var CategoryInterfaceFactory $categoryInterfaceFactory */ +$categoryInterfaceFactory = $objectManager->get(CategoryInterfaceFactory::class); + +$category = $categoryInterfaceFactory->create(); $category->isObjectNew(true); -$category->setId( - 333 -)->setCreatedAt( - '2014-06-23 09:50:07' -)->setName( - 'Category 1' -)->setParentId( - 2 -)->setPath( - '1/2/333' -)->setLevel( - 2 -)->setAvailableSortBy( - ['position', 'name'] -)->setDefaultSortBy( - 'name' -)->setIsActive( - true -)->setPosition( - 1 -)->setPostedProducts( - [10 => 10, 11 => 11, 12 => 12] -)->save(); +$category->setId(333) + ->setCreatedAt('2014-06-23 09:50:07') + ->setName('Category 1') + ->setParentId(2) + ->setPath('1/2/333') + ->setLevel(2) + ->setAvailableSortBy(['position', 'name']) + ->setDefaultSortBy('name') + ->setIsActive(true) + ->setPosition(1) + ->setPostedProducts( + [ + $simple1->getId() => 10, + $simple2->getId() => 11, + $simple3->getId() => 12 + ] + ); +$category->save(); -/** @var \Magento\Indexer\Model\Indexer\Collection $indexerCollection */ -$indexerCollection = Bootstrap::getObjectManager()->get(\Magento\Indexer\Model\Indexer\Collection::class); +/** @var Collection $indexerCollection */ +$indexerCollection = $objectManager->get(Collection::class); $indexerCollection->load(); -/** @var \Magento\Indexer\Model\Indexer $indexer */ +/** @var Indexer $indexer */ foreach ($indexerCollection->getItems() as $indexer) { $indexer->reindexAll(); } diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/products_with_layered_navigation_attribute_rollback.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/products_with_layered_navigation_attribute_rollback.php index dd89f8974a647..47e6a4e71cb69 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/_files/products_with_layered_navigation_attribute_rollback.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/products_with_layered_navigation_attribute_rollback.php @@ -5,47 +5,57 @@ */ declare(strict_types=1); -$objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); -/** @var \Magento\Framework\Registry $registry */ -$registry = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->get(\Magento\Framework\Registry::class); - +use Magento\Catalog\Api\CategoryRepositoryInterface; +use Magento\Catalog\Api\ProductAttributeRepositoryInterface; +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Eav\Model\Config; +use Magento\Framework\Exception\NoSuchEntityException; +use Magento\Framework\Registry; +use Magento\TestFramework\Catalog\Model\GetCategoryByName; +use Magento\TestFramework\Helper\Bootstrap; + +$objectManager = Bootstrap::getObjectManager(); +/** @var Registry $registry */ +$registry = $objectManager->get(Registry::class); $registry->unregister('isSecureArea'); $registry->register('isSecureArea', true); -/** @var \Magento\Catalog\Api\ProductRepositoryInterface $productRepository */ -$productRepository = $objectManager->create(\Magento\Catalog\Api\ProductRepositoryInterface::class); +/** @var ProductRepositoryInterface $productRepository */ +$productRepository = $objectManager->get(ProductRepositoryInterface::class); foreach (['simple1', 'simple2', 'simple3'] as $sku) { try { $product = $productRepository->get($sku, false, null, true); $productRepository->delete($product); - } catch (\Magento\Framework\Exception\NoSuchEntityException $exception) { + } catch (NoSuchEntityException $exception) { //Product already removed } } -$productCollection = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() - ->get(\Magento\Catalog\Model\ResourceModel\Product\Collection::class); -foreach ($productCollection as $product) { - $product->delete(); +/** @var CategoryRepositoryInterface $categoryRepository */ +$categoryRepository = $objectManager->get(CategoryRepositoryInterface::class); +/** @var GetCategoryByName $getCategoryByName */ +$getCategoryByName = $objectManager->get(GetCategoryByName::class); +$category = $getCategoryByName->execute('Category 1'); +try { + if ($category->getId()) { + $categoryRepository->delete($category); + } +} catch (NoSuchEntityException $exception) { + //Category already removed } -/** @var $category \Magento\Catalog\Model\Category */ -$category = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create(\Magento\Catalog\Model\Category::class); -$category->load(333); -if ($category->getId()) { - $category->delete(); -} +$eavConfig = $objectManager->get(Config::class); +/** @var ProductAttributeRepositoryInterface $attributeRepository */ +$attributeRepository = $objectManager->get(ProductAttributeRepositoryInterface::class); -$eavConfig = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->get(\Magento\Eav\Model\Config::class); -$attribute = $eavConfig->getAttribute('catalog_product', 'test_configurable'); -if ($attribute instanceof \Magento\Eav\Model\Entity\Attribute\AbstractAttribute - && $attribute->getId() -) { - $attribute->delete(); +try { + $attribute = $attributeRepository->get('test_configurable'); + $attributeRepository->delete($attribute); +} catch (NoSuchEntityException $exception) { + //Attribute already removed } $eavConfig->clear(); - $registry->unregister('isSecureArea'); $registry->register('isSecureArea', false); diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/products_with_layered_navigation_attribute_store_options.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/products_with_layered_navigation_attribute_store_options.php index c2ebfa4389ab2..69172f3edb34f 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/_files/products_with_layered_navigation_attribute_store_options.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/products_with_layered_navigation_attribute_store_options.php @@ -50,7 +50,11 @@ 'is_visible_on_front' => 1, 'used_in_product_listing' => 1, 'used_for_sort_by' => 1, - 'frontend_label' => ['Test Configurable'], + 'frontend_label' => [ + Store::DEFAULT_STORE_ID => 'Test Configurable Admin Store', + Store::DISTRO_STORE_ID => 'Test Configurable Default Store', + $store->getId() => 'Test Configurable Test Store' + ], 'backend_type' => 'int', 'option' => [ 'value' => ['option_0' => [ diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/products_with_layered_navigation_attribute_store_options_rollback.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/products_with_layered_navigation_attribute_store_options_rollback.php index 6793051b5787b..60a8525dede24 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/_files/products_with_layered_navigation_attribute_store_options_rollback.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/products_with_layered_navigation_attribute_store_options_rollback.php @@ -24,12 +24,6 @@ } } -$productCollection = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() - ->get(\Magento\Catalog\Model\ResourceModel\Product\Collection::class); -foreach ($productCollection as $product) { - $product->delete(); -} - /** @var $category \Magento\Catalog\Model\Category */ $category = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create(\Magento\Catalog\Model\Category::class); $category->load(333); diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/products_with_layered_navigation_custom_attribute.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/products_with_layered_navigation_custom_attribute.php index 4dd088e148d75..76056f2fa9e0d 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/_files/products_with_layered_navigation_custom_attribute.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/products_with_layered_navigation_custom_attribute.php @@ -5,8 +5,15 @@ */ declare(strict_types=1); +use Magento\Catalog\Api\Data\ProductAttributeInterface; +use Magento\Catalog\Api\Data\ProductAttributeInterfaceFactory; +use Magento\Catalog\Api\ProductAttributeRepositoryInterface; +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Catalog\Model\Product; +use Magento\Eav\Model\Config; +use Magento\Eav\Setup\EavSetup; +use Magento\Framework\Exception\NoSuchEntityException; use Magento\TestFramework\Helper\Bootstrap; -use Magento\Eav\Api\AttributeRepositoryInterface; use Magento\TestFramework\Helper\CacheCleaner; use Magento\TestFramework\Workaround\Override\Fixture\Resolver; use Magento\TestFramework\Eav\Model\GetAttributeSetByName; @@ -15,136 +22,117 @@ Resolver::getInstance()->requireDataFixture('Magento/Catalog/_files/categories.php'); $objectManager = Bootstrap::getObjectManager(); -/** @var GetAttributeSetByName $getAttributeSetByName */ -$getAttributeSetByName = $objectManager->get(GetAttributeSetByName::class); -$attributeSet = $getAttributeSetByName->execute('second_attribute_set'); -$eavConfig = Bootstrap::getObjectManager()->get(\Magento\Eav\Model\Config::class); -$attribute = $eavConfig->getAttribute('catalog_product', 'test_configurable'); - -$eavConfig->clear(); - -$attribute1 = $eavConfig->getAttribute('catalog_product', ' second_test_configurable'); -$eavConfig->clear(); - -/** @var $installer \Magento\Catalog\Setup\CategorySetup */ -$installer = Bootstrap::getObjectManager()->create(\Magento\Catalog\Setup\CategorySetup::class); - -if (!$attribute->getId()) { - - /** @var $attribute \Magento\Catalog\Model\ResourceModel\Eav\Attribute */ - $attribute = Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\ResourceModel\Eav\Attribute::class - ); - - /** @var AttributeRepositoryInterface $attributeRepository */ - $attributeRepository = Bootstrap::getObjectManager()->create(AttributeRepositoryInterface::class); - - $attribute->setData( - [ - 'attribute_code' => 'test_configurable', - 'entity_type_id' => $installer->getEntityTypeId('catalog_product'), - 'is_global' => 1, - 'is_user_defined' => 1, - 'frontend_input' => 'select', - 'is_unique' => 0, - 'is_required' => 0, - 'is_searchable' => 1, - 'is_visible_in_advanced_search' => 1, - 'is_comparable' => 1, - 'is_filterable' => 1, - 'is_filterable_in_search' => 1, - 'is_used_for_promo_rules' => 0, - 'is_html_allowed_on_front' => 1, - 'is_visible_on_front' => 1, - 'used_in_product_listing' => 1, - 'used_for_sort_by' => 1, - 'frontend_label' => ['Test Configurable'], - 'backend_type' => 'int', - 'option' => [ - 'value' => ['option_0' => ['Option 1'], 'option_1' => ['Option 2']], - 'order' => ['option_0' => 1, 'option_1' => 2], - ], - 'default_value' => 'option_0' - ] - ); - $attributeRepository->save($attribute); - - /* Assign attribute to attribute set */ - $installer->addAttributeToGroup('catalog_product', 'Default', 'General', $attribute->getId()); -} -// create a second attribute -if (!$attribute1->getId()) { - - /** @var $attribute1 \Magento\Catalog\Model\ResourceModel\Eav\Attribute */ - $attribute1 = Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\ResourceModel\Eav\Attribute::class - ); - - /** @var AttributeRepositoryInterface $attributeRepository */ - $attributeRepository = Bootstrap::getObjectManager()->create(AttributeRepositoryInterface::class); - - $attribute1->setData( - [ - 'attribute_code' => 'second_test_configurable', - 'entity_type_id' => $installer->getEntityTypeId('catalog_product'), - 'is_global' => 1, - 'is_user_defined' => 1, - 'frontend_input' => 'select', - 'is_unique' => 0, - 'is_required' => 0, - 'is_searchable' => 1, - 'is_visible_in_advanced_search' => 1, - 'is_comparable' => 1, - 'is_filterable' => 1, - 'is_filterable_in_search' => 1, - 'is_used_for_promo_rules' => 0, - 'is_html_allowed_on_front' => 1, - 'is_visible_on_front' => 1, - 'used_in_product_listing' => 1, - 'used_for_sort_by' => 1, - 'frontend_label' => ['Second Test Configurable'], - 'backend_type' => 'int', - 'option' => [ - 'value' => ['option_0' => ['Option 3'], 'option_1' => ['Option 4']], - 'order' => ['option_0' => 1, 'option_1' => 2], - ], - 'default' => ['option_0'] - ] - ); - - $attributeRepository->save($attribute1); - - /* Assign attribute to attribute set */ - $installer->addAttributeToGroup( - 'catalog_product', - $attributeSet->getId(), - $attributeSet->getDefaultGroupId(), - $attribute1->getId() - ); -} +/** @var Config $eavConfig */ +$eavConfig = $objectManager->get(Config::class); +/** @var ProductAttributeRepositoryInterface $attributeRepository */ +$attributeRepository = $objectManager->get(ProductAttributeRepositoryInterface::class); +/** @var ProductAttributeInterfaceFactory $attributeFactory */ +$attributeFactory = $objectManager->get(ProductAttributeInterfaceFactory::class); +/** @var GetAttributeSetByName $getAttributeSetByName */ +$getAttributeSetByName = $objectManager->get(GetAttributeSetByName::class); +$secondAttributeSet = $getAttributeSetByName->execute('second_attribute_set'); + +/** @var $installer EavSetup */ +$installer = $objectManager->get(EavSetup::class); +$defaultAttributeSetId = $installer->getAttributeSetId(Product::ENTITY, 'Default'); +$defaultGroupId = $installer->getDefaultAttributeGroupId(Product::ENTITY, $defaultAttributeSetId); + +$attributeModel = $attributeFactory->create(); +$attributeModel->setData( + [ + 'attribute_code' => 'test_configurable', + 'entity_type_id' => $installer->getEntityTypeId(Product::ENTITY), + 'is_global' => 1, + 'is_user_defined' => 1, + 'frontend_input' => 'select', + 'is_unique' => 0, + 'is_required' => 0, + 'is_searchable' => 1, + 'is_visible_in_advanced_search' => 1, + 'is_comparable' => 1, + 'is_filterable' => 1, + 'is_filterable_in_search' => 1, + 'is_used_for_promo_rules' => 0, + 'is_html_allowed_on_front' => 1, + 'is_visible_on_front' => 1, + 'used_in_product_listing' => 1, + 'used_for_sort_by' => 1, + 'frontend_label' => ['Test Configurable'], + 'backend_type' => 'int', + 'option' => [ + 'value' => ['option_0' => ['Option 1'], 'option_1' => ['Option 2']], + 'order' => ['option_0' => 1, 'option_1' => 2], + ], + 'default' => ['option_0'] + ] +); +$attribute = $attributeRepository->save($attributeModel); +$installer->addAttributeToGroup( + Product::ENTITY, + $defaultAttributeSetId, + $defaultGroupId, + $attribute->getId() +); $eavConfig->clear(); -/** @var \Magento\Framework\ObjectManagerInterface $objectManager */ -$objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); +/** @var ProductAttributeInterface $attribute */ +$attributeModel2 = $attributeFactory->create(); +$attributeModel2->setData( + [ + 'attribute_code' => 'second_test_configurable', + 'entity_type_id' => $installer->getEntityTypeId(Product::ENTITY), + 'is_global' => 1, + 'is_user_defined' => 1, + 'frontend_input' => 'select', + 'is_unique' => 0, + 'is_required' => 0, + 'is_searchable' => 1, + 'is_visible_in_advanced_search' => 1, + 'is_comparable' => 1, + 'is_filterable' => 1, + 'is_filterable_in_search' => 1, + 'is_used_for_promo_rules' => 0, + 'is_html_allowed_on_front' => 1, + 'is_visible_on_front' => 1, + 'used_in_product_listing' => 1, + 'used_for_sort_by' => 1, + 'frontend_label' => ['Second Test Configurable'], + 'backend_type' => 'int', + 'option' => [ + 'value' => ['option_0' => ['Option 3'], 'option_1' => ['Option 4']], + 'order' => ['option_0' => 1, 'option_1' => 2], + ], + 'default' => ['option_0'], + ] +); +$attribute2 = $attributeRepository->save($attributeModel2); +$installer->addAttributeToGroup( + Product::ENTITY, + $secondAttributeSet->getId(), + $secondAttributeSet->getDefaultGroupId(), + $attribute2->getId() +); /** @var $productRepository \Magento\Catalog\Api\ProductRepositoryInterface */ -$productRepository = $objectManager->get(\Magento\Catalog\Api\ProductRepositoryInterface::class); +$productRepository = $objectManager->get(ProductRepositoryInterface::class); $productsWithNewAttributeSet = ['simple', '12345', 'simple-4']; foreach ($productsWithNewAttributeSet as $sku) { try { $product = $productRepository->get($sku, false, null, true); - $product->setAttributeSetId($attributeSet->getId()); + $product->setAttributeSetId($secondAttributeSet->getId()); $product->setStockData( - ['use_config_manage_stock' => 1, + [ + 'use_config_manage_stock' => 1, 'qty' => 50, 'is_qty_decimal' => 0, - 'is_in_stock' => 1] + 'is_in_stock' => 1, + ] ); $productRepository->save($product); - } catch (\Magento\Framework\Exception\NoSuchEntityException $e) { + } catch (NoSuchEntityException $e) { } } diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/validate_image_info.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/validate_image_info.php index 96ddb797a6dea..945f582b8cbdd 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/_files/validate_image_info.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/validate_image_info.php @@ -12,10 +12,10 @@ /** @var Magento\Catalog\Model\Product\Media\Config $config */ $config = $objectManager->get(\Magento\Catalog\Model\Product\Media\Config::class); -/** @var $tmpDirectory \Magento\Framework\Filesystem\Directory\WriteInterface */ -$tmpDirectory = $filesystem->getDirectoryWrite(\Magento\Framework\App\Filesystem\DirectoryList::MEDIA); -$tmpDirectory->create($config->getBaseTmpMediaPath()); +/** @var $mediaDirectory \Magento\Framework\Filesystem\Directory\WriteInterface */ +$mediaDirectory = $filesystem->getDirectoryWrite(\Magento\Framework\App\Filesystem\DirectoryList::MEDIA); +$mediaDirectory->create($config->getBaseTmpMediaPath()); -$targetTmpFilePath = $tmpDirectory->getAbsolutePath($config->getBaseTmpMediaPath() . '/magento_small_image.jpg'); -copy(__DIR__ . '/magento_small_image.jpg', $targetTmpFilePath); +$targetTmpFilePath = $mediaDirectory->getAbsolutePath($config->getBaseTmpMediaPath() . '/magento_small_image.jpg'); +$mediaDirectory->getDriver()->filePutContents($targetTmpFilePath, file_get_contents(__DIR__ . '/magento_small_image.jpg')); // Copying the image to target dir is not necessary because during product save, it will be moved there from tmp dir diff --git a/dev/tests/integration/testsuite/Magento/Catalog/controllers/_files/products.php b/dev/tests/integration/testsuite/Magento/Catalog/controllers/_files/products.php index 3878cd2e5176e..4a3c8f2e6b96c 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/controllers/_files/products.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/controllers/_files/products.php @@ -24,7 +24,7 @@ $baseTmpMediaPath = $config->getBaseTmpMediaPath(); $mediaDirectory->create($baseTmpMediaPath); -copy(__DIR__ . '/product_image.png', $mediaDirectory->getAbsolutePath($baseTmpMediaPath . '/product_image.png')); +$mediaDirectory->getDriver()->filePutContents($mediaDirectory->getAbsolutePath($baseTmpMediaPath . '/product_image.png'), file_get_contents(__DIR__ . '/product_image.png')); /** @var $productOne \Magento\Catalog\Model\Product */ $productOne = $objectManager->create(\Magento\Catalog\Model\Product::class); diff --git a/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/ImportWithSharedImagesTest.php b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/ImportWithSharedImagesTest.php new file mode 100644 index 0000000000000..35d4cceb50845 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/ImportWithSharedImagesTest.php @@ -0,0 +1,237 @@ +objectManager = Bootstrap::getObjectManager(); + $this->fileSystem = $this->objectManager->get(Filesystem::class); + $this->fileDriver = $this->objectManager->get(File::class); + $this->mediaConfig = $this->objectManager->get(ConfigInterface::class); + $this->productRepository = $this->objectManager->get(ProductRepositoryInterface::class); + $this->productRepository->cleanCache(); + $this->import = $this->objectManager->get(ProductFactory::class)->create(); + $this->csvFactory = $this->objectManager->get(CsvFactory::class); + $this->importDataResource = $this->objectManager->get(Data::class); + $this->appParams = Bootstrap::getInstance()->getBootstrap()->getApplication() + ->getInitParams()[AppBootstrap::INIT_PARAM_FILESYSTEM_DIR_PATHS]; + } + + /** + * @inheritdoc + */ + protected function tearDown(): void + { + $this->removeFiles(); + $this->removeProducts(); + $this->importDataResource->cleanBunches(); + + parent::tearDown(); + } + + /** + * @return void + */ + public function testImportProductsWithSameImages(): void + { + $this->moveImages('magento_image.jpg'); + $source = $this->prepareFile('catalog_import_products_with_same_images.csv'); + $this->updateUploader(); + $errors = $this->import->setParameters([ + 'behavior' => Import::BEHAVIOR_ADD_UPDATE, + 'entity' => ProductEntity::ENTITY, + ]) + ->setSource($source)->validateData(); + $this->assertEmpty($errors->getAllErrors()); + $this->import->importData(); + $this->createdProductsSkus = ['SimpleProductForTest1', 'SimpleProductForTest2']; + $this->checkProductsImages('/m/a/magento_image.jpg', $this->createdProductsSkus); + } + + /** + * Check product images + * + * @param string $expectedImagePath + * @param array $productSkus + * @return void + */ + private function checkProductsImages(string $expectedImagePath, array $productSkus): void + { + foreach ($productSkus as $productSku) { + $product = $this->productRepository->get($productSku); + $productImageItem = $product->getMediaGalleryImages()->getFirstItem(); + $productImageFile = $productImageItem->getFile(); + $productImagePath = $productImageItem->getPath(); + $this->filesToRemove[] = $productImagePath; + $this->assertEquals($expectedImagePath, $productImageFile); + $this->assertNotEmpty($productImagePath); + $this->assertTrue($this->fileDriver->isExists($productImagePath)); + } + } + + /** + * Remove created files + * + * @return void + */ + private function removeFiles(): void + { + foreach ($this->filesToRemove as $file) { + if ($this->fileDriver->isExists($file)) { + $this->fileDriver->deleteFile($file); + } + } + } + + /** + * Remove created products + * + * @return void + */ + private function removeProducts(): void + { + foreach ($this->createdProductsSkus as $sku) { + try { + $this->productRepository->deleteById($sku); + } catch (NoSuchEntityException $e) { + //already removed + } + } + } + + /** + * Prepare file + * + * @param string $fileName + * @return Csv + */ + private function prepareFile(string $fileName): Csv + { + $tmpDirectory = $this->fileSystem->getDirectoryWrite(DirectoryList::VAR_DIR); + $fixtureDir = realpath(__DIR__ . '/../../_files'); + $filePath = $tmpDirectory->getAbsolutePath($fileName); + $this->filesToRemove[] = $filePath; + $tmpDirectory->getDriver()->copy($fixtureDir . DIRECTORY_SEPARATOR . $fileName, $filePath); + $source = $this->csvFactory->create( + [ + 'file' => $fileName, + 'directory' => $tmpDirectory + ] + ); + + return $source; + } + + /** + * Update upload to use sandbox folders + * + * @return void + */ + private function updateUploader(): void + { + $uploader = $this->import->getUploader(); + $rootDirectory = $this->fileSystem->getDirectoryWrite(DirectoryList::ROOT); + $destDir = $rootDirectory->getRelativePath( + $this->appParams[DirectoryList::MEDIA][DirectoryList::PATH] + . DS . $this->mediaConfig->getBaseMediaPath() + ); + $tmpDir = $rootDirectory->getRelativePath( + $this->appParams[DirectoryList::MEDIA][DirectoryList::PATH] + ); + $rootDirectory->create($destDir); + $rootDirectory->create($tmpDir); + $uploader->setDestDir($destDir); + $uploader->setTmpDir($tmpDir); + } + + /** + * Move images to appropriate folder + * + * @param string $fileName + * @return void + */ + private function moveImages(string $fileName): void + { + $rootDirectory = $this->fileSystem->getDirectoryWrite(DirectoryList::ROOT); + $tmpDir = $rootDirectory->getRelativePath( + $this->appParams[DirectoryList::MEDIA][DirectoryList::PATH] + ); + $fixtureDir = realpath(__DIR__ . '/../../_files'); + $tmpFilePath = $rootDirectory->getAbsolutePath($tmpDir . DS . $fileName); + $this->fileDriver->createDirectory($tmpDir); + $rootDirectory->getDriver()->copy( + $fixtureDir . DIRECTORY_SEPARATOR . $fileName, + $tmpFilePath + ); + $this->filesToRemove[] = $tmpFilePath; + } +} diff --git a/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/ProductTest.php b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/ProductTest.php index a9699ea4a8050..01a6bfe7b39b6 100644 --- a/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/ProductTest.php +++ b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/ProductTest.php @@ -34,6 +34,7 @@ use Magento\Store\Model\Store; use Magento\Store\Model\StoreManagerInterface; use Magento\TestFramework\Helper\Bootstrap as BootstrapHelper; +use Magento\TestFramework\Indexer\TestCase; use Magento\UrlRewrite\Model\ResourceModel\UrlRewriteCollection; use Psr\Log\LoggerInterface; @@ -50,8 +51,10 @@ * @SuppressWarnings(PHPMD.ExcessivePublicCount) * phpcs:disable Generic.PHP.NoSilencedErrors, Generic.Metrics.NestingLevel, Magento2.Functions.StaticFunction */ -class ProductTest extends \Magento\TestFramework\Indexer\TestCase +class ProductTest extends TestCase { + private const LONG_FILE_NAME_IMAGE = 'magento_long_image_name_magento_long_image_name_magento_long_image_name.jpg'; + /** * @var \Magento\CatalogImportExport\Model\Import\Product */ @@ -729,7 +732,7 @@ function ($input) { ) ); // phpcs:ignore Magento2.Performance.ForeachArrayMerge - $option = array_merge(...$option); + $option = array_merge([], ...$option); if (!empty($option['type']) && !empty($option['name'])) { $lastOptionKey = $option['type'] . '|' . $option['name']; @@ -1029,13 +1032,12 @@ function (\Magento\Framework\DataObject $item) { ) ); - $this->importDataForMediaTest('import_media_additional_images.csv'); + $this->importDataForMediaTest('import_media_additional_long_name_image.csv'); $product->cleanModelCache(); $product = $this->getProductBySku('simple_new'); $items = array_values($product->getMediaGalleryImages()->getItems()); - $images[] = ['file' => '/m/a/magento_additional_image_three.jpg', 'label' => '']; - $images[] = ['file' => '/m/a/magento_additional_image_four.jpg', 'label' => '']; - $this->assertCount(7, $items); + $images[] = ['file' => '/m/a/' . self::LONG_FILE_NAME_IMAGE, 'label' => '']; + $this->assertCount(6, $items); $this->assertEquals( $images, array_map( @@ -1047,6 +1049,23 @@ function (\Magento\Framework\DataObject $item) { ); } + /** + * Test import twice and check that image will not be duplicate + * + * @magentoDataFixture mediaImportImageFixture + * @return void + */ + public function testSaveMediaImageDuplicateImages(): void + { + $this->importDataForMediaTest('import_media.csv'); + $imagesCount = count($this->getProductBySku('simple_new')->getMediaGalleryImages()->getItems()); + + // import the same file again + $this->importDataForMediaTest('import_media.csv'); + + $this->assertCount($imagesCount, $this->getProductBySku('simple_new')->getMediaGalleryImages()->getItems()); + } + /** * Test that errors occurred during importing images are logged. * @@ -1089,6 +1108,10 @@ public static function mediaImportImageFixture() 'source' => __DIR__ . '/../../../../Magento/Catalog/_files/magento_thumbnail.jpg', 'dest' => $dirPath . '/magento_thumbnail.jpg', ], + [ + 'source' => __DIR__ . '/../../../../Magento/Catalog/_files/' . self::LONG_FILE_NAME_IMAGE, + 'dest' => $dirPath . '/' . self::LONG_FILE_NAME_IMAGE, + ], [ 'source' => __DIR__ . '/_files/magento_additional_image_one.jpg', 'dest' => $dirPath . '/magento_additional_image_one.jpg', @@ -1646,6 +1669,12 @@ public function validateUrlKeysDataProvider() RowValidatorInterface::ERROR_DUPLICATE_URL_KEY => 0 ] ], + [ + 'products_to_check_valid_url_keys_with_different_language.csv', + [ + RowValidatorInterface::ERROR_DUPLICATE_URL_KEY => 0 + ] + ], [ 'products_to_check_duplicated_url_keys.csv', [ @@ -2252,7 +2281,7 @@ function (ProductInterface $item) { $collection = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() ->create(\Magento\Catalog\Model\ResourceModel\Category\Collection::class); $collection - ->addAttributeToFilter('entity_id', ['in' => \array_unique(\array_merge(...$categoryIds))]) + ->addAttributeToFilter('entity_id', ['in' => \array_unique(\array_merge([], ...$categoryIds))]) ->load() ->delete(); diff --git a/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_additional_long_name_image.csv b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_additional_long_name_image.csv new file mode 100644 index 0000000000000..2d2a192ed6c7c --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_additional_long_name_image.csv @@ -0,0 +1,2 @@ +sku,additional_images +simple_new,magento_long_image_name_magento_long_image_name_magento_long_image_name.jpg diff --git a/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/products_to_check_valid_url_keys_with_different_language.csv b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/products_to_check_valid_url_keys_with_different_language.csv new file mode 100644 index 0000000000000..0995eba0b3e90 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/products_to_check_valid_url_keys_with_different_language.csv @@ -0,0 +1,4 @@ +sku,product_type,store_view_code,name,price,attribute_set_code,url_key +24-MG04,simple,,"লক্ষ্য এনালগ ওয়াচ টি ২০",25,Default,লক্ষ্য এনালগ ওয়াচ টি ২০ +24-MG01,simple,,"ধৈর্যশীলতা ওয়াচ টি ২০",34,Default,ধৈর্যশীলতা ওয়াচ টি ২০ +24-MG03,simple,,"সামিট ওয়াচ টি ২০",58,Default,সামিট ওয়াচ টি ২০ diff --git a/dev/tests/integration/testsuite/Magento/CatalogImportExport/_files/catalog_import_products_with_same_images.csv b/dev/tests/integration/testsuite/Magento/CatalogImportExport/_files/catalog_import_products_with_same_images.csv new file mode 100644 index 0000000000000..7761ed7ac2360 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/CatalogImportExport/_files/catalog_import_products_with_same_images.csv @@ -0,0 +1,3 @@ +sku,store_view_code,attribute_set_code,product_type,categories,product_websites,name,description,short_description,weight,product_online,tax_class_name,visibility,price,special_price,special_price_from_date,special_price_to_date,url_key,meta_title,meta_keywords,meta_description,base_image,base_image_label,small_image,small_image_label,thumbnail_image,thumbnail_image_label,swatch_image,swatch_image_label,created_at,updated_at,new_from_date,new_to_date,display_product_options_in,map_price,msrp_price,map_enabled,gift_message_available,custom_design,custom_design_from,custom_design_to,custom_layout_update,page_layout,product_options_container,msrp_display_actual_price_type,country_of_manufacture,additional_attributes,qty,out_of_stock_qty,use_config_min_qty,is_qty_decimal,allow_backorders,use_config_backorders,min_cart_qty,use_config_min_sale_qty,max_cart_qty,use_config_max_sale_qty,is_in_stock,notify_on_stock_below,use_config_notify_stock_qty,manage_stock,use_config_manage_stock,use_config_qty_increments,qty_increments,use_config_enable_qty_inc,enable_qty_increments,is_decimal_divided,website_id,related_skus,related_position,crosssell_skus,crosssell_position,upsell_skus,upsell_position,additional_images,additional_image_labels,hide_from_product_page,custom_options,bundle_price_type,bundle_sku_type,bundle_price_view,bundle_weight_type,bundle_values,bundle_shipment_type,configurable_variations,configurable_variation_labels,associated_skus +SimpleProductForTest1,,Default,simple,Default,base,SimpleProductAfterImport1,,,1,1,Taxable Goods,"Catalog, Search",250,,,,simple-product-for-test-1,,,,magento_image.jpg,BASE magento_image.jpg,magento_image.jpg,SMALL blueshirt,magento_image.jpg,Thumb Image,,,"3/4/19, 5:53 AM","3/4/19, 4:47 PM",,,Block after Info Column,,,,,,,,,,,Use config,,,100,0,1,0,0,1,1,1,0,1,1,,1,0,1,1,0,1,0,0,0,,,,,,,,,,,,,,,,,,, +SimpleProductForTest2,,Default,simple,Default,base,SimpleProductAfterImport2,,,1,1,Taxable Goods,"Catalog, Search",300,,,,simple-product-for-test-2,,,,magento_image.jpg,BASE magento_image.jpg,magento_image.jpg,SMALL blueshirt,magento_image.jpg,Thumb Image,,,"3/4/19, 5:53 AM","3/4/19, 4:47 PM",,,Block after Info Column,,,,,,,,,,,Use config,,,100,0,1,0,0,1,1,1,0,1,1,,1,0,1,1,0,1,0,0,0,,,,,,,,,,,,,,,,,,, diff --git a/dev/tests/integration/testsuite/Magento/CatalogImportExport/_files/magento_image.jpg b/dev/tests/integration/testsuite/Magento/CatalogImportExport/_files/magento_image.jpg new file mode 100644 index 0000000000000..3b825a41b2101 Binary files /dev/null and b/dev/tests/integration/testsuite/Magento/CatalogImportExport/_files/magento_image.jpg differ diff --git a/dev/tests/integration/testsuite/Magento/CatalogSearch/Controller/Advanced/ResultTest.php b/dev/tests/integration/testsuite/Magento/CatalogSearch/Controller/Advanced/ResultTest.php index dcbaa4addd85e..33fac8e670f3d 100644 --- a/dev/tests/integration/testsuite/Magento/CatalogSearch/Controller/Advanced/ResultTest.php +++ b/dev/tests/integration/testsuite/Magento/CatalogSearch/Controller/Advanced/ResultTest.php @@ -67,6 +67,40 @@ public function testExecute(array $searchParams): void $this->assertStringContainsString('Simple product name', $responseBody); } + /** + * Advanced search test by difference product attributes. + * + * @magentoAppArea frontend + * @magentoDataFixture Magento/CatalogSearch/_files/product_for_search_with_hyphen_in_sku.php + * @magentoDataFixture Magento/CatalogSearch/_files/full_reindex.php + * + * @return void + */ + public function testExecuteSkuWithHyphen(): void + { + $this->getRequest()->setQuery( + $this->_objectManager->create( + Parameters::class, + [ + 'values' => [ + 'name' => '', + 'sku' => '24-mb01', + 'description' => '', + 'short_description' => '', + 'price' => [ + 'from' => '', + 'to' => '', + ], + 'test_searchable_attribute' => '', + ] + ] + ) + ); + $this->dispatch('catalogsearch/advanced/result'); + $responseBody = $this->getResponse()->getBody(); + $this->assertStringContainsString('Simple product name', $responseBody); + } + /** * Data provider with strings for quick search. * diff --git a/dev/tests/integration/testsuite/Magento/CatalogSearch/_files/product_for_search_with_hyphen_in_sku.php b/dev/tests/integration/testsuite/Magento/CatalogSearch/_files/product_for_search_with_hyphen_in_sku.php new file mode 100644 index 0000000000000..9139fe69738b3 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/CatalogSearch/_files/product_for_search_with_hyphen_in_sku.php @@ -0,0 +1,58 @@ +requireDataFixture('Magento/CatalogSearch/_files/searchable_attribute.php'); + +/** @var ObjectManager $objectManager */ +$objectManager = Bootstrap::getObjectManager(); + +/** @var ProductAttributeRepositoryInterface $productAttributeRepository */ +$productAttributeRepository = $objectManager->get(ProductAttributeRepositoryInterface::class); +$attribute = $productAttributeRepository->get('test_searchable_attribute'); + +/** @var ProductRepositoryInterface $productRepository */ +$productRepository = $objectManager->get(ProductRepositoryInterface::class); +/** @var ProductFactory $productFactory */ +$productFactory = $objectManager->get(ProductFactory::class); +$product = $productFactory->create(); +$product->isObjectNew(true); +$product->setTypeId(Type::TYPE_SIMPLE) + ->setAttributeSetId($product->getDefaultAttributeSetId()) + ->setWebsiteIds([1]) + ->setName('Simple product name') + ->setSku('24-mb01') + ->setPrice(100) + ->setWeight(1) + ->setShortDescription('Product short description') + ->setTaxClassId(0) + ->setDescription('Product description') + ->setMetaTitle('meta title') + ->setMetaKeyword('meta keyword') + ->setMetaDescription('meta description') + ->setVisibility(Visibility::VISIBILITY_BOTH) + ->setStatus(Status::STATUS_ENABLED) + ->setTestSearchableAttribute($attribute->getSource()->getOptionId('Option 1')) + ->setStockData( + [ + 'use_config_manage_stock' => 1, + 'qty' => 100, + 'is_qty_decimal' => 0, + 'is_in_stock' => 1, + ] + ); +$productRepository->save($product); diff --git a/dev/tests/integration/testsuite/Magento/CatalogSearch/_files/product_for_search_with_hyphen_in_sku_rollback.php b/dev/tests/integration/testsuite/Magento/CatalogSearch/_files/product_for_search_with_hyphen_in_sku_rollback.php new file mode 100644 index 0000000000000..1e41f393894e7 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/CatalogSearch/_files/product_for_search_with_hyphen_in_sku_rollback.php @@ -0,0 +1,39 @@ +create(EavSetupFactory::class); +/** @var Registry $registry */ +$registry = $objectManager->get(Registry::class); +/** @var ProductRepositoryInterface $productRepository */ +$productRepository = $objectManager->create(ProductRepositoryInterface::class); +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', true); + +try { + $productRepository->deleteById('24-mb01'); +} catch (NoSuchEntityException $e) { + //Product already deleted. +} +/** @var EavSetup $eavSetup */ +$eavSetup = $eavSetupFactory->create(); +$eavSetup->removeAttribute(Product::ENTITY, 'test_searchable_attribute'); + +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', false); diff --git a/dev/tests/integration/testsuite/Magento/CatalogUrlRewrite/Plugin/Catalog/Block/Adminhtml/Category/Tab/AttributesTest.php b/dev/tests/integration/testsuite/Magento/CatalogUrlRewrite/Plugin/Catalog/Block/Adminhtml/Category/Tab/AttributesTest.php index e2f8a2d5e4c21..3f9ab815038cc 100644 --- a/dev/tests/integration/testsuite/Magento/CatalogUrlRewrite/Plugin/Catalog/Block/Adminhtml/Category/Tab/AttributesTest.php +++ b/dev/tests/integration/testsuite/Magento/CatalogUrlRewrite/Plugin/Catalog/Block/Adminhtml/Category/Tab/AttributesTest.php @@ -9,6 +9,9 @@ use Magento\Eav\Model\Config as EavConfig; use Magento\TestFramework\Helper\Bootstrap; +/** + * @magentoAppArea adminhtml + */ class AttributesTest extends \PHPUnit\Framework\TestCase { /** diff --git a/dev/tests/integration/testsuite/Magento/Checkout/Model/CaptchaPaymentProcessingRateLimiterTest.php b/dev/tests/integration/testsuite/Magento/Checkout/Model/CaptchaPaymentProcessingRateLimiterTest.php new file mode 100644 index 0000000000000..2a7b3c29223be --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Checkout/Model/CaptchaPaymentProcessingRateLimiterTest.php @@ -0,0 +1,185 @@ +request = $objectManager->get(RequestInterface::class); + $this->request->getServer()->set('REMOTE_ADDR', '127.0.0.1'); + $objectManager->removeSharedInstance(RemoteAddress::class); + $this->captchaHelper = $objectManager->get(CaptchaHelper::class); + $this->customerSession = $objectManager->get(CustomerSession::class); + $this->customerRepo = $objectManager->get(CustomerRepositoryInterface::class); + $this->model = $objectManager->get(CaptchaPaymentProcessingRateLimiter::class); + } + + /** + * Verify that limits work for logged-in customers. + * + * @return void + * + * @magentoDataFixture Magento/Customer/_files/customer.php + * + * @magentoConfigFixture default_store customer/captcha/enable 1 + * @magentoConfigFixture default_store customer/captcha/forms payment_processing_request + * @magentoConfigFixture default_store customer/captcha/failed_attempts_login 2 + * @magentoConfigFixture default_store customer/captcha/failed_attempts_ip 10 + */ + public function testLoggedInLimits(): void + { + //Logging in + $customer = $this->customerRepo->get('customer@example.com'); + $this->customerSession->loginById($customer->getId()); + + $this->model->limit(); + $this->model->limit(); + try { + $this->model->limit(); + $limited = false; + } catch (PaymentProcessingRateLimitExceededException $exception) { + $limited = true; + } + $this->assertTrue($limited); + } + + /** + * Verify that limits work for guest. + * + * @return void + * + * @magentoConfigFixture default_store customer/captcha/enable 1 + * @magentoConfigFixture default_store customer/captcha/forms payment_processing_request + * @magentoConfigFixture default_store customer/captcha/failed_attempts_login 10 + * @magentoConfigFixture default_store customer/captcha/failed_attempts_ip 2 + */ + public function testGuestLimits(): void + { + $this->model->limit(); + $this->model->limit(); + try { + $this->model->limit(); + $limited = false; + } catch (PaymentProcessingRateLimitExceededException $exception) { + $limited = true; + } + $this->assertTrue($limited); + } + + /** + * Verify that CAPTCHA is validated. + * + * @return void + * + * @magentoConfigFixture default_store customer/captcha/enable 1 + * @magentoConfigFixture default_store customer/captcha/forms payment_processing_request + * @magentoConfigFixture default_store customer/captcha/failed_attempts_login 10 + * @magentoConfigFixture default_store customer/captcha/failed_attempts_ip 2 + */ + public function testCaptchaValidation(): void + { + $this->model->limit(); + $this->model->limit(); + try { + $this->model->limit(); + $limited = false; + } catch (PaymentProcessingRateLimitExceededException $exception) { + $limited = true; + } + //CAPTCHA is required + $this->assertTrue($limited); + + //Providing CAPTCHA value + /** @var DefaultModel $captcha */ + $captcha = $this->captchaHelper->getCaptcha(CaptchaPaymentProcessingRateLimiter::CAPTCHA_FORM); + $captcha->generate(); + $this->request->setPostValue( + 'captcha', + [CaptchaPaymentProcessingRateLimiter::CAPTCHA_FORM => $captcha->getWord()] + ); + $this->model->limit(); + //Providing CAPTCHA value in a header + /** @var DefaultModel $captcha */ + $captcha = $this->captchaHelper->getCaptcha(CaptchaPaymentProcessingRateLimiter::CAPTCHA_FORM); + $captcha->generate(); + $this->request->setPostValue( + 'captcha', + [CaptchaPaymentProcessingRateLimiter::CAPTCHA_FORM => ''] + ); + $this->request->getHeaders()->addHeaderLine('X-Captcha', $captcha->getWord()); + $this->model->limit(); + + //Providing invalid CAPTCHA value. + $this->request->setPostValue( + 'captcha', + [CaptchaPaymentProcessingRateLimiter::CAPTCHA_FORM => 'invalid'] + ); + $this->request->getHeaders()->removeHeader($this->request->getHeaders()->get('X-Captcha')); + try { + $this->model->limit(); + $limited = false; + } catch (PaymentProcessingRateLimitExceededException $exception) { + $limited = true; + } + //CAPTCHA was validated + $this->assertTrue($limited); + } +} diff --git a/dev/tests/integration/testsuite/Magento/Checkout/_files/quote_with_address_and_shipping_method_saved.php b/dev/tests/integration/testsuite/Magento/Checkout/_files/quote_with_address_and_shipping_method_saved.php new file mode 100644 index 0000000000000..98953fe785695 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Checkout/_files/quote_with_address_and_shipping_method_saved.php @@ -0,0 +1,28 @@ +requireDataFixture('Magento/Checkout/_files/quote_with_address_saved.php'); + +$objectManager = Bootstrap::getObjectManager(); +/** @var CartRepositoryInterface $quoteRepository */ +$quoteRepository = $objectManager->get(CartRepositoryInterface::class); +/** @var CartManagementInterface $quoteManagement */ +$quoteManagement = $objectManager->get(CartManagementInterface::class); +/** @var GetQuoteByReservedOrderId $getQuoteByReservedOrderId */ +$getQuoteByReservedOrderId = $objectManager->get(GetQuoteByReservedOrderId::class); +$quote = $getQuoteByReservedOrderId->execute('test_order_1'); +$quote->setIsActive(true); +$quote->getShippingAddress()->setShippingMethod('flatrate_flatrate'); +$quote->getShippingAddress()->setCollectShippingRates(true); +$quote->getShippingAddress()->collectShippingRates(); +$quoteRepository->save($quote); diff --git a/dev/tests/integration/testsuite/Magento/Checkout/_files/quote_with_address_and_shipping_method_saved_rollback.php b/dev/tests/integration/testsuite/Magento/Checkout/_files/quote_with_address_and_shipping_method_saved_rollback.php new file mode 100644 index 0000000000000..919958d9cbcf4 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Checkout/_files/quote_with_address_and_shipping_method_saved_rollback.php @@ -0,0 +1,10 @@ +requireDataFixture('Magento/Checkout/_files/quote_with_address_saved_rollback.php'); diff --git a/dev/tests/integration/testsuite/Magento/Cms/Command/WysiwygRestrictCommandTest.php b/dev/tests/integration/testsuite/Magento/Cms/Command/WysiwygRestrictCommandTest.php new file mode 100644 index 0000000000000..cd9844dc98811 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Cms/Command/WysiwygRestrictCommandTest.php @@ -0,0 +1,78 @@ +config = $objectManager->get(ReinitableConfigInterface::class); + $this->factory = $objectManager->get(WysiwygRestrictCommandFactory::class); + } + + /** + * "Execute" method cases. + * + * @return array + */ + public function getExecuteCases(): array + { + return [ + 'yes' => ['y', true], + 'no' => ['n', false], + 'no-but-different' => ['what', false] + ]; + } + + /** + * Test the command. + * + * @param string $argument + * @param bool $expectedFlag + * @return void + * @dataProvider getExecuteCases + * @magentoConfigFixture default_store cms/wysiwyg/force_valid 0 + */ + public function testExecute(string $argument, bool $expectedFlag): void + { + /** @var WysiwygRestrictCommand $model */ + $model = $this->factory->create(); + $tester = new CommandTester($model); + $tester->execute(['restrict' => $argument]); + + $this->config->reinit(); + $this->assertEquals($expectedFlag, $this->config->isSetFlag(Validator::CONFIG_PATH_THROW_EXCEPTION)); + } +} diff --git a/dev/tests/integration/testsuite/Magento/Cms/Controller/Adminhtml/Block/DeleteTest.php b/dev/tests/integration/testsuite/Magento/Cms/Controller/Adminhtml/Block/DeleteTest.php new file mode 100644 index 0000000000000..ab3eda8cf4e9f --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Cms/Controller/Adminhtml/Block/DeleteTest.php @@ -0,0 +1,67 @@ +getBlockByIdentifier = $this->_objectManager->get(GetBlockByIdentifierInterface::class); + $this->storeManager = $this->_objectManager->get(StoreManagerInterface::class); + $this->collectionFactory = $this->_objectManager->get(CollectionFactory::class); + } + + /** + * @magentoDataFixture Magento/Cms/_files/block_default_store.php + * + * @return void + */ + public function testDeleteBlock(): void + { + $defaultStoreId = (int)$this->storeManager->getStore('default')->getId(); + $blockId = $this->getBlockByIdentifier->execute('default_store_block', $defaultStoreId)->getId(); + $this->getRequest()->setMethod(Http::METHOD_POST) + ->setParams(['block_id' => $blockId]); + $this->dispatch('backend/cms/block/delete'); + $this->assertSessionMessages( + $this->containsEqual((string)__('You deleted the block.')), + MessageInterface::TYPE_SUCCESS + ); + $this->assertRedirect($this->stringContains('cms/block/index')); + $collection = $this->collectionFactory->getReport('cms_block_listing_data_source'); + $this->assertNull($collection->getItemByColumnValue(BlockInterface::IDENTIFIER, 'default_store_block')); + } +} diff --git a/dev/tests/integration/testsuite/Magento/Cms/Helper/Wysiwyg/ImagesTest.php b/dev/tests/integration/testsuite/Magento/Cms/Helper/Wysiwyg/ImagesTest.php index 46eb1e98ddc6a..7d3bf7ec1a1ea 100644 --- a/dev/tests/integration/testsuite/Magento/Cms/Helper/Wysiwyg/ImagesTest.php +++ b/dev/tests/integration/testsuite/Magento/Cms/Helper/Wysiwyg/ImagesTest.php @@ -82,7 +82,7 @@ public function testGetImageHtmlDeclaration( public function providerGetImageHtmlDeclaration() { return [ - [true, 'wysiwyg/hello.png', true, ''], + [true, 'wysiwyg/hello.png', true, ''], [ false, 'wysiwyg/hello.png', @@ -96,7 +96,7 @@ function ($actualResult) { $this->assertStringContainsString($expectedResult, parse_url($actualResult, PHP_URL_PATH)); } ], - [true, 'wysiwyg/hello.png', false, 'http://example.com/pub/media/wysiwyg/hello.png'], + [true, 'wysiwyg/hello.png', false, 'http://example.com/media/wysiwyg/hello.png'], [false, 'wysiwyg/hello.png', true, ''], ]; } diff --git a/dev/tests/integration/testsuite/Magento/Cms/Model/PageRepositoryTest.php b/dev/tests/integration/testsuite/Magento/Cms/Model/PageRepositoryTest.php index 53e514083d6ba..42845c0d8ac73 100644 --- a/dev/tests/integration/testsuite/Magento/Cms/Model/PageRepositoryTest.php +++ b/dev/tests/integration/testsuite/Magento/Cms/Model/PageRepositoryTest.php @@ -40,8 +40,9 @@ protected function setUp(): void \Magento\TestFramework\Cms\Model\CustomLayoutManager::class ] ]); - $this->repo = Bootstrap::getObjectManager()->get(PageRepositoryInterface::class); - $this->retriever = Bootstrap::getObjectManager()->get(GetPageByIdentifierInterface::class); + $objectManager = Bootstrap::getObjectManager(); + $this->repo = $objectManager->get(PageRepositoryInterface::class); + $this->retriever = $objectManager->get(GetPageByIdentifierInterface::class); } /** @@ -54,7 +55,7 @@ protected function setUp(): void public function testSaveUpdateXml(): void { $page = $this->retriever->execute('test_custom_layout_page_1', 0); - $page->setTitle($page->getTitle() .'TEST'); + $page->setTitle($page->getTitle() . 'TEST'); //Is successfully saved without changes to the custom layout xml. $page = $this->repo->save($page); @@ -86,4 +87,19 @@ public function testSaveUpdateXml(): void $this->assertEmpty($page->getCustomLayoutUpdateXml()); $this->assertEmpty($page->getLayoutUpdateXml()); } + + /** + * Verifies that cms page with identifier which duplicates existed route shouldn't be saved + * + * @return void + * @throws \Throwable + * @magentoDataFixture Magento/Cms/_files/pages.php + */ + public function testSaveWithRouteDuplicate(): void + { + $page = $this->retriever->execute('page100', 0); + $page->setIdentifier('customer'); + $this->expectException(CouldNotSaveException::class); + $this->repo->save($page); + } } diff --git a/dev/tests/integration/testsuite/Magento/Cms/Model/Wysiwyg/ConfigTest.php b/dev/tests/integration/testsuite/Magento/Cms/Model/Wysiwyg/ConfigTest.php index 3d6cbe98cf160..53b9dfee46aac 100644 --- a/dev/tests/integration/testsuite/Magento/Cms/Model/Wysiwyg/ConfigTest.php +++ b/dev/tests/integration/testsuite/Magento/Cms/Model/Wysiwyg/ConfigTest.php @@ -46,7 +46,7 @@ public function testGetConfig() public function testGetConfigCssUrls() { $config = $this->model->getConfig(); - $publicPathPattern = 'http://localhost/pub/static/%s/adminhtml/Magento/backend/en_US/%s'; + $publicPathPattern = 'http://localhost/static/%s/adminhtml/Magento/backend/en_US/%s'; $tinyMce4Config = $config->getData('tinymce4'); $contentCss = $tinyMce4Config['content_css']; if (is_array($contentCss)) { diff --git a/dev/tests/integration/testsuite/Magento/Cms/Model/Wysiwyg/Images/GetInsertImageContentTest.php b/dev/tests/integration/testsuite/Magento/Cms/Model/Wysiwyg/Images/GetInsertImageContentTest.php index 076a669f3f8ad..7ce695cb476fe 100644 --- a/dev/tests/integration/testsuite/Magento/Cms/Model/Wysiwyg/Images/GetInsertImageContentTest.php +++ b/dev/tests/integration/testsuite/Magento/Cms/Model/Wysiwyg/Images/GetInsertImageContentTest.php @@ -105,7 +105,7 @@ public function imageDataProvider(): array true, false, 1, - '/pub/media/catalog/category/test-image.jpg' + '/media/catalog/category/test-image.jpg' ], [ 'test-image.jpg', diff --git a/dev/tests/integration/testsuite/Magento/Cms/Model/Wysiwyg/Images/StorageTest.php b/dev/tests/integration/testsuite/Magento/Cms/Model/Wysiwyg/Images/StorageTest.php index a68a546c20bc6..96084981fe0b8 100644 --- a/dev/tests/integration/testsuite/Magento/Cms/Model/Wysiwyg/Images/StorageTest.php +++ b/dev/tests/integration/testsuite/Magento/Cms/Model/Wysiwyg/Images/StorageTest.php @@ -6,7 +6,13 @@ */ namespace Magento\Cms\Model\Wysiwyg\Images; +use Magento\Cms\Model\Wysiwyg\Images\Storage\Collection; use Magento\Framework\App\Filesystem\DirectoryList; +use Magento\Framework\DataObject; +use Magento\Framework\Filesystem; +use Magento\Framework\Filesystem\Driver\File; +use Magento\Framework\Filesystem\DriverInterface; +use Magento\TestFramework\Helper\Bootstrap; /** * Test methods of class Storage @@ -29,22 +35,27 @@ class StorageTest extends \PHPUnit\Framework\TestCase private $objectManager; /** - * @var \Magento\Framework\Filesystem + * @var Filesystem */ private $filesystem; /** - * @var \Magento\Cms\Model\Wysiwyg\Images\Storage + * @var Storage */ private $storage; + /** + * @var DriverInterface + */ + private $driver; + /** * @inheritdoc */ // phpcs:disable public static function setUpBeforeClass(): void { - self::$_baseDir = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->get( + self::$_baseDir = Bootstrap::getObjectManager()->get( \Magento\Cms\Helper\Wysiwyg\Images::class )->getCurrentPath() . 'MagentoCmsModelWysiwygImagesStorageTest'; if (!file_exists(self::$_baseDir)) { @@ -60,8 +71,8 @@ public static function setUpBeforeClass(): void // phpcs:ignore public static function tearDownAfterClass(): void { - \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Framework\Filesystem\Driver\File::class + Bootstrap::getObjectManager()->create( + File::class )->deleteDirectory( self::$_baseDir ); @@ -72,9 +83,10 @@ public static function tearDownAfterClass(): void */ protected function setUp(): void { - $this->objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); - $this->filesystem = $this->objectManager->get(\Magento\Framework\Filesystem::class); - $this->storage = $this->objectManager->create(\Magento\Cms\Model\Wysiwyg\Images\Storage::class); + $this->objectManager = Bootstrap::getObjectManager(); + $this->filesystem = $this->objectManager->get(Filesystem::class); + $this->storage = $this->objectManager->create(Storage::class); + $this->driver = Bootstrap::getObjectManager()->get(DriverInterface::class); } /** @@ -83,16 +95,32 @@ protected function setUp(): void */ public function testGetFilesCollection(): void { - \Magento\TestFramework\Helper\Bootstrap::getInstance() + Bootstrap::getInstance() ->loadArea(\Magento\Backend\App\Area\FrontNameResolver::AREA_CODE); - $collection = $this->storage->getFilesCollection(self::$_baseDir, 'media'); - $this->assertInstanceOf(\Magento\Cms\Model\Wysiwyg\Images\Storage\Collection::class, $collection); + $fileName = 'magento_image.jpg'; + $imagePath = realpath(__DIR__ . '/../../../../Catalog/_files/' . $fileName); + $mediaDirectory = $this->filesystem->getDirectoryWrite(DirectoryList::MEDIA); + $modifiableFilePath = $mediaDirectory->getAbsolutePath('MagentoCmsModelWysiwygImagesStorageTest/' . $fileName); + $this->driver->copy( + $imagePath, + $modifiableFilePath + ); + $this->storage->resizeFile($modifiableFilePath); + $collection = $this->storage->getFilesCollection(self::$_baseDir, 'image'); + $this->assertInstanceOf(Collection::class, $collection); foreach ($collection as $item) { - $this->assertInstanceOf(\Magento\Framework\DataObject::class, $item); - $this->assertStringEndsWith('/1.swf', $item->getUrl()); - $this->assertStringMatchesFormat( - 'http://%s/static/%s/adminhtml/%s/%s/Magento_Cms/images/placeholder_thumbnail.jpg', - $item->getThumbUrl() + $thumbUrl = parse_url($item->getThumbUrl(), PHP_URL_PATH); + $this->assertInstanceOf(DataObject::class, $item); + $this->assertStringEndsWith('/' . $fileName, $item->getUrl()); + $this->assertEquals( + '/media/.thumbsMagentoCmsModelWysiwygImagesStorageTest/magento_image.jpg', + $thumbUrl, + "Check if Thumbnail URL is equal to the generated URL" + ); + $this->assertEquals( + 'image/jpeg', + $item->getMimeType(), + "Check if Mime Type is equal to the image in the file system" ); return; } @@ -121,7 +149,7 @@ public function testDeleteDirectory(): void $this->storage->createDirectory($dir, $path); $this->assertFileExists($fullPath); $this->storage->deleteDirectory($fullPath); - $this->assertFileNotExists($fullPath); + $this->assertFileDoesNotExist($fullPath); } /** @@ -142,7 +170,7 @@ public function testDeleteDirectoryWithExcludedDirPath(): void public function testUploadFile(): void { $fileName = 'magento_small_image.jpg'; - $tmpDirectory = $this->filesystem->getDirectoryWrite(\Magento\Framework\App\Filesystem\DirectoryList::SYS_TMP); + $tmpDirectory = $this->filesystem->getDirectoryWrite(DirectoryList::SYS_TMP); $filePath = $tmpDirectory->getAbsolutePath($fileName); // phpcs:disable $fixtureDir = realpath(__DIR__ . '/../../../../Catalog/_files'); @@ -172,7 +200,7 @@ public function testUploadFileWithExcludedDirPath(): void ); $fileName = 'magento_small_image.jpg'; - $tmpDirectory = $this->filesystem->getDirectoryWrite(\Magento\Framework\App\Filesystem\DirectoryList::SYS_TMP); + $tmpDirectory = $this->filesystem->getDirectoryWrite(DirectoryList::SYS_TMP); $filePath = $tmpDirectory->getAbsolutePath($fileName); // phpcs:disable $fixtureDir = realpath(__DIR__ . '/../../../../Catalog/_files'); @@ -204,7 +232,7 @@ public function testUploadFileWithWrongExtension(string $fileName, string $fileT $this->expectException(\Magento\Framework\Exception\LocalizedException::class); $this->expectExceptionMessage('File validation failed.'); - $tmpDirectory = $this->filesystem->getDirectoryWrite(\Magento\Framework\App\Filesystem\DirectoryList::SYS_TMP); + $tmpDirectory = $this->filesystem->getDirectoryWrite(DirectoryList::SYS_TMP); $filePath = $tmpDirectory->getAbsolutePath($fileName); // phpcs:disable $fixtureDir = realpath(__DIR__ . '/../../../_files'); @@ -251,7 +279,7 @@ public function testUploadFileWithWrongFile(): void $this->expectExceptionMessage('File validation failed.'); $fileName = 'file.gif'; - $tmpDirectory = $this->filesystem->getDirectoryWrite(\Magento\Framework\App\Filesystem\DirectoryList::SYS_TMP); + $tmpDirectory = $this->filesystem->getDirectoryWrite(DirectoryList::SYS_TMP); $filePath = $tmpDirectory->getAbsolutePath($fileName); // phpcs:disable $file = fopen($filePath, "wb"); @@ -360,17 +388,17 @@ public function getThumbnailUrlDataProvider(): array [ '/', 'image1.png', - '/pub/media/.thumbs/image1.png' + '/media/.thumbs/image1.png' ], [ '/cms', 'image2.png', - '/pub/media/.thumbscms/image2.png' + '/media/.thumbscms/image2.png' ], [ '/cms/pages', 'image3.png', - '/pub/media/.thumbscms/pages/image3.png' + '/media/.thumbscms/pages/image3.png' ] ]; } diff --git a/dev/tests/integration/testsuite/Magento/Cms/Ui/Component/DataProviderTest.php b/dev/tests/integration/testsuite/Magento/Cms/Ui/Component/DataProviderTest.php new file mode 100644 index 0000000000000..710c49241d82c --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Cms/Ui/Component/DataProviderTest.php @@ -0,0 +1,104 @@ +objectManager = Bootstrap::getObjectManager(); + $this->request = $this->objectManager->get(RequestInterface::class); + $this->componentFactory = $this->objectManager->get(UiComponentFactory::class); + } + + /** + * @magentoDataFixture Magento/Cms/_files/pages.php + * + * @return void + */ + public function testPageFilteringByTitlePart(): void + { + $this->request->setParams(['search' => 'Cms Page 1']); + $data = $this->getComponentProvidedData('cms_page_listing'); + $items = $data['items']; + $this->assertCount(1, $items); + $this->assertEquals('page100', reset($items)[PageInterface::IDENTIFIER]); + } + + /** + * @magentoDataFixture Magento/Cms/_files/blocks.php + * + * @return void + */ + public function testBlockFilteringByTitlePart(): void + { + $this->request->setParams(['search' => 'Enabled CMS Block']); + $data = $this->getComponentProvidedData('cms_block_listing'); + $items = $data['items']; + $this->assertCount(1, $items); + $this->assertEquals('enabled_block', reset($items)[BlockInterface::IDENTIFIER]); + } + + /** + * Call prepare method in the child components + * + * @param UiComponentInterface $component + * @return void + */ + private function prepareChildComponents(UiComponentInterface $component): void + { + foreach ($component->getChildComponents() as $child) { + $this->prepareChildComponents($child); + } + + $component->prepare(); + } + + /** + * Get component provided data + * + * @param string $namespace + * @return array + */ + private function getComponentProvidedData(string $namespace): array + { + $component = $this->componentFactory->create($namespace); + $this->prepareChildComponents($component); + + return $component->getContext()->getDataProvider()->getData(); + } +} diff --git a/dev/tests/integration/testsuite/Magento/Config/Model/Config/Backend/Admin/RobotsTest.php b/dev/tests/integration/testsuite/Magento/Config/Model/Config/Backend/Admin/RobotsTest.php index 8458a26e44659..1fd45ba1c87ba 100644 --- a/dev/tests/integration/testsuite/Magento/Config/Model/Config/Backend/Admin/RobotsTest.php +++ b/dev/tests/integration/testsuite/Magento/Config/Model/Config/Backend/Admin/RobotsTest.php @@ -6,6 +6,8 @@ namespace Magento\Config\Model\Config\Backend\Admin; use Magento\Config\Model\Config\Reader\Source\Deployed\DocumentRoot; +use Magento\Framework\App\Filesystem\DirectoryList; +use Magento\Framework\Filesystem; /** * @magentoAppArea adminhtml @@ -34,10 +36,7 @@ protected function setUp(): void $this->model->setPath('design/search_engine_robots/custom_instructions'); $this->model->afterLoad(); - $documentRootPath = $objectManager->get(DocumentRoot::class)->getPath(); - $this->rootDirectory = $objectManager->get( - \Magento\Framework\Filesystem::class - )->getDirectoryRead($documentRootPath); + $this->rootDirectory = $objectManager->get(Filesystem::class)->getDirectoryRead(DirectoryList::PUB); } /** @@ -57,7 +56,8 @@ public function testAfterLoadRobotsTxtNotExists() */ public function testAfterLoadRobotsTxtExists() { - $this->assertEquals('Sitemap: http://store.com/sitemap.xml', $this->model->getValue()); + $value = $this->model->getValue(); + $this->assertEquals('Sitemap: http://store.com/sitemap.xml', $value); } /** @@ -92,7 +92,8 @@ protected function _modifyConfig() { $robotsTxt = "User-Agent: *\nDisallow: /checkout"; $this->model->setValue($robotsTxt)->save(); - $this->assertStringEqualsFile($this->rootDirectory->getAbsolutePath('robots.txt'), $robotsTxt); + $file = $this->rootDirectory->getAbsolutePath('robots.txt'); + $this->assertStringEqualsFile($file, $robotsTxt); } /** diff --git a/dev/tests/integration/testsuite/Magento/Config/Model/Config/Export/CommentTest.php b/dev/tests/integration/testsuite/Magento/Config/Model/Config/Export/CommentTest.php new file mode 100644 index 0000000000000..55821a6d64941 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Config/Model/Config/Export/CommentTest.php @@ -0,0 +1,65 @@ +comment = Bootstrap::getObjectManager()->create(Comment::class); + } + + public function testGet() + { + $sensitivePaths = $this->getSensitivePaths(); + $comments = $this->comment->get(); + + $missedPaths = []; + foreach ($sensitivePaths as $sensitivePath) { + if (stripos($comments, $sensitivePath) === false) { + $missedPaths[] = $sensitivePath; + } + } + + $this->assertEmpty( + $missedPaths, + 'Sensitive paths are missed: ' . implode(', ', $missedPaths) + ); + } + + /** + * Retrieve sensitive paths from class that is used to check is path sensitive. + * + * There is no public method to get this data. + * It's why they are read using private method. + * + * @return array + */ + private function getSensitivePaths(): array + { + $typePool = Bootstrap::getObjectManager()->get(TypePool::class); + $sensitivePathsReader = \Closure::bind( + function () { + return $this->getPathsByType(TypePool::TYPE_SENSITIVE); + }, + $typePool, + $typePool + ); + + return $sensitivePathsReader(); + } +} diff --git a/dev/tests/integration/testsuite/Magento/Config/Model/_files/no_robots_txt.php b/dev/tests/integration/testsuite/Magento/Config/Model/_files/no_robots_txt.php index bbb229221bac3..d840261669992 100644 --- a/dev/tests/integration/testsuite/Magento/Config/Model/_files/no_robots_txt.php +++ b/dev/tests/integration/testsuite/Magento/Config/Model/_files/no_robots_txt.php @@ -9,7 +9,7 @@ $rootDirectory = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->get( \Magento\Framework\Filesystem::class )->getDirectoryWrite( - DirectoryList::ROOT + DirectoryList::PUB ); if ($rootDirectory->isExist('robots.txt')) { $rootDirectory->delete('robots.txt'); diff --git a/dev/tests/integration/testsuite/Magento/Config/Model/_files/robots_txt.php b/dev/tests/integration/testsuite/Magento/Config/Model/_files/robots_txt.php index c4fb2c92c45a5..3097132b74c2c 100644 --- a/dev/tests/integration/testsuite/Magento/Config/Model/_files/robots_txt.php +++ b/dev/tests/integration/testsuite/Magento/Config/Model/_files/robots_txt.php @@ -3,12 +3,15 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + use Magento\Framework\App\Filesystem\DirectoryList; +use Magento\Framework\Filesystem; +use Magento\TestFramework\Helper\Bootstrap; -/** @var \Magento\Framework\Filesystem\Directory\Write $rootDirectory */ -$rootDirectory = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->get( - \Magento\Framework\Filesystem::class -)->getDirectoryWrite( - DirectoryList::ROOT -); -$rootDirectory->copyFile($rootDirectory->getRelativePath(__DIR__ . '/robots.txt'), 'robots.txt'); +/** @var $fileSystem Filesystem */ +$fileSystem = Bootstrap::getObjectManager()->get(Filesystem::class); +$pubDirectory = $fileSystem->getDirectoryWrite(DirectoryList::PUB); +$rootDirectory = $fileSystem->getDirectoryRead(DirectoryList::ROOT); +$source = $rootDirectory->getAbsolutePath(__DIR__ . '/robots.txt'); +$content = $rootDirectory->readFile(__DIR__ . '/robots.txt'); +$pubDirectory->writeFile('robots.txt', $content); diff --git a/dev/tests/integration/testsuite/Magento/ConfigurableProduct/Block/Product/View/Type/ConfigurableTest.php b/dev/tests/integration/testsuite/Magento/ConfigurableProduct/Block/Product/View/Type/ConfigurableTest.php index 0344d467a3cc2..214613821afb6 100644 --- a/dev/tests/integration/testsuite/Magento/ConfigurableProduct/Block/Product/View/Type/ConfigurableTest.php +++ b/dev/tests/integration/testsuite/Magento/ConfigurableProduct/Block/Product/View/Type/ConfigurableTest.php @@ -19,6 +19,8 @@ use Magento\Framework\ObjectManagerInterface; use Magento\Framework\Serialize\SerializerInterface; use Magento\Framework\View\LayoutInterface; +use Magento\Store\Api\WebsiteRepositoryInterface; +use Magento\Store\Model\StoreManagerInterface; use Magento\TestFramework\Helper\Bootstrap; use PHPUnit\Framework\TestCase; @@ -126,6 +128,29 @@ public function testGetAllowProducts(): void } } + /** + * Verify configurable option not assigned to current website won't be visible. + * + * @magentoDataFixture Magento/ConfigurableProduct/_files/configurable_product_two_websites.php + * @magentoDbIsolation disabled + * @magentoAppArea frontend + * + * @return void + */ + public function testGetAllowProductsNonDefaultWebsite(): void + { + // Set current website to non-default. + $storeManager = $this->objectManager->get(StoreManagerInterface::class); + $storeManager->setCurrentStore('fixture_second_store'); + // Un-assign simple product from non-default website. + $simple = $this->productRepository->get('simple_Option_1'); + $simple->setWebsiteIds([1]); + $this->productRepository->save($simple); + // Verify only one configurable option will be visible. + $products = $this->block->getAllowProducts(); + $this->assertEquals(1, count($products)); + } + /** * @return void */ diff --git a/dev/tests/integration/testsuite/Magento/ConfigurableProduct/Block/Product/View/Type/MultiStoreConfigurableViewOnProductPageTest.php b/dev/tests/integration/testsuite/Magento/ConfigurableProduct/Block/Product/View/Type/MultiStoreConfigurableViewOnProductPageTest.php index 3a6052da3964f..cd206ec8ec273 100644 --- a/dev/tests/integration/testsuite/Magento/ConfigurableProduct/Block/Product/View/Type/MultiStoreConfigurableViewOnProductPageTest.php +++ b/dev/tests/integration/testsuite/Magento/ConfigurableProduct/Block/Product/View/Type/MultiStoreConfigurableViewOnProductPageTest.php @@ -184,7 +184,10 @@ private function prepareConfigurableProduct(string $sku, string $storeCode): voi { $product = $this->productRepository->get($sku, false, null, true); $productToUpdate = $product->getTypeInstance()->getUsedProductCollection($product) - ->setPageSize(1)->getFirstItem(); + ->addStoreFilter($storeCode) + ->setPageSize(1) + ->getFirstItem(); + $this->assertNotEmpty($productToUpdate->getData(), 'Configurable product does not have a child'); $this->executeInStoreContext->execute($storeCode, [$this, 'setProductDisabled'], $productToUpdate); } diff --git a/dev/tests/integration/testsuite/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/ConfigurableTest.php b/dev/tests/integration/testsuite/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/ConfigurableTest.php index ffa84ca740e62..28cbf80703d51 100644 --- a/dev/tests/integration/testsuite/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/ConfigurableTest.php +++ b/dev/tests/integration/testsuite/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/ConfigurableTest.php @@ -3,6 +3,8 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); + namespace Magento\ConfigurableProduct\Model\ResourceModel\Product\Indexer\Price; use Magento\Catalog\Api\ProductRepositoryInterface; @@ -153,10 +155,44 @@ public function testReindexWithCorrectPriority() true ); - $configurableProduct = $this->getConfigurableProductFromCollection($configurableProduct->getId()); + $configurableProduct = $this->getConfigurableProductFromCollection((int)$configurableProduct->getId()); $this->assertEquals($childProduct1->getPrice(), $configurableProduct->getMinimalPrice()); } + /** + * Test get product minimal price if all children is out of stock + * + * @magentoConfigFixture current_store cataloginventory/options/show_out_of_stock 1 + * @magentoDataFixture Magento/ConfigurableProduct/_files/product_configurable.php + * @magentoDbIsolation disabled + * + * @return void + * @throws \Magento\Framework\Exception\NoSuchEntityException + */ + public function testReindexIfAllChildrenIsOutOfStock(): void + { + $configurableProduct = $this->getConfigurableProductFromCollection(1); + $this->assertEquals(10, $configurableProduct->getMinimalPrice()); + + $childProduct1 = $this->productRepository->getById(10, false, null, true); + $stockItem = $childProduct1->getExtensionAttributes()->getStockItem(); + $stockItem->setIsInStock(Stock::STOCK_OUT_OF_STOCK); + $this->stockRepository->save($stockItem); + + $childProduct2 = $this->productRepository->getById(20, false, null, true); + $stockItem = $childProduct2->getExtensionAttributes()->getStockItem(); + $stockItem->setIsInStock(Stock::STOCK_OUT_OF_STOCK); + $this->stockRepository->save($stockItem); + + $configurableProduct1 = $this->productRepository->getById(1, false, null, true); + $stockItem = $configurableProduct1->getExtensionAttributes()->getStockItem(); + $stockItem->setIsInStock(Stock::STOCK_OUT_OF_STOCK); + $this->stockRepository->save($stockItem); + + $configurableProduct = $this->getConfigurableProductFromCollection(1); + $this->assertEquals(10, $configurableProduct->getMinimalPrice()); + } + /** * Retrieve configurable product. * Returns Configurable product that was created by Magento/ConfigurableProduct/_files/product_configurable.php diff --git a/dev/tests/integration/testsuite/Magento/ConfigurableProduct/Model/ResourceModel/Product/RelationTest.php b/dev/tests/integration/testsuite/Magento/ConfigurableProduct/Model/ResourceModel/Product/RelationTest.php new file mode 100644 index 0000000000000..e9fa6d5bf96b7 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/ConfigurableProduct/Model/ResourceModel/Product/RelationTest.php @@ -0,0 +1,86 @@ +objectManager = Bootstrap::getObjectManager(); + $this->model = $this->objectManager->get(Relation::class); + $this->productRepository = $this->objectManager->get(ProductRepositoryInterface::class); + $this->searchCriteriaBuilder = $this->objectManager->get(SearchCriteriaBuilder::class); + } + + /** + * Tests that getRelationsByChildren will return parent products entity ids of child products entity ids. + * + * @magentoDataFixture Magento/ConfigurableProduct/_files/configurable_products.php + */ + public function testGetRelationsByChildren(): void + { + // Find configurable products options + $productOptionSkus = ['simple_10', 'simple_20', 'simple_30', 'simple_40']; + $searchCriteria = $this->searchCriteriaBuilder->addFilter('sku', $productOptionSkus, 'in') + ->create(); + $productOptions = $this->productRepository->getList($searchCriteria) + ->getItems(); + + $productOptionsIds = []; + + foreach ($productOptions as $productOption) { + $productOptionsIds[] = $productOption->getId(); + } + + // Find configurable products + $searchCriteria = $this->searchCriteriaBuilder->addFilter('sku', ['configurable', 'configurable_12345'], 'in') + ->create(); + $configurableProducts = $this->productRepository->getList($searchCriteria) + ->getItems(); + + // Assert there are configurable products ids in result of getRelationsByChildren method. + $result = $this->model->getRelationsByChildren($productOptionsIds); + + foreach ($configurableProducts as $configurableProduct) { + $this->assertContains($configurableProduct->getId(), $result); + } + } +} diff --git a/dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/configurable_attribute.php b/dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/configurable_attribute.php index 12f63993cb2d3..939c1d261b3c6 100644 --- a/dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/configurable_attribute.php +++ b/dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/configurable_attribute.php @@ -4,59 +4,67 @@ * See COPYING.txt for license details. */ +use Magento\Catalog\Api\Data\ProductAttributeInterface; +use Magento\Catalog\Api\Data\ProductAttributeInterfaceFactory; +use Magento\Catalog\Api\ProductAttributeRepositoryInterface; +use Magento\Catalog\Model\Product; +use Magento\Eav\Model\Config; +use Magento\Eav\Setup\EavSetup; +use Magento\Framework\Exception\NoSuchEntityException; use Magento\TestFramework\Helper\Bootstrap; -use Magento\Eav\Api\AttributeRepositoryInterface; +use Magento\TestFramework\Workaround\Override\Fixture\Resolver; -$eavConfig = Bootstrap::getObjectManager()->get(\Magento\Eav\Model\Config::class); -$attribute = $eavConfig->getAttribute('catalog_product', 'test_configurable'); +$objectManager = Bootstrap::getObjectManager(); -$eavConfig->clear(); +/** @var ProductAttributeRepositoryInterface $attributeRepository */ +$attributeRepository = $objectManager->get(ProductAttributeRepositoryInterface::class); +/** @var ProductAttributeInterfaceFactory $attributeFactory */ +$attributeFactory = $objectManager->get(ProductAttributeInterfaceFactory::class); -/** @var $installer \Magento\Catalog\Setup\CategorySetup */ -$installer = Bootstrap::getObjectManager()->create(\Magento\Catalog\Setup\CategorySetup::class); - -if (!$attribute->getId()) { - - /** @var $attribute \Magento\Catalog\Model\ResourceModel\Eav\Attribute */ - $attribute = Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\ResourceModel\Eav\Attribute::class - ); - - /** @var AttributeRepositoryInterface $attributeRepository */ - $attributeRepository = Bootstrap::getObjectManager()->create(AttributeRepositoryInterface::class); - - $attribute->setData( - [ - 'attribute_code' => 'test_configurable', - 'entity_type_id' => $installer->getEntityTypeId('catalog_product'), - 'is_global' => 1, - 'is_user_defined' => 1, - 'frontend_input' => 'select', - 'is_unique' => 0, - 'is_required' => 0, - 'is_searchable' => 0, - 'is_visible_in_advanced_search' => 0, - 'is_comparable' => 0, - 'is_filterable' => 0, - 'is_filterable_in_search' => 0, - 'is_used_for_promo_rules' => 0, - 'is_html_allowed_on_front' => 1, - 'is_visible_on_front' => 0, - 'used_in_product_listing' => 0, - 'used_for_sort_by' => 0, - 'frontend_label' => ['Test Configurable'], - 'backend_type' => 'int', - 'option' => [ - 'value' => ['option_0' => ['Option 1'], 'option_1' => ['Option 2']], - 'order' => ['option_0' => 1, 'option_1' => 2], - ], - ] - ); - - $attributeRepository->save($attribute); - - /* Assign attribute to attribute set */ - $installer->addAttributeToGroup('catalog_product', 'Default', 'General', $attribute->getId()); +try { + $attributeRepository->get('test_configurable'); + Resolver::getInstance() + ->requireDataFixture('Magento/ConfigurableProduct/_files/configurable_attribute_rollback.php'); +} catch (NoSuchEntityException $e) { } +$eavConfig = $objectManager->get(Config::class); + +/** @var $installer EavSetup */ +$installer = $objectManager->get(EavSetup::class); +$attributeSetId = $installer->getAttributeSetId(Product::ENTITY, 'Default'); +$groupId = $installer->getDefaultAttributeGroupId(Product::ENTITY, $attributeSetId); +/** @var ProductAttributeInterface $attributeModel */ +$attributeModel = $attributeFactory->create(); +$attributeModel->setData( + [ + 'attribute_code' => 'test_configurable', + 'entity_type_id' => $installer->getEntityTypeId(Product::ENTITY), + 'is_global' => 1, + 'is_user_defined' => 1, + 'frontend_input' => 'select', + 'is_unique' => 0, + 'is_required' => 0, + 'is_searchable' => 0, + 'is_visible_in_advanced_search' => 0, + 'is_comparable' => 0, + 'is_filterable' => 0, + 'is_filterable_in_search' => 0, + 'is_used_for_promo_rules' => 0, + 'is_html_allowed_on_front' => 1, + 'is_visible_on_front' => 0, + 'used_in_product_listing' => 0, + 'used_for_sort_by' => 0, + 'frontend_label' => ['Test Configurable'], + 'backend_type' => 'int', + 'option' => [ + 'value' => ['option_0' => ['Option 1'], 'option_1' => ['Option 2']], + 'order' => ['option_0' => 1, 'option_1' => 2], + ], + ] +); + +$attribute = $attributeRepository->save($attributeModel); + +$installer->addAttributeToGroup(Product::ENTITY, $attributeSetId, $groupId, $attribute->getId()); $eavConfig->clear(); diff --git a/dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/configurable_products.php b/dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/configurable_products.php index 61c2bf7b5fa72..618b554aaa2cc 100644 --- a/dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/configurable_products.php +++ b/dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/configurable_products.php @@ -3,50 +3,63 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + +use Magento\Catalog\Api\Data\ProductInterfaceFactory; +use Magento\Catalog\Api\ProductAttributeRepositoryInterface; use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Catalog\Model\ResourceModel\Eav\Attribute; use Magento\Catalog\Model\Product; use Magento\Catalog\Model\Product\Attribute\Source\Status; use Magento\Catalog\Model\Product\Type; use Magento\Catalog\Model\Product\Visibility; -use Magento\Catalog\Setup\CategorySetup; use Magento\ConfigurableProduct\Helper\Product\Options\Factory; use Magento\ConfigurableProduct\Model\Product\Type\Configurable; use Magento\Eav\Api\Data\AttributeOptionInterface; -use Magento\Eav\Model\Config; +use Magento\Eav\Setup\EavSetup; +use Magento\Store\Api\WebsiteRepositoryInterface; use Magento\TestFramework\Helper\Bootstrap; use Magento\TestFramework\Workaround\Override\Fixture\Resolver; Resolver::getInstance()->requireDataFixture('Magento/ConfigurableProduct/_files/configurable_attribute.php'); $objectManager = Bootstrap::getObjectManager(); -/** @var ProductRepositoryInterface $productRepository */ -$productRepository = $objectManager - ->get(ProductRepositoryInterface::class); -/** @var Config $eavConfig */ -$eavConfig = $objectManager->get(Config::class); -$attribute = $eavConfig->getAttribute(Product::ENTITY, 'test_configurable'); -/** @var $installer CategorySetup */ -$installer = $objectManager->create(CategorySetup::class); +/** @var WebsiteRepositoryInterface $websiteRepository */ +$websiteRepository = $objectManager->get(WebsiteRepositoryInterface::class); +$baseWebsite = $websiteRepository->get('base'); -/* Create simple products per each option value*/ +/** @var ProductRepositoryInterface $productRepository */ +$productRepository = $objectManager->get(ProductRepositoryInterface::class); +/** @var ProductInterfaceFactory $productInterfaceFactory */ +$productInterfaceFactory = $objectManager->get(ProductInterfaceFactory::class); + +/** @var ProductAttributeRepositoryInterface $attributeRepository */ +$attributeRepository = $objectManager->get(ProductAttributeRepositoryInterface::class); +/** @var $attribute Attribute */ +$attribute = $attributeRepository->get('test_configurable'); /** @var AttributeOptionInterface[] $options */ $options = $attribute->getOptions(); +/** @var $installer EavSetup */ +$installer = $objectManager->get(EavSetup::class); +$attributeSetId = $installer->getAttributeSetId(Product::ENTITY, 'Default'); + +/** @var Factory $optionsFactory */ +$optionsFactory = $objectManager->get(Factory::class); +/* Create simple products per each option value*/ + $attributeValues = []; -$attributeSetId = $installer->getAttributeSetId('catalog_product', 'Default'); $associatedProductIds = []; $productIds = [10, 20]; array_shift($options); //remove the first option which is empty foreach ($options as $option) { /** @var $product Product */ - $product = $objectManager->create(Product::class); + $product = $productInterfaceFactory->create(); $productId = array_shift($productIds); $product->setTypeId(Type::TYPE_SIMPLE) - ->setId($productId) ->setAttributeSetId($attributeSetId) - ->setWebsiteIds([1]) + ->setWebsiteIds([$baseWebsite->getId()]) ->setName('Configurable Option' . $option->getLabel()) ->setSku('simple_' . $productId) ->setPrice($productId) @@ -54,20 +67,18 @@ ->setVisibility(Visibility::VISIBILITY_NOT_VISIBLE) ->setStatus(Status::STATUS_ENABLED) ->setStockData(['use_config_manage_stock' => 1, 'qty' => 100, 'is_qty_decimal' => 0, 'is_in_stock' => 1]); - $product = $productRepository->save($product); + $simple1 = $productRepository->save($product); $attributeValues[] = [ 'label' => 'test', 'attribute_id' => $attribute->getId(), 'value_index' => $option->getValue(), ]; - $associatedProductIds[] = $product->getId(); + $associatedProductIds[] = $simple1->getId(); } /** @var $product Product */ -$product = $objectManager->create(Product::class); -/** @var Factory $optionsFactory */ -$optionsFactory = $objectManager->create(Factory::class); +$product = $productInterfaceFactory->create(); $configurableAttributesData = [ [ 'attribute_id' => $attribute->getId(), @@ -84,9 +95,8 @@ $product->setExtensionAttributes($extensionConfigurableAttributes); $product->setTypeId(Configurable::TYPE_CODE) - ->setId(1) ->setAttributeSetId($attributeSetId) - ->setWebsiteIds([1]) + ->setWebsiteIds([$baseWebsite->getId()]) ->setName('Configurable Product') ->setSku('configurable') ->setVisibility(Visibility::VISIBILITY_BOTH) @@ -100,19 +110,17 @@ $options = $attribute->getOptions(); $attributeValues = []; -$attributeSetId = $installer->getAttributeSetId('catalog_product', 'Default'); $associatedProductIds = []; $productIds = [30, 40]; array_shift($options); //remove the first option which is empty foreach ($options as $option) { /** @var $product Product */ - $product = $objectManager->create(Product::class); + $product = $productInterfaceFactory->create(); $productId = array_shift($productIds); $product->setTypeId(Type::TYPE_SIMPLE) - ->setId($productId) ->setAttributeSetId($attributeSetId) - ->setWebsiteIds([1]) + ->setWebsiteIds([$baseWebsite->getId()]) ->setName('Configurable Option' . $option->getLabel()) ->setSku('simple_' . $productId) ->setPrice($productId) @@ -120,21 +128,18 @@ ->setVisibility(Visibility::VISIBILITY_NOT_VISIBLE) ->setStatus(Status::STATUS_ENABLED) ->setStockData(['use_config_manage_stock' => 1, 'qty' => 100, 'is_qty_decimal' => 0, 'is_in_stock' => 1]); - $product = $productRepository->save($product); + $simple2 = $productRepository->save($product); $attributeValues[] = [ 'label' => 'test', 'attribute_id' => $attribute->getId(), 'value_index' => $option->getValue(), ]; - $associatedProductIds[] = $product->getId(); + $associatedProductIds[] = $simple2->getId(); } /** @var $product Product */ -$product = $objectManager->create(Product::class); - -/** @var Factory $optionsFactory */ -$optionsFactory = $objectManager->create(Factory::class); +$product = $productInterfaceFactory->create(); $configurableAttributesData = [ [ @@ -155,9 +160,8 @@ $product->setExtensionAttributes($extensionConfigurableAttributes); $product->setTypeId(Configurable::TYPE_CODE) - ->setId(11) ->setAttributeSetId($attributeSetId) - ->setWebsiteIds([1]) + ->setWebsiteIds([$baseWebsite->getId()]) ->setName('Configurable Product 12345') ->setSku('configurable_12345') ->setVisibility(Visibility::VISIBILITY_BOTH) diff --git a/dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/configurable_products_with_two_attributes_combination.php b/dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/configurable_products_with_two_attributes_combination.php new file mode 100644 index 0000000000000..24e6010275bac --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/configurable_products_with_two_attributes_combination.php @@ -0,0 +1,188 @@ +requireDataFixture( + 'Magento/ConfigurableProduct/_files/configurable_attribute_first.php' +); +Resolver::getInstance()->requireDataFixture( + 'Magento/ConfigurableProduct/_files/configurable_attribute_second.php' +); + +/** @var ProductRepositoryInterface $productRepository */ +$productRepository = Bootstrap::getObjectManager() + ->get(ProductRepositoryInterface::class); + +/** @var $installer CategorySetup */ +$installer = Bootstrap::getObjectManager()->create(CategorySetup::class); + +/** @var \Magento\Eav\Model\Config $eavConfig */ +$eavConfig = Bootstrap::getObjectManager()->get(\Magento\Eav\Model\Config::class); +$firstAttribute = $eavConfig->getAttribute(Product::ENTITY, 'test_configurable_first'); +$secondAttribute = $eavConfig->getAttribute(Product::ENTITY, 'test_configurable_second'); + +/* Create simple products per each option value*/ +/** @var AttributeOptionInterface[] $firstAttributeOptions */ +$firstAttributeOptions = $firstAttribute->getOptions(); +/** @var AttributeOptionInterface[] $secondAttributeOptions */ +$secondAttributeOptions = $secondAttribute->getOptions(); + +$attributeSetId = $installer->getAttributeSetId('catalog_product', 'Default'); +$associatedProductIds = []; +$firstAttributeValues = []; +$secondAttributeValues = []; +$testImagePath = __DIR__ . '/magento_image.jpg'; + +array_shift($firstAttributeOptions); +array_shift($secondAttributeOptions); +foreach ($firstAttributeOptions as $i => $firstAttributeOption) { + $firstAttributeValues[] = [ + 'label' => 'test first ' . $firstAttributeOption->getValue(), + 'attribute_id' => $firstAttribute->getId(), + 'value_index' => $firstAttributeOption->getValue(), + ]; + foreach ($secondAttributeOptions as $j => $secondAttributeOption) { + if ($i == 3 && in_array($j, [0, 1])) { + $qty = 0; + $isInStock = 0; + } else { + $qty = 100; + $isInStock = 1; + } + $product = Bootstrap::getObjectManager()->create(Product::class); + $product->setTypeId(Type::TYPE_SIMPLE) + ->setAttributeSetId($attributeSetId) + ->setWebsiteIds([1]) + ->setName( + 'Configurable Option ' . $firstAttributeOption->getLabel() . '-' . $secondAttributeOption->getLabel() + ) + ->setSku('simple_' . $firstAttributeOption->getValue() . '_' . $secondAttributeOption->getValue()) + ->setPrice($firstAttributeOption->getValue() + $secondAttributeOption->getValue()) + ->setVisibility(Visibility::VISIBILITY_NOT_VISIBLE) + ->setStatus(Status::STATUS_ENABLED) + ->setStockData( + ['use_config_manage_stock' => 1, 'qty' => $qty, 'is_qty_decimal' => 0, 'is_in_stock' => $isInStock] + ) + ->setImage('/m/a/magento_image.jpg') + ->setSmallImage('/m/a/magento_image.jpg') + ->setThumbnail('/m/a/magento_image.jpg') + ->setData( + 'media_gallery', + [ + 'images' => [ + [ + 'file' => '/m/a/magento_image.jpg', + 'position' => 1, + 'label' => 'Image Alt Text', + 'disabled' => 0, + 'media_type' => 'image', + 'content' => [ + 'data' => [ + ImageContentInterface::BASE64_ENCODED_DATA => base64_encode( + file_get_contents($testImagePath) + ), + ImageContentInterface::NAME => 'simple_' . $firstAttributeOption->getValue() . + '_' . $secondAttributeOption->getValue() . "_1.jpg", + ImageContentInterface::TYPE => "image/jpeg" + ] + ] + ], + ] + ] + ); + $customAttributes = [ + $firstAttribute->getAttributeCode() => $firstAttributeOption->getValue(), + $secondAttribute->getAttributeCode() => $secondAttributeOption->getValue() + ]; + foreach ($customAttributes as $attributeCode => $attributeValue) { + $product->setCustomAttributes($customAttributes); + } + $product = $productRepository->save($product); + $associatedProductIds[] = $product->getId(); + + /** @var \Magento\CatalogInventory\Model\Stock\Item $stockItem */ + $stockItem = Bootstrap::getObjectManager()->create(Item::class); + $stockItem->load($product->getId(), 'product_id'); + + if (!$stockItem->getProductId()) { + $stockItem->setProductId($product->getId()); + } + $stockItem->setUseConfigManageStock(1); + $stockItem->setQty($qty); + $stockItem->setIsQtyDecimal(0); + $stockItem->setIsInStock($isInStock); + $stockItem->save(); + + $secondAttributeValues[$j] = [ + 'label' => 'test second ' . $firstAttributeOption->getValue() . $secondAttributeOption->getValue(), + 'attribute_id' => $secondAttribute->getId(), + 'value_index' => $secondAttributeOption->getValue(), + ]; + } + +} + +$indexerProcessor = Bootstrap::getObjectManager()->get(PriceIndexerProcessor::class); +$indexerProcessor->reindexList($associatedProductIds, true); + +/** @var $product Product */ +$product = Bootstrap::getObjectManager()->create(Product::class); + +/** @var Factory $optionsFactory */ +$optionsFactory = Bootstrap::getObjectManager()->create(Factory::class); + +$configurableAttributesData = [ + [ + 'attribute_id' => $firstAttribute->getId(), + 'code' => $firstAttribute->getAttributeCode(), + 'label' => $firstAttribute->getStoreLabel(), + 'position' => '0', + 'values' => $firstAttributeValues, + ], + [ + 'attribute_id' => $secondAttribute->getId(), + 'code' => $secondAttribute->getAttributeCode(), + 'label' => $secondAttribute->getStoreLabel(), + 'position' => '1', + 'values' => $secondAttributeValues, + ], +]; + +$configurableOptions = $optionsFactory->create($configurableAttributesData); +$firstAttributeSetId = $installer->getAttributeSetId('catalog_product', 'Default'); + +$extensionConfigurableAttributes = $product->getExtensionAttributes(); +$extensionConfigurableAttributes->setConfigurableProductOptions($configurableOptions); +$extensionConfigurableAttributes->setConfigurableProductLinks($associatedProductIds); + +$product->setExtensionAttributes($extensionConfigurableAttributes); + +$product->setTypeId(Configurable::TYPE_CODE) + ->setAttributeSetId($firstAttributeSetId) + ->setWebsiteIds([1]) + ->setName('Configurable Product 12345') + ->setSku('configurable_12345') + ->setVisibility(Visibility::VISIBILITY_BOTH) + ->setStatus(Status::STATUS_ENABLED) + ->setStockData(['use_config_manage_stock' => 1, 'qty' => 100, 'is_qty_decimal' => 0, 'is_in_stock' => 1]); +$productRepository->cleanCache(); +$product = $productRepository->save($product); + +$indexerProcessor = Bootstrap::getObjectManager()->get(PriceIndexerProcessor::class); +$indexerProcessor->reindexRow($product->getId(), true); diff --git a/dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/configurable_products_with_two_attributes_combination_rollback.php b/dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/configurable_products_with_two_attributes_combination_rollback.php new file mode 100644 index 0000000000000..b5527b8484a19 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/configurable_products_with_two_attributes_combination_rollback.php @@ -0,0 +1,84 @@ +get(Registry::class); + +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', true); + +/** @var ProductRepositoryInterface $productRepository */ +$productRepository = $objectManager->get(ProductRepositoryInterface::class); + +/** @var $installer CategorySetup */ +$installer = $objectManager->create(CategorySetup::class); + +/** @var Config $eavConfig */ +$eavConfig = $objectManager->get(Config::class); +$firstAttribute = $eavConfig->getAttribute(Product::ENTITY, 'test_configurable_first'); +$secondAttribute = $eavConfig->getAttribute(Product::ENTITY, 'test_configurable_second'); + +/** @var AttributeOptionInterface[] $firstAttributeOptions */ +$firstAttributeOptions = $firstAttribute->getOptions(); +/** @var AttributeOptionInterface[] $secondAttributeOptions */ +$secondAttributeOptions = $secondAttribute->getOptions(); + +array_shift($firstAttributeOptions); +array_shift($secondAttributeOptions); +foreach ($firstAttributeOptions as $i => $firstAttributeOption) { + foreach ($secondAttributeOptions as $j => $secondAttributeOption) { + /** @var ProductRepositoryInterface $productRepository */ + $productRepository = $objectManager->get(ProductRepositoryInterface::class); + try { + //delete child product + $sku = 'simple_' . $firstAttributeOption->getValue() . '_' . $secondAttributeOption->getValue(); + $product = $productRepository->get($sku, true); + $stockStatus = $objectManager->create(Status::class); + $stockStatus->load($product->getEntityId(), 'product_id'); + $stockStatus->delete(); + $productRepository->delete($product); + } catch (NoSuchEntityException $e) { + //Product already removed + } + } +} + +//delete configurable product +try { + $product = $productRepository->get('configurable_12345', true); + $stockStatus = $objectManager->create(Status::class); + $stockStatus->load($product->getEntityId(), 'product_id'); + $stockStatus->delete(); + + $productRepository->delete($product); +} catch (NoSuchEntityException $e) { + //Product already removed +} +Resolver::getInstance()->requireDataFixture( + 'Magento/ConfigurableProduct/_files/configurable_attribute_first_rollback.php' +); +Resolver::getInstance()->requireDataFixture( + 'Magento/ConfigurableProduct/_files/configurable_attribute_second_rollback.php' +); + +Resolver::getInstance()->requireDataFixture( + 'Magento/Catalog/_files/product_image_rollback.php' +); + +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', false); diff --git a/dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/product_configurable_with_custom_option_dropdown.php b/dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/product_configurable_with_custom_option_dropdown.php new file mode 100644 index 0000000000000..cc440eb4c474f --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/product_configurable_with_custom_option_dropdown.php @@ -0,0 +1,50 @@ +requireDataFixture('Magento/ConfigurableProduct/_files/product_configurable.php'); + +/** @var ObjectManagerInterface $objectManager */ +$objectManager = Bootstrap::getObjectManager(); +/** @var ProductCustomOptionInterfaceFactory $optionRepository */ +$optionRepository = $objectManager->get(ProductCustomOptionInterfaceFactory::class); +/** @var ProductRepositoryInterface $productRepository */ +$productRepository = Bootstrap::getObjectManager()->create(ProductRepositoryInterface::class); +$product = $productRepository->get('configurable'); +$dropdownOption = [ + 'previous_group' => 'select', + 'title' => 'Dropdown Options', + 'type' => 'drop_down', + 'is_require' => 1, + 'sort_order' => 0, + 'values' => [ + [ + 'option_type_id' => null, + 'title' => 'Option 1', + 'price' => '10.00', + 'price_type' => 'fixed', + 'sku' => 'opt1', + ], + [ + 'option_type_id' => null, + 'title' => 'Option 2', + 'price' => '20.00', + 'price_type' => 'fixed', + 'sku' => 'opt2', + ], + ] +]; + +$createdOption = $optionRepository->create(['data' => $dropdownOption]); +$createdOption->setProductSku($product->getSku()); +$product->setOptions([$createdOption]); +$productRepository->save($product); diff --git a/dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/product_configurable_with_custom_option_dropdown_rollback.php b/dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/product_configurable_with_custom_option_dropdown_rollback.php new file mode 100644 index 0000000000000..26e15b60dc695 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/product_configurable_with_custom_option_dropdown_rollback.php @@ -0,0 +1,10 @@ +requireDataFixture('Magento/ConfigurableProduct/_files/product_configurable_rollback.php'); diff --git a/dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/quote_with_configurable_product_last_variation.php b/dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/quote_with_configurable_product_last_variation.php index 072c0cd8f9118..1f0dee32ce4a2 100644 --- a/dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/quote_with_configurable_product_last_variation.php +++ b/dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/quote_with_configurable_product_last_variation.php @@ -14,11 +14,11 @@ $quote = $objectManager->create(\Magento\Quote\Model\Quote::class); /** @var ProductRepositoryInterface $productRepository */ $productRepository = $objectManager->create(ProductRepositoryInterface::class); -$product = $productRepository->getById(10); +$product = $productRepository->get('simple_10'); $product->setStockData(['use_config_manage_stock' => 1, 'qty' => 1, 'is_qty_decimal' => 0, 'is_in_stock' => 1]); $productRepository->save($product); -$product = $productRepository->getById(20); +$product = $productRepository->get('simple_20'); $product->setStockData(['use_config_manage_stock' => 1, 'qty' => 0, 'is_qty_decimal' => 0, 'is_in_stock' => 0]); $productRepository->save($product); diff --git a/dev/tests/integration/testsuite/Magento/Customer/Controller/Adminhtml/Index/InlineEditTest.php b/dev/tests/integration/testsuite/Magento/Customer/Controller/Adminhtml/Index/InlineEditTest.php index 3edfe1eab8aa5..1b8965711d77d 100644 --- a/dev/tests/integration/testsuite/Magento/Customer/Controller/Adminhtml/Index/InlineEditTest.php +++ b/dev/tests/integration/testsuite/Magento/Customer/Controller/Adminhtml/Index/InlineEditTest.php @@ -7,12 +7,15 @@ namespace Magento\Customer\Controller\Adminhtml\Index; +use Magento\Customer\Api\AddressRepositoryInterface; use Magento\Customer\Api\CustomerMetadataInterface; use Magento\Customer\Api\CustomerRepositoryInterface; use Magento\Customer\Api\Data\CustomerInterface; +use Magento\Customer\Observer\AfterAddressSaveObserver; use Magento\Eav\Model\AttributeRepository; use Magento\Framework\App\Request\Http as HttpRequest; use Magento\Framework\ObjectManagerInterface; +use Magento\Framework\Registry; use Magento\Framework\Serialize\SerializerInterface; use Magento\Store\Api\WebsiteRepositoryInterface; use Magento\TestFramework\Helper\Bootstrap; @@ -23,6 +26,7 @@ * * @magentoAppArea adminhtml * @magentoDbIsolation enabled + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class InlineEditTest extends AbstractBackendController { @@ -41,6 +45,12 @@ class InlineEditTest extends AbstractBackendController /** @var AttributeRepository */ private $attributeRepository; + /** @var AddressRepositoryInterface */ + private $addressRepository; + + /** @var Registry */ + private $coreRegistry; + /** * @inheritdoc */ @@ -53,6 +63,8 @@ protected function setUp(): void $this->json = $this->objectManager->get(SerializerInterface::class); $this->websiteRepository = $this->objectManager->get(WebsiteRepositoryInterface::class); $this->attributeRepository = $this->objectManager->get(AttributeRepository::class); + $this->addressRepository = $this->objectManager->get(AddressRepositoryInterface::class); + $this->coreRegistry = Bootstrap::getObjectManager()->get(Registry::class); } /** @@ -121,6 +133,53 @@ public function inlineEditParametersDataProvider(): array ]; } + /** + * Customer group should not change after saving customer via customer grid because of disabled address validation + * + * @magentoConfigFixture current_store customer/create_account/auto_group_assign 1 + * @magentoConfigFixture current_store customer/create_account/viv_invalid_group 2 + * @magentoDataFixture Magento/Customer/_files/customer_one_address.php + * + * @return void + */ + public function testInlineEditActionWithAddress(): void + { + $customer = $this->getCustomer(); + $params = [ + 'items' => [ + $customer->getId() => [] + ], + 'isAjax' => true, + ]; + $actual = $this->performInlineEditRequest($params); + $updatedCustomer = $this->customerRepository->get('customer_one_address@test.com'); + $this->assertEmpty($actual['messages']); + $this->assertFalse($actual['error']); + $this->assertEquals( + $customer->getGroupId(), + $updatedCustomer->getGroupId(), + 'Customer group was changed!' + ); + } + + /** + * Change customer address with setting country from EU and setting VAT number + * + * @return CustomerInterface + */ + private function getCustomer(): CustomerInterface + { + $customer = $this->customerRepository->get('customer_one_address@test.com'); + $address = $this->addressRepository->getById((int)$customer->getDefaultShipping()); + $address->setVatId(12345); + $address->setCountryId('DE'); + $address->setRegionId(0); + $this->addressRepository->save($address); + $this->coreRegistry->unregister(AfterAddressSaveObserver::VIV_PROCESSED_FLAG); + //return customer after address repository save + return $this->customerRepository->get('customer_one_address@test.com'); + } + /** * Perform inline edit request. * diff --git a/dev/tests/integration/testsuite/Magento/Customer/Controller/Adminhtml/Index/SaveTest.php b/dev/tests/integration/testsuite/Magento/Customer/Controller/Adminhtml/Index/SaveTest.php index 2ec87f758b812..33635d3678726 100644 --- a/dev/tests/integration/testsuite/Magento/Customer/Controller/Adminhtml/Index/SaveTest.php +++ b/dev/tests/integration/testsuite/Magento/Customer/Controller/Adminhtml/Index/SaveTest.php @@ -8,12 +8,16 @@ namespace Magento\Customer\Controller\Adminhtml\Index; use Magento\Backend\Model\Session; +use Magento\Customer\Api\CustomerMetadataInterface; use Magento\Customer\Api\CustomerNameGenerationInterface; use Magento\Customer\Api\CustomerRepositoryInterface; +use Magento\Customer\Api\Data\CustomerInterface; use Magento\Customer\Model\Data\Customer as CustomerData; use Magento\Customer\Model\EmailNotification; +use Magento\Eav\Api\AttributeRepositoryInterface; use Magento\Framework\App\Area; use Magento\Framework\App\Request\Http as HttpRequest; +use Magento\Framework\Locale\ResolverInterface; use Magento\Framework\Mail\Template\TransportBuilder; use Magento\Framework\Mail\TransportInterface; use Magento\Framework\Message\MessageInterface; @@ -21,6 +25,7 @@ use Magento\Newsletter\Model\SubscriberFactory; use Magento\Store\Model\Store; use Magento\Store\Model\StoreManagerInterface; +use Magento\TestFramework\Helper\Bootstrap; use Magento\TestFramework\TestCase\AbstractBackendController; use PHPUnit\Framework\MockObject\MockObject; @@ -54,6 +59,12 @@ class SaveTest extends AbstractBackendController /** @var StoreManagerInterface */ private $storeManager; + /** @var ResolverInterface */ + private $localeResolver; + + /** @var CustomerInterface */ + private $customer; + /** * @inheritdoc */ @@ -65,6 +76,19 @@ protected function setUp(): void $this->subscriberFactory = $this->_objectManager->get(SubscriberFactory::class); $this->session = $this->_objectManager->get(Session::class); $this->storeManager = $this->_objectManager->get(StoreManagerInterface::class); + $this->localeResolver = $this->_objectManager->get(ResolverInterface::class); + } + + /** + * @inheritdoc + */ + protected function tearDown(): void + { + if ($this->customer instanceof CustomerInterface) { + $this->customerRepository->delete($this->customer); + } + + parent::tearDown(); } /** @@ -418,6 +442,42 @@ public function testCreateSameEmailFormatDateError(): void $this->assertRedirect($this->stringContains($this->baseControllerUrl . 'new/key/')); } + /** + * @return void + */ + public function testCreateCustomerByAdminWithLocaleGB(): void + { + $this->localeResolver->setLocale('en_GB'); + $postData = array_replace_recursive( + $this->getDefaultCustomerData(), + [ + 'customer' => [ + CustomerData::DOB => '24/10/1990', + ], + ] + ); + $expectedData = array_replace_recursive( + $postData, + [ + 'customer' => [ + CustomerData::DOB => '1990-10-24', + ], + ] + ); + unset($expectedData['customer']['sendemail_store_id']); + $this->dispatchCustomerSave($postData); + $this->assertSessionMessages( + $this->containsEqual((string)__('You saved the customer.')), + MessageInterface::TYPE_SUCCESS + ); + $this->assertRedirect($this->stringContains($this->baseControllerUrl . 'index/key/')); + $this->assertCustomerData( + $postData['customer'][CustomerData::EMAIL], + (int)$postData['customer'][CustomerData::WEBSITE_ID], + $expectedData + ); + } + /** * Default values for customer creation * @@ -438,7 +498,8 @@ private function getDefaultCustomerData(): array CustomerData::EMAIL => 'janedoe' . uniqid() . '@example.com', CustomerData::DOB => '01/01/2000', CustomerData::TAXVAT => '121212', - CustomerData::GENDER => 'Male', + CustomerData::GENDER => Bootstrap::getObjectManager()->get(AttributeRepositoryInterface::class) + ->get(CustomerMetadataInterface::ENTITY_TYPE_CUSTOMER, 'gender')->getSource()->getOptionId('Male'), 'sendemail_store_id' => '1', ] ]; @@ -458,7 +519,6 @@ private function getExpectedCustomerData(array $defaultCustomerData): array [ 'customer' => [ CustomerData::DOB => '2000-01-01', - CustomerData::GENDER => '0', CustomerData::STORE_ID => 1, CustomerData::CREATED_IN => 'Default Store View', ], @@ -496,9 +556,8 @@ private function assertCustomerData( int $customerWebsiteId, array $expectedData ): void { - /** @var CustomerData $customerData */ - $customerData = $this->customerRepository->get($customerEmail, $customerWebsiteId); - $actualCustomerArray = $customerData->__toArray(); + $this->customer = $this->customerRepository->get($customerEmail, $customerWebsiteId); + $actualCustomerArray = $this->customer->__toArray(); foreach ($expectedData['customer'] as $key => $expectedValue) { $this->assertEquals( $expectedValue, diff --git a/dev/tests/integration/testsuite/Magento/Customer/Controller/Adminhtml/IndexTest.php b/dev/tests/integration/testsuite/Magento/Customer/Controller/Adminhtml/IndexTest.php index 40c84d8b5db58..e70056be69ba7 100644 --- a/dev/tests/integration/testsuite/Magento/Customer/Controller/Adminhtml/IndexTest.php +++ b/dev/tests/integration/testsuite/Magento/Customer/Controller/Adminhtml/IndexTest.php @@ -191,6 +191,32 @@ public function testResetPasswordActionSuccess() $this->assertRedirect($this->stringContains($this->baseControllerUrl . 'edit')); } + /** + * @magentoDataFixture Magento/Customer/_files/customer.php + */ + public function testAclDeleteActionAllow() + { + $this->getRequest()->setParam('id', 1); + $this->dispatch('backend/customer/index/edit'); + $body = $this->getResponse()->getBody(); + $this->assertStringContainsString('Delete Customer', $body); + } + + /** + * @magentoDataFixture Magento/Customer/_files/customer.php + */ + public function testAclDeleteActionDeny() + { + $resource= 'Magento_Customer::delete'; + $this->_objectManager->get(\Magento\Framework\Acl\Builder::class) + ->getAcl() + ->deny(null, $resource); + $this->getRequest()->setParam('id', 1); + $this->dispatch('backend/customer/index/edit'); + $body = $this->getResponse()->getBody(); + $this->assertStringNotContainsString('Delete Customer', $body); + } + /** * Prepare email mock to test emails. * diff --git a/dev/tests/integration/testsuite/Magento/Customer/Model/AccountManagement/CreateAccountWithAddressTest.php b/dev/tests/integration/testsuite/Magento/Customer/Model/AccountManagement/CreateAccountWithAddressTest.php new file mode 100644 index 0000000000000..351c84680389b --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Customer/Model/AccountManagement/CreateAccountWithAddressTest.php @@ -0,0 +1,113 @@ +objectManager = Bootstrap::getObjectManager(); + $this->accountManagement = $this->objectManager->get(AccountManagementInterface::class); + $this->customerFactory = $this->objectManager->get(CustomerInterfaceFactory::class); + $this->customerRepository = $this->objectManager->get(CustomerRepositoryInterface::class); + $this->addressFactory = $this->objectManager->get(AddressInterfaceFactory::class); + $this->registry = $this->objectManager->get(Registry::class); + } + + /** + * @inheritdoc + */ + protected function tearDown(): void + { + if ($this->customer instanceof CustomerInterface) { + $this->registry->unregister('isSecureArea'); + $this->registry->register('isSecureArea', true); + $this->customerRepository->delete($this->customer); + $this->registry->unregister('isSecureArea'); + $this->registry->register('isSecureArea', false); + } + + parent::tearDown(); + } + + /** + * @magentoDataFixture Magento/Store/_files/second_website_with_store_group_and_store.php + * @magentoConfigFixture default_store general/country/allow BD,BB,AF + * @magentoConfigFixture fixture_second_store_store general/country/allow AS,BM + * @return void + */ + public function testCreateNewCustomerWithAddress(): void + { + $availableCountry = 'BD'; + $address = $this->addressFactory->create(); + $address->setCountryId($availableCountry) + ->setPostcode('75477') + ->setRegionId(1) + ->setStreet(['Green str, 67']) + ->setTelephone('3468676') + ->setCity('CityM') + ->setFirstname('John') + ->setLastname('Smith') + ->setIsDefaultShipping(true) + ->setIsDefaultBilling(true); + $customerEntity = $this->customerFactory->create(); + $customerEntity->setEmail('test@example.com') + ->setFirstname('John') + ->setLastname('Smith') + ->setStoreId(1); + $customerEntity->setAddresses([$address]); + $this->customer = $this->accountManagement->createAccount($customerEntity); + $this->assertCount(1, $this->customer->getAddresses(), 'The available address wasn\'t saved.'); + $this->assertSame( + $availableCountry, + $this->customer->getAddresses()[0]->getCountryId(), + 'The address was saved with disallowed country.' + ); + } +} diff --git a/dev/tests/integration/testsuite/Magento/Customer/Model/AccountManagementTest.php b/dev/tests/integration/testsuite/Magento/Customer/Model/AccountManagementTest.php index 6f2cf2d76bd11..3a16d3eafd6ce 100644 --- a/dev/tests/integration/testsuite/Magento/Customer/Model/AccountManagementTest.php +++ b/dev/tests/integration/testsuite/Magento/Customer/Model/AccountManagementTest.php @@ -14,6 +14,7 @@ use Magento\Framework\Exception\State\ExpiredException; use Magento\Framework\Reflection\DataObjectProcessor; use Magento\Framework\Session\SessionManagerInterface; +use Magento\Framework\Stdlib\DateTime; use Magento\Framework\Url as UrlBuilder; use Magento\Store\Model\StoreManagerInterface; use Magento\TestFramework\Helper\Bootstrap; @@ -113,6 +114,10 @@ protected function tearDown(): void $customerRegistry->remove(1); $addressRegistry->remove(1); $addressRegistry->remove(2); + /** @var \Magento\Customer\Model\ResourceModel\Visitor $resourceModel */ + $resourceModel = $this->objectManager->get(\Magento\Customer\Model\ResourceModel\Visitor::class); + $resourceModel->getConnection()->delete($resourceModel->getMainTable()); + parent::tearDown(); } /** @@ -158,19 +163,52 @@ public function testChangePassword() { /** @var SessionManagerInterface $session */ $session = $this->objectManager->get(SessionManagerInterface::class); - $oldSessionId = $session->getSessionId(); - $session->setTestData('test'); + $time = time(); + + $session->start(); + $guessSessionId = $session->getSessionId(); + $this->createVisitorSession($guessSessionId); + $session->setTestData('guest_session_data'); + + // open new session + $activeSessionId = uniqid("active-$time-"); + $this->startNewSession($activeSessionId); + $this->createVisitorSession($activeSessionId, 1); + $session->setTestData('customer_session_data_1'); + + // open new session + $currentSessionId = uniqid("current-$time-"); + $this->startNewSession($currentSessionId); + $this->createVisitorSession($currentSessionId, 1); + $session->setTestData('customer_session_data_current'); + + // change password $this->accountManagement->changePassword('customer@example.com', 'password', 'new_Password123'); - - $this->assertTrue( - $oldSessionId !== $session->getSessionId(), - 'Customer session id wasn\'t regenerated after change password' + $this->assertEquals( + $currentSessionId, + $session->getSessionId(), + 'Current session was renewed' ); - $session->destroy(); - $session->setSessionId($oldSessionId); + // open customer active session + $this->startNewSession($activeSessionId); + $this->assertNull($session->getTestData(), 'Customer active session data wasn\'t cleaned up'); + + // open customer current session + $this->startNewSession($currentSessionId); + $this->assertEquals( + 'customer_session_data_current', + $session->getTestData(), + 'Customer current session data was cleaned up' + ); - $this->assertNull($session->getTestData(), 'Customer session data wasn\'t cleaned'); + // open guess session + $this->startNewSession($guessSessionId); + $this->assertEquals( + 'guest_session_data', + $session->getTestData(), + 'Guest session data was cleaned up' + ); $this->accountManagement->authenticate('customer@example.com', 'new_Password123'); } @@ -392,11 +430,58 @@ public function testValidateResetPasswordLinkTokenAmbiguous() */ public function testResetPassword() { + /** @var SessionManagerInterface $session */ + $session = $this->objectManager->get(SessionManagerInterface::class); + $time = time(); + + $session->start(); + $guessSessionId = $session->getSessionId(); + $this->createVisitorSession($guessSessionId); + $session->setTestData('guest_session_data'); + + // open new session + $activeSessionId = uniqid("active-$time-"); + $this->startNewSession($activeSessionId); + $this->createVisitorSession($activeSessionId, 1); + $session->setTestData('customer_session_data_1'); + + // open new session + $currentSessionId = uniqid("current-$time-"); + $this->startNewSession($currentSessionId); + $this->createVisitorSession($currentSessionId, 1); + $session->setTestData('customer_session_data_current'); + $resetToken = 'lsdj579slkj5987slkj595lkj'; $password = 'new_Password123'; $this->setResetPasswordData($resetToken, 'Y-m-d H:i:s'); $this->assertTrue($this->accountManagement->resetPassword('customer@example.com', $resetToken, $password)); + + $this->assertEquals( + $currentSessionId, + $session->getSessionId(), + 'Current session was renewed' + ); + + // open customer active session + $this->startNewSession($activeSessionId); + $this->assertNull($session->getTestData(), 'Customer active session data wasn\'t cleaned up'); + + // open customer current session + $this->startNewSession($currentSessionId); + $this->assertEquals( + 'customer_session_data_current', + $session->getTestData(), + 'Customer current session data was cleaned up' + ); + + // open guess session + $this->startNewSession($guessSessionId); + $this->assertEquals( + 'guest_session_data', + $session->getTestData(), + 'Guest session data was cleaned up' + ); } /** @@ -727,4 +812,35 @@ protected function setResetPasswordData( $customerModel->setRpTokenCreatedAt(date($date)); $customerModel->save(); } + + /** + * @param string $sessionId + */ + private function startNewSession(string $sessionId): void + { + /** @var SessionManagerInterface $session */ + $session = $this->objectManager->get(SessionManagerInterface::class); + // close session and cleanup session variable + $session->writeClose(); + $session->clearStorage(); + // open new session + $session->setSessionId($sessionId); + $session->start(); + } + + /** + * @param string $sessionId + * @param int|null $customerId + * @return Visitor + */ + private function createVisitorSession(string $sessionId, ?int $customerId = null): Visitor + { + /** @var Visitor $visitor */ + $visitor = Bootstrap::getObjectManager()->create(Visitor::class); + $visitor->setCustomerId($customerId); + $visitor->setSessionId($sessionId); + $visitor->setLastVisitAt((new \DateTime())->format(DateTime::DATETIME_PHP_FORMAT)); + $visitor->save(); + return $visitor; + } } diff --git a/dev/tests/integration/testsuite/Magento/Customer/Model/Address/CreateAddressTest.php b/dev/tests/integration/testsuite/Magento/Customer/Model/Address/CreateAddressTest.php index eb638eeb329aa..79f8b1466d8d3 100644 --- a/dev/tests/integration/testsuite/Magento/Customer/Model/Address/CreateAddressTest.php +++ b/dev/tests/integration/testsuite/Magento/Customer/Model/Address/CreateAddressTest.php @@ -424,6 +424,23 @@ public function testAddressCreatedWithGroupAssignByVatIdWithError(): void $this->assertEquals(2, $this->getCustomerGroupId('customer5@example.com')); } + /** + * @magentoDataFixture Magento/Customer/_files/customer_no_address.php + * @magentoDataFixture Magento/Store/_files/second_website_with_store_group_and_store.php + * @magentoConfigFixture default_store general/country/allow BD,BB,AF + * @magentoConfigFixture fixture_second_store_store general/country/allow AS,BM + * + * @return void + */ + public function testCreateAvailableAddress(): void + { + $countryId = 'BB'; + $addressData = array_merge(self::STATIC_CUSTOMER_ADDRESS_DATA, [AddressInterface::COUNTRY_ID => $countryId]); + $customer = $this->customerRepository->get('customer5@example.com'); + $address = $this->createAddress((int)$customer->getId(), $addressData); + $this->assertSame($countryId, $address->getCountryId()); + } + /** * Create customer address with provided address data. * diff --git a/dev/tests/integration/testsuite/Magento/Customer/Model/Config/Source/Group/MultiselectTest.php b/dev/tests/integration/testsuite/Magento/Customer/Model/Config/Source/Group/MultiselectTest.php index 962933f026d9e..9fc9a10df27c4 100644 --- a/dev/tests/integration/testsuite/Magento/Customer/Model/Config/Source/Group/MultiselectTest.php +++ b/dev/tests/integration/testsuite/Magento/Customer/Model/Config/Source/Group/MultiselectTest.php @@ -23,12 +23,12 @@ public function testToOptionArray() $optionsToCompare = []; foreach ($options as $option) { if (is_array($option['value'])) { - // phpcs:ignore Magento2.Performance.ForeachArrayMerge - $optionsToCompare = array_merge($optionsToCompare, $option['value']); + $optionsToCompare[] = $option['value']; } else { - $optionsToCompare[] = $option; + $optionsToCompare[] = [$option]; } } + $optionsToCompare = array_merge([], ...$optionsToCompare); sort($optionsToCompare); foreach ($optionsToCompare as $item) { $this->assertTrue( diff --git a/dev/tests/integration/testsuite/Magento/Customer/Model/ForgotPasswordToken/ConfirmCustomerByTokenTest.php b/dev/tests/integration/testsuite/Magento/Customer/Model/ForgotPasswordToken/ConfirmCustomerByTokenTest.php new file mode 100644 index 0000000000000..5399f6903ee9f --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Customer/Model/ForgotPasswordToken/ConfirmCustomerByTokenTest.php @@ -0,0 +1,92 @@ +objectManager = Bootstrap::getObjectManager(); + + $resource = $this->objectManager->get(ResourceConnection::class); + $this->connection = $resource->getConnection(); + + $this->confirmCustomerByToken = $this->objectManager->get(ConfirmCustomerByToken::class); + } + + /** + * Customer address shouldn't validate during confirm customer by token + * + * @magentoDataFixture Magento/Customer/_files/customer.php + * @magentoDataFixture Magento/Customer/_files/customer_address.php + * + * @return void + */ + public function testExecuteWithInvalidAddress(): void + { + $id = 1; + + $customerModel = $this->objectManager->create(Customer::class); + $customerModel->load($id); + $customerModel->setRpToken(self::STUB_CUSTOMER_RESET_TOKEN); + $customerModel->setRpTokenCreatedAt(date('Y-m-d H:i:s')); + $customerModel->setConfirmation($customerModel->getRandomConfirmationKey()); + $customerModel->save(); + + //make city address invalid + $this->makeCityInvalid($id); + + $this->confirmCustomerByToken->execute(self::STUB_CUSTOMER_RESET_TOKEN); + $this->assertNull($customerModel->load($id)->getConfirmation()); + } + + /** + * Set city invalid for customer address + * + * @param int $id + * @return void + */ + private function makeCityInvalid(int $id): void + { + $this->connection->update( + $this->connection->getTableName('customer_address_entity'), + ['city' => ''], + $this->connection->quoteInto('entity_id = ?', $id) + ); + } +} diff --git a/dev/tests/integration/testsuite/Magento/Customer/_files/customer_for_second_website_rollback.php b/dev/tests/integration/testsuite/Magento/Customer/_files/customer_for_second_website_rollback.php index 4d8c524cf8f5f..b93e9e68dfc2c 100644 --- a/dev/tests/integration/testsuite/Magento/Customer/_files/customer_for_second_website_rollback.php +++ b/dev/tests/integration/testsuite/Magento/Customer/_files/customer_for_second_website_rollback.php @@ -21,16 +21,16 @@ $customerRepository = $objectManager->get(CustomerRepositoryInterface::class); /** @var WebsiteRepositoryInterface $websiteRepository */ $websiteRepository = $objectManager->get(WebsiteRepositoryInterface::class); -$websiteId = $websiteRepository->get('test')->getId(); $registry->unregister('isSecureArea'); $registry->register('isSecureArea', true); try { + $websiteId = $websiteRepository->get('test')->getId(); $customer = $customerRepository->get('customer@example.com', $websiteId); $customerRepository->delete($customer); } catch (NoSuchEntityException $e) { - //customer already deleted + //customer or website already deleted } $registry->unregister('isSecureArea'); diff --git a/dev/tests/integration/testsuite/Magento/Customer/_files/customer_for_second_website_with_address.php b/dev/tests/integration/testsuite/Magento/Customer/_files/customer_for_second_website_with_address.php new file mode 100644 index 0000000000000..1c2782dbe675b --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Customer/_files/customer_for_second_website_with_address.php @@ -0,0 +1,49 @@ +requireDataFixture('Magento/Store/_files/second_website_with_two_stores.php'); + +$objectManager = Bootstrap::getObjectManager(); +/** @var WebsiteRepositoryInterface $websiteRepository */ +$websiteRepository = $objectManager->get(WebsiteRepositoryInterface::class); +$websiteId = $websiteRepository->get('test')->getId(); +/** @var StoreManagerInterface $storeManager */ +$storeManager = $objectManager->get(StoreManagerInterface::class); +$store = $storeManager->getStore('fixture_third_store'); +/** @var AccountManagementInterface $accountManagment */ +$accountManagment = $objectManager->get(AccountManagementInterface::class); +/** @var CustomerFactory $customerFactory */ +$customerFactory = $objectManager->get(CustomerFactory::class); +/** @var AttributeRepository $attributeRepository */ +$attributeRepository = $objectManager->get(AttributeRepository::class); +$gender = $attributeRepository->get(CustomerMetadataInterface::ENTITY_TYPE_CUSTOMER, CustomerInterface::GENDER) + ->getSource()->getOptionId('Male'); +$defaultGroupId = $objectManager->get(GroupManagement::class)->getDefaultGroup($store->getStoreId())->getId(); + +$customer = $customerFactory->create(); +$customer->setWebsiteId($websiteId) + ->setEmail('customer_second_ws_with_addr@example.com') + ->setGroupId($defaultGroupId) + ->setStoreId($store->getStoreId()) + ->setFirstname('John') + ->setLastname('Smith') + ->setDefaultBilling(1) + ->setDefaultShipping(1) + ->setGender($gender); + +$accountManagment->createAccount($customer, 'Apassword1'); diff --git a/dev/tests/integration/testsuite/Magento/Customer/_files/customer_for_second_website_with_address_rollback.php b/dev/tests/integration/testsuite/Magento/Customer/_files/customer_for_second_website_with_address_rollback.php new file mode 100644 index 0000000000000..48e6f56d83442 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Customer/_files/customer_for_second_website_with_address_rollback.php @@ -0,0 +1,37 @@ +get(Registry::class); +/** @var CustomerRepositoryInterface $customerRepository */ +$customerRepository = $objectManager->get(CustomerRepositoryInterface::class); +/** @var WebsiteRepositoryInterface $websiteRepository */ +$websiteRepository = $objectManager->get(WebsiteRepositoryInterface::class); +$websiteId = $websiteRepository->get('test')->getId(); + +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', true); + +try { + $customer = $customerRepository->get('customer_second_ws_with_addr@example.com', $websiteId); + $customerRepository->delete($customer); +} catch (NoSuchEntityException $e) { + //customer already deleted +} + +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', false); + +Resolver::getInstance()->requireDataFixture('Magento/Store/_files/second_website_with_two_stores_rollback.php'); diff --git a/dev/tests/integration/testsuite/Magento/CustomerImportExport/Model/Import/AddressTest.php b/dev/tests/integration/testsuite/Magento/CustomerImportExport/Model/Import/AddressTest.php index 0a5e6cdfe21bd..6aa5589ba7799 100644 --- a/dev/tests/integration/testsuite/Magento/CustomerImportExport/Model/Import/AddressTest.php +++ b/dev/tests/integration/testsuite/Magento/CustomerImportExport/Model/Import/AddressTest.php @@ -329,13 +329,13 @@ public function testImportDataAddUpdate() // form attribute list $keyAttribute = 'postcode'; - $requiredAttributes[] = $keyAttribute; + $requiredAttributes[] = [$keyAttribute]; foreach (['update', 'remove'] as $action) { foreach ($this->_updateData[$action] as $attributes) { - // phpcs:ignore Magento2.Performance.ForeachArrayMerge - $requiredAttributes = array_merge($requiredAttributes, array_keys($attributes)); + $requiredAttributes[] = array_keys($attributes); } } + $requiredAttributes = array_merge([], ...$requiredAttributes); // get addresses $addressCollection = Bootstrap::getObjectManager()->create( diff --git a/dev/tests/integration/testsuite/Magento/Developer/Model/Logger/Handler/DebugTest.php b/dev/tests/integration/testsuite/Magento/Developer/Model/Logger/Handler/DebugTest.php index 6190742cced14..1548a8d30eeab 100644 --- a/dev/tests/integration/testsuite/Magento/Developer/Model/Logger/Handler/DebugTest.php +++ b/dev/tests/integration/testsuite/Magento/Developer/Model/Logger/Handler/DebugTest.php @@ -16,9 +16,11 @@ use Magento\Setup\Mvc\Bootstrap\InitParamListener; use Magento\TestFramework\Helper\Bootstrap; use Magento\TestFramework\ObjectManager; +use Psr\Log\LoggerInterface; /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + * @magentoAppIsolation enabled */ class DebugTest extends \PHPUnit\Framework\TestCase { @@ -76,7 +78,7 @@ protected function setUp(): void { $this->objectManager = Bootstrap::getObjectManager(); $this->shell = $this->objectManager->get(Shell::class); - $this->logger = $this->objectManager->get(Monolog::class); + $this->logger = $this->objectManager->get(LoggerInterface::class); $this->deploymentConfig = $this->objectManager->get(DeploymentConfig::class); /** @var Filesystem $filesystem */ diff --git a/dev/tests/integration/testsuite/Magento/Directory/Model/Country/Postcode/Config/ReaderTest.php b/dev/tests/integration/testsuite/Magento/Directory/Model/Country/Postcode/Config/ReaderTest.php index 740afcda11386..87dfd2a4a3981 100644 --- a/dev/tests/integration/testsuite/Magento/Directory/Model/Country/Postcode/Config/ReaderTest.php +++ b/dev/tests/integration/testsuite/Magento/Directory/Model/Country/Postcode/Config/ReaderTest.php @@ -6,18 +6,20 @@ namespace Magento\Directory\Model\Country\Postcode\Config; -class ReaderTest extends \PHPUnit\Framework\TestCase +use Magento\TestFramework\Helper\Bootstrap; +use PHPUnit\Framework\TestCase; + +class ReaderTest extends TestCase { /** - * @var \Magento\Directory\Model\Country\Postcode\Config\Reader + * @var Reader */ private $reader; protected function setUp(): void { - $this->reader = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Directory\Model\Country\Postcode\Config\Reader::class - ); + $this->reader = Bootstrap::getObjectManager() + ->create(Reader::class); } public function testRead() @@ -39,5 +41,21 @@ public function testRead() $this->assertEquals('test1', $result['NL_NEW']['pattern_1']['example']); $this->assertEquals('^[0-2]{4}[A-Z]{2}$', $result['NL_NEW']['pattern_1']['pattern']); + + $this->assertArrayHasKey('AR', $result); + $this->assertArrayHasKey('pattern_1', $result['AR']); + $this->assertArrayHasKey('pattern_2', $result['AR']); + $this->assertEquals('1234', $result['AR']['pattern_1']['example']); + $this->assertEquals('^[0-9]{4}$', $result['AR']['pattern_1']['pattern']); + $this->assertEquals('A1234BCD', $result['AR']['pattern_2']['example']); + $this->assertEquals('^[a-zA-z]{1}[0-9]{4}[a-zA-z]{3}$', $result['AR']['pattern_2']['pattern']); + + $this->assertArrayHasKey('KR', $result); + $this->assertArrayHasKey('pattern_1', $result['KR']); + $this->assertArrayHasKey('pattern_2', $result['KR']); + $this->assertEquals('123-456', $result['KR']['pattern_1']['example']); + $this->assertEquals('^[0-9]{3}-[0-9]{3}$', $result['KR']['pattern_1']['pattern']); + $this->assertEquals('12345', $result['KR']['pattern_2']['example']); + $this->assertEquals('^[0-9]{5}$', $result['KR']['pattern_2']['pattern']); } } diff --git a/dev/tests/integration/testsuite/Magento/Directory/Model/Country/Postcode/ValidatorTest.php b/dev/tests/integration/testsuite/Magento/Directory/Model/Country/Postcode/ValidatorTest.php index 74db33398f721..2a4efd766b7c6 100644 --- a/dev/tests/integration/testsuite/Magento/Directory/Model/Country/Postcode/ValidatorTest.php +++ b/dev/tests/integration/testsuite/Magento/Directory/Model/Country/Postcode/ValidatorTest.php @@ -104,7 +104,7 @@ public function getPostcodesDataProvider() ['countryId' => 'BY', 'postcode' => '123456'], ['countryId' => 'BE', 'postcode' => '1234'], ['countryId' => 'BA', 'postcode' => '12345'], - ['countryId' => 'BR', 'postcode' => '12345'], + ['countryId' => 'BR', 'postcode' => '12345678'], ['countryId' => 'BR', 'postcode' => '12345-678'], ['countryId' => 'BN', 'postcode' => 'PS1234'], ['countryId' => 'BG', 'postcode' => '1234'], diff --git a/dev/tests/integration/testsuite/Magento/Downloadable/_files/customer_order_with_downloadable_product.php b/dev/tests/integration/testsuite/Magento/Downloadable/_files/customer_order_with_downloadable_product.php index 74c52d4642a77..c9abb31dd547c 100644 --- a/dev/tests/integration/testsuite/Magento/Downloadable/_files/customer_order_with_downloadable_product.php +++ b/dev/tests/integration/testsuite/Magento/Downloadable/_files/customer_order_with_downloadable_product.php @@ -43,8 +43,11 @@ $orderItem->setProductId(1) ->setProductType(\Magento\Downloadable\Model\Product\Type::TYPE_DOWNLOADABLE) + ->setName('Downloadable Product') ->setProductOptions(['links' => [$link->getId()]]) ->setBasePrice(100) + ->setPrice(10) + ->setSku('downloadable-product') ->setQtyOrdered(1); $order = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create(\Magento\Sales\Model\Order::class); diff --git a/dev/tests/integration/testsuite/Magento/Downloadable/_files/customer_order_with_invoice_downloadable_product.php b/dev/tests/integration/testsuite/Magento/Downloadable/_files/customer_order_with_invoice_downloadable_product.php new file mode 100644 index 0000000000000..5dea9a6c40754 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Downloadable/_files/customer_order_with_invoice_downloadable_product.php @@ -0,0 +1,35 @@ +get(OrderInterfaceFactory::class)->create()->loadByIncrementId('100000001'); + +$payment = $objectManager->create(Payment::class); +$payment->setMethod('checkmo'); +$order->setPayment($payment); +$order->save(); + +/** @var InvoiceManagementInterface $orderService */ +$orderService = $objectManager->create( + InvoiceManagementInterface::class +); +$invoice = $orderService->prepareInvoice($order); +$invoice->register(); +$order = $invoice->getOrder(); +$order->setIsInProcess(true); +$transactionSave = $objectManager + ->create(Transaction::class); +$transactionSave->addObject($invoice)->addObject($order)->save(); diff --git a/dev/tests/integration/testsuite/Magento/Downloadable/_files/customer_order_with_invoice_downloadable_product_rollback.php b/dev/tests/integration/testsuite/Magento/Downloadable/_files/customer_order_with_invoice_downloadable_product_rollback.php new file mode 100644 index 0000000000000..aba4c3c972955 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Downloadable/_files/customer_order_with_invoice_downloadable_product_rollback.php @@ -0,0 +1,9 @@ +requireDataFixture('Magento/Sales/_files/default_rollback.php'); diff --git a/dev/tests/integration/testsuite/Magento/Downloadable/_files/order_with_customer_and_downloadable_product_with_multiple_links.php b/dev/tests/integration/testsuite/Magento/Downloadable/_files/order_with_customer_and_downloadable_product_with_multiple_links.php new file mode 100644 index 0000000000000..dd048b976afb0 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Downloadable/_files/order_with_customer_and_downloadable_product_with_multiple_links.php @@ -0,0 +1,90 @@ +requireDataFixture('Magento/Downloadable/_files/product_downloadable_with_purchased_separately_links.php'); +Resolver::getInstance() + ->requireDataFixture('Magento/Customer/_files/customer.php'); + +$addressData = include __DIR__ . '/../../../Magento/Sales/_files/address_data.php'; +$objectManager = Bootstrap::getObjectManager(); +/** @var CustomerRegistry $customerRegistry */ +$customerRegistry = $objectManager->create(CustomerRegistry::class); +$customer = $customerRegistry->retrieve(1); +/** @var ProductRepositoryInterface $productRepository */ +$productRepository = $objectManager->create(ProductRepositoryInterface::class); +/** @var AddressFactory $addressFactory */ +$addressFactory = $objectManager->get(AddressFactory::class); +$billingAddress = $addressFactory->create(['data' => $addressData]); +$billingAddress->setAddressType(Address::TYPE_BILLING); +/** @var ItemFactory $orderItemFactory */ +$orderItemFactory = $objectManager->get(ItemFactory::class); +/** @var PaymentFactory $orderPaymentFactory */ +$orderPaymentFactory = $objectManager->get(PaymentFactory::class); +/** @var StoreManagerInterface $storeManager */ +$storeManager = $objectManager->get(StoreManagerInterface::class); +/** @var OrderRepositoryInterface $orderRepository */ +$orderRepository = $objectManager->create(OrderRepositoryInterface::class); +/** @var OrderFactory $orderFactory */ +$orderFactory = $objectManager->get(OrderFactory::class); + +$payment = $orderPaymentFactory->create(); +$payment->setMethod('checkmo') + ->setAdditionalInformation('last_trans_id', '11122') + ->setAdditionalInformation( + 'metadata', + ['type' => 'free', 'fraudulent' => false] + ); +/** @var ProductInterface $product */ +$product = $productRepository->get('downloadable-product-with-purchased-separately-links'); +/** @var LinkInterface $links */ +$links = $product->getExtensionAttributes()->getDownloadableProductLinks(); +$link = reset($links); + +$orderItem = $orderItemFactory->create(); +$orderItem->setProductId($product->getId()) + ->setQtyOrdered(1) + ->setBasePrice($product->getPrice()) + ->setProductOptions(['links' => [$link->getId()]]) + ->setPrice($product->getPrice()) + ->setRowTotal($product->getPrice()) + ->setProductType(Type::TYPE_DOWNLOADABLE) + ->setName($product->getName()) + ->setSku($product->getSku()); + +$order = $orderFactory->create(); +$order->setIncrementId('100000002') + ->setState(Order::STATE_PROCESSING) + ->setStatus($order->getConfig()->getStateDefaultStatus(Order::STATE_PROCESSING)) + ->setSubtotal(100) + ->setGrandTotal(100) + ->setBaseSubtotal(100) + ->setBaseGrandTotal(100) + ->setCustomerId($customer->getId()) + ->setCustomerEmail($customer->getEmail()) + ->setBillingAddress($billingAddress) + ->setStoreId($storeManager->getStore()->getId()) + ->addItem($orderItem) + ->setPayment($payment); + +$orderRepository->save($order); diff --git a/dev/tests/integration/testsuite/Magento/Downloadable/_files/order_with_customer_and_downloadable_product_with_multiple_links_rollback.php b/dev/tests/integration/testsuite/Magento/Downloadable/_files/order_with_customer_and_downloadable_product_with_multiple_links_rollback.php new file mode 100644 index 0000000000000..4a28435ceb2d2 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Downloadable/_files/order_with_customer_and_downloadable_product_with_multiple_links_rollback.php @@ -0,0 +1,41 @@ +requireDataFixture( + 'Magento/Downloadable/_files/product_downloadable_with_purchased_separately_links_rollback.php' +); +Resolver::getInstance()->requireDataFixture('Magento/Customer/_files/customer_rollback.php'); + +$objectManager = Bootstrap::getObjectManager(); +/** @var Registry $registry */ +$registry = $objectManager->get(Registry::class); +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', true); + +/** @var RemoveLinkPurchasedByOrderIncrementId $removeLinkPurchasedByOrderIncrementId */ +$removeLinkPurchasedByOrderIncrementId = $objectManager->get(RemoveLinkPurchasedByOrderIncrementId::class); +/** @var OrderRepositoryInterface $orderRepository */ +$orderRepository = $objectManager->get(OrderRepositoryInterface::class); +$orderIncrementIdToDelete = '100000002'; +$removeLinkPurchasedByOrderIncrementId->execute($orderIncrementIdToDelete); +/** @var OrderFactory $order */ +$order = $objectManager->get(OrderFactory::class)->create(); +$order->loadByIncrementId($orderIncrementIdToDelete); + +if ($order->getId()) { + $orderRepository->delete($order); +} + +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', false); diff --git a/dev/tests/integration/testsuite/Magento/Elasticsearch/Model/CatalogSearch/AdvancedTest.php b/dev/tests/integration/testsuite/Magento/Elasticsearch/Model/CatalogSearch/AdvancedTest.php new file mode 100644 index 0000000000000..815f480cf7b53 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Elasticsearch/Model/CatalogSearch/AdvancedTest.php @@ -0,0 +1,101 @@ +objectManager = Bootstrap::getObjectManager(); + $this->registry = $this->objectManager->get(Registry::class); + $this->productVisibility = $this->objectManager->get(Visibility::class); + $this->productRepository = $this->objectManager->get(ProductRepositoryInterface::class); + } + + /** + * Check that Advanced Search does NOT return products that do NOT have search visibility. + * + * @magentoDbIsolation disabled + * @magentoConfigFixture default/catalog/search/engine elasticsearch7 + * @magentoDataFixture Magento/ConfigurableProduct/_files/configurable_product_with_two_child_products.php + * @return void + */ + public function testAddFilters(): void + { + $this->assertResultsAfterRequest(1); + + /** @var ProductInterface $configurableProductOption */ + $configurableProductOption = $this->productRepository->get('Simple option 1'); + $configurableProductOption->setVisibility(Visibility::VISIBILITY_IN_SEARCH); + $this->productRepository->save($configurableProductOption); + + $this->registry->unregister('advanced_search_conditions'); + $this->assertResultsAfterRequest(2); + } + + /** + * Do Elasticsearch query and assert results. + * + * @param int $count + * @return void + */ + private function assertResultsAfterRequest(int $count): void + { + /** @var Advanced $advancedSearch */ + $advancedSearch = $this->objectManager->create(Advanced::class); + $advancedSearch->addFilters(['name' => 'Configurable']); + + /** @var ProductInterface[] $itemsResult */ + $itemsResult = $advancedSearch->getProductCollection() + ->addAttributeToSelect(ProductInterface::VISIBILITY) + ->getItems(); + + $this->assertCount($count, $itemsResult); + foreach ($itemsResult as $product) { + $this->assertStringContainsString('Configurable', $product->getName()); + $this->assertContains((int)$product->getVisibility(), $this->productVisibility->getVisibleInSearchIds()); + } + } +} diff --git a/dev/tests/integration/testsuite/Magento/Elasticsearch/Model/Indexer/ReindexAllTest.php b/dev/tests/integration/testsuite/Magento/Elasticsearch/Model/Indexer/ReindexAllTest.php index 9679b4f232ee2..6df4d8fbb2d92 100644 --- a/dev/tests/integration/testsuite/Magento/Elasticsearch/Model/Indexer/ReindexAllTest.php +++ b/dev/tests/integration/testsuite/Magento/Elasticsearch/Model/Indexer/ReindexAllTest.php @@ -130,6 +130,45 @@ public function testSort() $this->assertEquals($productSimpleId, $firstInSearchResults); } + /** + * Test sorting of products with lower and upper case names after full reindex + * + * @magentoDbIsolation enabled + * @magentoConfigFixture current_store catalog/search/elasticsearch_index_prefix indexerhandlertest + * @magentoDataFixture Magento/Elasticsearch/_files/case_sensitive.php + */ + public function testSortCaseSensitive(): void + { + $productFirst = $this->productRepository->get('fulltext-1'); + $productSecond = $this->productRepository->get('fulltext-2'); + $productThird = $this->productRepository->get('fulltext-3'); + $productFourth = $this->productRepository->get('fulltext-4'); + $productFifth = $this->productRepository->get('fulltext-5'); + $correctSortedIds = [ + $productFirst->getId(), + $productFourth->getId(), + $productSecond->getId(), + $productFifth->getId(), + $productThird->getId(), + ]; + $this->reindexAll(); + $result = $this->sortByName(); + $firstInSearchResults = (int) $result[0]['_id']; + $secondInSearchResults = (int) $result[1]['_id']; + $thirdInSearchResults = (int) $result[2]['_id']; + $fourthInSearchResults = (int) $result[3]['_id']; + $fifthInSearchResults = (int) $result[4]['_id']; + $actualSortedIds = [ + $firstInSearchResults, + $secondInSearchResults, + $thirdInSearchResults, + $fourthInSearchResults, + $fifthInSearchResults + ]; + $this->assertCount(5, $result); + $this->assertEquals($correctSortedIds, $actualSortedIds); + } + /** * Test search of specific product after full reindex * diff --git a/dev/tests/integration/testsuite/Magento/Elasticsearch/_files/case_sensitive.php b/dev/tests/integration/testsuite/Magento/Elasticsearch/_files/case_sensitive.php new file mode 100644 index 0000000000000..1b664f958dd46 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Elasticsearch/_files/case_sensitive.php @@ -0,0 +1,126 @@ +requireDataFixture('Magento/Catalog/_files/product_boolean_attribute.php'); + +/** @var $objectManager \Magento\Framework\ObjectManagerInterface */ +$objectManager = Bootstrap::getObjectManager(); + +$productRepository = $objectManager->get(ProductRepositoryInterface::class); +try { + $productRepository->get('fulltext-1'); +} catch (NoSuchEntityException $e) { + /** @var $productFirst Product */ + $productFirst = $objectManager->create(Product::class); + $productFirst->setTypeId('simple') + ->setAttributeSetId(4) + ->setWebsiteIds([1]) + ->setName('A') + ->setSku('fulltext-1') + ->setPrice(10) + ->setMetaTitle('first meta title') + ->setMetaKeyword('first meta keyword') + ->setMetaDescription('first meta description') + ->setVisibility(Visibility::VISIBILITY_BOTH) + ->setStatus(Status::STATUS_ENABLED) + ->setStockData(['use_config_manage_stock' => 0]) + ->setBooleanAttribute(1) + ->save(); +} + +try { + $productRepository->get('fulltext-2'); +} catch (NoSuchEntityException $e) { + /** @var $productSecond Product */ + $productSecond = $objectManager->create(Product::class); + $productSecond->setTypeId('simple') + ->setAttributeSetId(4) + ->setWebsiteIds([1]) + ->setName('B') + ->setSku('fulltext-2') + ->setPrice(20) + ->setMetaTitle('second meta title') + ->setMetaKeyword('second meta keyword') + ->setMetaDescription('second meta description') + ->setVisibility(Visibility::VISIBILITY_BOTH) + ->setStatus(Status::STATUS_ENABLED) + ->setStockData(['use_config_manage_stock' => 0]) + ->setBooleanAttribute(1) + ->save(); +} + +try { + $productRepository->get('fulltext-3'); +} catch (NoSuchEntityException $e) { + /** @var $productThird Product */ + $productThird = $objectManager->create(Product::class); + $productThird->setTypeId('simple') + ->setAttributeSetId(4) + ->setWebsiteIds([1]) + ->setName('C') + ->setSku('fulltext-3') + ->setPrice(20) + ->setMetaTitle('third meta title') + ->setMetaKeyword('third meta keyword') + ->setMetaDescription('third meta description') + ->setVisibility(Visibility::VISIBILITY_BOTH) + ->setStatus(Status::STATUS_ENABLED) + ->setStockData(['use_config_manage_stock' => 0]) + ->setBooleanAttribute(1) + ->save(); +} + +try { + $productRepository->get('fulltext-4'); +} catch (NoSuchEntityException $e) { + /** @var $productFourth Product */ + $productFourth = $objectManager->create(Product::class); + $productFourth->setTypeId('simple') + ->setAttributeSetId(4) + ->setWebsiteIds([1]) + ->setName('a') + ->setSku('fulltext-4') + ->setPrice(20) + ->setMetaTitle('fourth meta title') + ->setMetaKeyword('fourth meta keyword') + ->setMetaDescription('fourth meta description') + ->setUrlKey('aa') + ->setVisibility(Visibility::VISIBILITY_BOTH) + ->setStatus(Status::STATUS_ENABLED) + ->setStockData(['use_config_manage_stock' => 0]) + ->setBooleanAttribute(0) + ->save(); +} + +try { + $productRepository->get('fulltext-5'); +} catch (NoSuchEntityException $e) { + /** @var $productFifth Product */ + $productFifth = $objectManager->create(Product::class); + $productFifth->setTypeId('simple') + ->setAttributeSetId(4) + ->setWebsiteIds([1]) + ->setName('b') + ->setSku('fulltext-5') + ->setPrice(20) + ->setMetaTitle('fifth meta title') + ->setMetaKeyword('fifth meta keyword') + ->setMetaDescription('fifth meta description') + ->setUrlKey('bb') + ->setVisibility(Visibility::VISIBILITY_BOTH) + ->setStatus(Status::STATUS_ENABLED) + ->setStockData(['use_config_manage_stock' => 0]) + ->setBooleanAttribute(0) + ->save(); +} diff --git a/dev/tests/integration/testsuite/Magento/Elasticsearch/_files/case_sensitive_rollback.php b/dev/tests/integration/testsuite/Magento/Elasticsearch/_files/case_sensitive_rollback.php new file mode 100644 index 0000000000000..a97faa29a1588 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Elasticsearch/_files/case_sensitive_rollback.php @@ -0,0 +1,35 @@ +requireDataFixture('Magento/Catalog/_files/product_boolean_attribute_rollback.php'); + +/** @var $objectManager \Magento\Framework\ObjectManagerInterface */ +$objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); + +/** @var \Magento\Framework\Registry $registry */ +$registry = $objectManager->get(\Magento\Framework\Registry::class); +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', true); + +/** @var \Magento\Catalog\Model\ResourceModel\Product\Collection $collection */ +$collection = $objectManager->create(\Magento\Catalog\Model\ResourceModel\Product\Collection::class); +$collection->addAttributeToSelect('id')->load(); +if ($collection->count() > 0) { + $collection->delete(); +} + +/** @var \Magento\Store\Model\Store $store */ +$store = $objectManager->create(\Magento\Store\Model\Store::class); +$storeCode = 'secondary'; +$store->load($storeCode); +if ($store->getId()) { + $store->delete(); +} + +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', false); diff --git a/dev/tests/integration/testsuite/Magento/Email/Model/TemplateTest.php b/dev/tests/integration/testsuite/Magento/Email/Model/TemplateTest.php index f09a0429979ba..3617c467da659 100644 --- a/dev/tests/integration/testsuite/Magento/Email/Model/TemplateTest.php +++ b/dev/tests/integration/testsuite/Magento/Email/Model/TemplateTest.php @@ -75,7 +75,11 @@ protected function mockModel($filesystem = null) $this->objectManager->get(\Magento\Framework\App\State::class)->setAreaCode('frontend'); $this->model->expects($this->any())->method('_getMail')->willReturnCallback([$this, 'getMail']); - $this->model->setSenderName('sender')->setSenderEmail('sender@example.com')->setTemplateSubject('Subject'); + $this->model + ->setSenderName('sender') + ->setSenderEmail('sender@example.com') + ->setTemplateSubject('Subject') + ->setTemplateId('abc'); } /** @@ -120,6 +124,7 @@ public function testLoadDefault() public function testGetProcessedTemplate() { $this->mockModel(); + $this->model->setTemplateId(null); $this->objectManager->get(\Magento\Framework\App\AreaList::class) ->getArea(Area::AREA_FRONTEND) ->load(); @@ -416,6 +421,40 @@ public function testLegacyTemplateLoadedFromDbIsFilteredInLegacyMode() self::assertEquals('1 - some_unique_code - 1 - some_unique_code', $this->model->getProcessedTemplate()); } + /** + * @magentoDataFixture Magento/Store/_files/core_fixturestore.php + * @magentoComponentsDir Magento/Email/Model/_files/design + * @magentoAppIsolation enabled + * @magentoDbIsolation enabled + */ + public function testPreviewTemplateIsFilteredInStrictMode() + { + $this->mockModel(); + + $this->setUpThemeFallback(BackendFrontNameResolver::AREA_CODE); + + $this->model->setTemplateType(TemplateTypesInterface::TYPE_HTML); + $template = '{{var store.isSaveAllowed()}} - {{template config_path="design/email/footer_template"}}'; + $this->model->setTemplateText($template); + + $template = $this->objectManager->create(\Magento\Email\Model\Template::class); + $templateData = [ + 'is_legacy' => '0', + 'template_code' => 'some_unique_code', + 'template_type' => TemplateTypesInterface::TYPE_HTML, + 'template_text' => '{{var this.template_code}}' + . ' - {{var store.isSaveAllowed()}} - {{var this.getTemplateCode()}}', + ]; + $template->setData($templateData); + $template->save(); + + // Store the ID of the newly created template in the system config so that this template will be loaded + $this->objectManager->get(\Magento\Framework\App\Config\MutableScopeConfigInterface::class) + ->setValue('design/email/footer_template', $template->getId(), ScopeInterface::SCOPE_STORE, 'fixturestore'); + + self::assertEquals('1 - some_unique_code - - some_unique_code', $this->model->getProcessedTemplate()); + } + /** * Ensure that the template_styles variable contains styles from either or the "Template Styles" * textarea in backend, depending on whether template was loaded from filesystem or DB. diff --git a/dev/tests/integration/testsuite/Magento/Framework/App/Filesystem/CreatePdfFileTest.php b/dev/tests/integration/testsuite/Magento/Framework/App/Filesystem/CreatePdfFileTest.php index d7b492bf5153c..7bd4b3a99d1bf 100644 --- a/dev/tests/integration/testsuite/Magento/Framework/App/Filesystem/CreatePdfFileTest.php +++ b/dev/tests/integration/testsuite/Magento/Framework/App/Filesystem/CreatePdfFileTest.php @@ -8,7 +8,6 @@ namespace Magento\Framework\App\Filesystem; -use Magento\Framework\App\Filesystem\DirectoryList; use Magento\Framework\App\Response\Http\FileFactory; use Magento\Framework\Filesystem; use Magento\TestFramework\Helper\Bootstrap; diff --git a/dev/tests/integration/testsuite/Magento/Framework/Cache/Backend/MongoDbTest.php b/dev/tests/integration/testsuite/Magento/Framework/Cache/Backend/MongoDbTest.php index f4cec6e80aa36..82512c0bd37ff 100644 --- a/dev/tests/integration/testsuite/Magento/Framework/Cache/Backend/MongoDbTest.php +++ b/dev/tests/integration/testsuite/Magento/Framework/Cache/Backend/MongoDbTest.php @@ -203,7 +203,7 @@ public function testSave() $actualData = $this->_model->load($cacheId); $this->assertEquals($data, $actualData); $actualMetadata = $this->_model->getMetadatas($cacheId); - $this->arrayHasKey('tags', $actualMetadata); + $this->arrayHasKey('tags'); $this->assertEquals($tags, $actualMetadata['tags']); } diff --git a/dev/tests/integration/testsuite/Magento/Framework/Code/Generator/AutoloaderTest.php b/dev/tests/integration/testsuite/Magento/Framework/Code/Generator/AutoloaderTest.php index c79774aefa045..9724f61abb1c4 100644 --- a/dev/tests/integration/testsuite/Magento/Framework/Code/Generator/AutoloaderTest.php +++ b/dev/tests/integration/testsuite/Magento/Framework/Code/Generator/AutoloaderTest.php @@ -13,6 +13,9 @@ use PHPUnit\Framework\MockObject\MockObject as MockObject; use Psr\Log\LoggerInterface; +/** + * @magentoAppIsolation enabled + */ class AutoloaderTest extends TestCase { /** @@ -28,21 +31,11 @@ private function getTestFrameworkObjectManager() return ObjectManager::getInstance(); } - /** - * @before - */ - public function setupLoggerTestDouble(): void - { - $loggerTestDouble = $this->getMockForAbstractClass(LoggerInterface::class); - $this->getTestFrameworkObjectManager()->addSharedInstance($loggerTestDouble, MagentoMonologLogger::class); - } - - /** - * @after - */ - public function removeLoggerTestDouble(): void + protected function setUp(): void { - $this->getTestFrameworkObjectManager()->removeSharedInstance(MagentoMonologLogger::class); + $loggerTestDouble = $this->createMock(LoggerInterface::class); + $this->getTestFrameworkObjectManager()->addSharedInstance($loggerTestDouble, LoggerInterface::class, true); + // magentoAppIsolation will cleanup the mess } /** diff --git a/dev/tests/integration/testsuite/Magento/Framework/Code/GeneratorTest.php b/dev/tests/integration/testsuite/Magento/Framework/Code/GeneratorTest.php index fe92c295b47fa..e19d0a7364b27 100644 --- a/dev/tests/integration/testsuite/Magento/Framework/Code/GeneratorTest.php +++ b/dev/tests/integration/testsuite/Magento/Framework/Code/GeneratorTest.php @@ -16,6 +16,8 @@ require_once __DIR__ . '/GeneratorTest/SourceClassWithNamespace.php'; require_once __DIR__ . '/GeneratorTest/ParentClassWithNamespace.php'; require_once __DIR__ . '/GeneratorTest/SourceClassWithNamespaceExtension.php'; +require_once __DIR__ . '/GeneratorTest/NestedNamespace/SourceClassWithNestedNamespace.php'; +require_once __DIR__ . '/GeneratorTest/NestedNamespace/SourceClassWithNestedNamespaceExtension.php'; /** * @magentoAppIsolation enabled @@ -24,6 +26,10 @@ class GeneratorTest extends TestCase { const CLASS_NAME_WITH_NAMESPACE = GeneratorTest\SourceClassWithNamespace::class; + const CLASS_NAME_WITH_NESTED_NAMESPACE = GeneratorTest\NestedNamespace\SourceClassWithNestedNamespace::class; + const EXTENSION_CLASS_NAME_WITH_NAMESPACE = GeneratorTest\SourceClassWithNamespaceExtension::class; + const EXTENSION_CLASS_NAME_WITH_NESTED_NAMESPACE = + GeneratorTest\NestedNamespace\SourceClassWithNestedNamespaceExtension::class; /** * @var Generator @@ -59,6 +65,7 @@ protected function setUp(): void /** @var Filesystem $filesystem */ $filesystem = $objectManager->get(Filesystem::class); $this->generatedDirectory = $filesystem->getDirectoryWrite(DirectoryList::GENERATED_CODE); + $this->generatedDirectory->create($this->testRelativePath); $this->logDirectory = $filesystem->getDirectoryRead(DirectoryList::LOG); $generatedDirectoryAbsolutePath = $this->generatedDirectory->getAbsolutePath(); $this->_ioObject = new Generator\Io(new Filesystem\Driver\File(), $generatedDirectoryAbsolutePath); @@ -98,78 +105,99 @@ protected function _clearDocBlock($classBody) } /** - * Generates a new file with Factory class and compares with the sample from the - * SourceClassWithNamespaceFactory.php.sample file. + * Generates a new class Factory file and compares with the sample. + * + * @param $className + * @param $generateType + * @param $expectedDataPath + * @dataProvider generateClassFactoryDataProvider */ - public function testGenerateClassFactoryWithNamespace() + public function testGenerateClassFactory($className, $generateType, $expectedDataPath) { - $factoryClassName = self::CLASS_NAME_WITH_NAMESPACE . 'Factory'; + $factoryClassName = $className . $generateType; $this->assertEquals(Generator::GENERATION_SUCCESS, $this->_generator->generateClass($factoryClassName)); $factory = Bootstrap::getObjectManager()->create($factoryClassName); - $this->assertInstanceOf(self::CLASS_NAME_WITH_NAMESPACE, $factory->create()); + $this->assertInstanceOf($className, $factory->create()); $content = $this->_clearDocBlock( file_get_contents($this->_ioObject->generateResultFileName($factoryClassName)) ); $expectedContent = $this->_clearDocBlock( - file_get_contents(__DIR__ . '/_expected/SourceClassWithNamespaceFactory.php.sample') + file_get_contents(__DIR__ . $expectedDataPath) ); $this->assertEquals($expectedContent, $content); } /** - * Generates a new file with Proxy class and compares with the sample from the - * SourceClassWithNamespaceProxy.php.sample file. + * DataProvider for testGenerateClassFactory + * + * @return array */ - public function testGenerateClassProxyWithNamespace() + public function generateClassFactoryDataProvider() { - $proxyClassName = self::CLASS_NAME_WITH_NAMESPACE . '\Proxy'; - $this->assertEquals(Generator::GENERATION_SUCCESS, $this->_generator->generateClass($proxyClassName)); - $proxy = Bootstrap::getObjectManager()->create($proxyClassName); - $this->assertInstanceOf(self::CLASS_NAME_WITH_NAMESPACE, $proxy); - $content = $this->_clearDocBlock( - file_get_contents($this->_ioObject->generateResultFileName($proxyClassName)) - ); - $expectedContent = $this->_clearDocBlock( - file_get_contents(__DIR__ . '/_expected/SourceClassWithNamespaceProxy.php.sample') - ); - $this->assertEquals($expectedContent, $content); + return [ + 'factory_with_namespace' => [ + 'className' => self::CLASS_NAME_WITH_NAMESPACE, + 'generateType' => 'Factory', + 'expectedDataPath' => '/_expected/SourceClassWithNamespaceFactory.php.sample' + ], + 'factory_with_nested_namespace' => [ + 'classToGenerate' => self::CLASS_NAME_WITH_NESTED_NAMESPACE, + 'generateType' => 'Factory', + 'expectedDataPath' => '/_expected/SourceClassWithNestedNamespaceFactory.php.sample' + ], + 'ext_interface_factory_with_namespace' => [ + 'classToGenerate' => self::EXTENSION_CLASS_NAME_WITH_NAMESPACE, + 'generateType' => 'InterfaceFactory', + 'expectedDataPath' => '/_expected/SourceClassWithNamespaceExtensionInterfaceFactory.php.sample' + ], + 'ext_interface_factory_with_nested_namespace' => [ + 'classToGenerate' => self::EXTENSION_CLASS_NAME_WITH_NESTED_NAMESPACE, + 'generateType' => 'InterfaceFactory', + 'expectedDataPath' => '/_expected/SourceClassWithNestedNamespaceExtensionInterfaceFactory.php.sample' + ], + ]; } /** - * Generates a new file with Interceptor class and compares with the sample from the - * SourceClassWithNamespaceInterceptor.php.sample file. + * @param $className + * @param $generateType + * @param $expectedDataPath + * @dataProvider generateClassDataProvider */ - public function testGenerateClassInterceptorWithNamespace() + public function testGenerateClass($className, $generateType, $expectedDataPath) { - $interceptorClassName = self::CLASS_NAME_WITH_NAMESPACE . '\Interceptor'; - $this->assertEquals(Generator::GENERATION_SUCCESS, $this->_generator->generateClass($interceptorClassName)); + $generateClassName = $className . $generateType; + $this->assertEquals(Generator::GENERATION_SUCCESS, $this->_generator->generateClass($generateClassName)); + $instance = Bootstrap::getObjectManager()->create($generateClassName); + $this->assertInstanceOf($className, $instance); $content = $this->_clearDocBlock( - file_get_contents($this->_ioObject->generateResultFileName($interceptorClassName)) + file_get_contents($this->_ioObject->generateResultFileName($generateClassName)) ); $expectedContent = $this->_clearDocBlock( - file_get_contents(__DIR__ . '/_expected/SourceClassWithNamespaceInterceptor.php.sample') + file_get_contents(__DIR__ . $expectedDataPath) ); $this->assertEquals($expectedContent, $content); } /** - * Generates a new file with ExtensionInterfaceFactory class and compares with the sample from the - * SourceClassWithNamespaceExtensionInterfaceFactory.php.sample file. + * DataProvider for testGenerateClass + * + * @return array */ - public function testGenerateClassExtensionAttributesInterfaceFactoryWithNamespace() + public function generateClassDataProvider() { - $factoryClassName = self::CLASS_NAME_WITH_NAMESPACE . 'ExtensionInterfaceFactory'; - $this->generatedDirectory->create($this->testRelativePath); - $this->assertEquals(Generator::GENERATION_SUCCESS, $this->_generator->generateClass($factoryClassName)); - $factory = Bootstrap::getObjectManager()->create($factoryClassName); - $this->assertInstanceOf(self::CLASS_NAME_WITH_NAMESPACE . 'Extension', $factory->create()); - $content = $this->_clearDocBlock( - file_get_contents($this->_ioObject->generateResultFileName($factoryClassName)) - ); - $expectedContent = $this->_clearDocBlock( - file_get_contents(__DIR__ . '/_expected/SourceClassWithNamespaceExtensionInterfaceFactory.php.sample') - ); - $this->assertEquals($expectedContent, $content); + return [ + 'proxy' => [ + 'className' => self::CLASS_NAME_WITH_NAMESPACE, + 'generateType' => '\Proxy', + 'expectedDataPath' => '/_expected/SourceClassWithNamespaceProxy.php.sample' + ], + 'interceptor' => [ + 'className' => self::CLASS_NAME_WITH_NAMESPACE, + 'generateType' => '\Interceptor', + 'expectedDataPath' => '/_expected/SourceClassWithNamespaceInterceptor.php.sample' + ] + ]; } /** @@ -183,7 +211,6 @@ public function testGeneratorClassWithErrorSaveClassFile() $regexpMsgPart = preg_quote($msgPart); $this->expectException(\RuntimeException::class); $this->expectExceptionMessageMatches("/.*$regexpMsgPart.*/"); - $this->generatedDirectory->create($this->testRelativePath); $this->generatedDirectory->changePermissionsRecursively($this->testRelativePath, 0555, 0444); $generatorResult = $this->_generator->generateClass($factoryClassName); $this->assertFalse($generatorResult); diff --git a/dev/tests/integration/testsuite/Magento/Framework/Code/GeneratorTest/NestedNamespace/SourceClassWithNestedNamespace.php b/dev/tests/integration/testsuite/Magento/Framework/Code/GeneratorTest/NestedNamespace/SourceClassWithNestedNamespace.php new file mode 100644 index 0000000000000..6471a198b31f9 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Framework/Code/GeneratorTest/NestedNamespace/SourceClassWithNestedNamespace.php @@ -0,0 +1,170 @@ +_objectManager = $objectManager; + $this->_instanceName = $instanceName; + } + + /** + * Create class instance with specified parameters + * + * @param array $data + * @return \Magento\Framework\Code\GeneratorTest\NestedNamespace\SourceClassWithNestedNamespaceExtension + */ + public function create(array $data = []) + { + return $this->_objectManager->create($this->_instanceName, $data); + } +} diff --git a/dev/tests/integration/testsuite/Magento/Framework/Code/_expected/SourceClassWithNestedNamespaceFactory.php.sample b/dev/tests/integration/testsuite/Magento/Framework/Code/_expected/SourceClassWithNestedNamespaceFactory.php.sample new file mode 100644 index 0000000000000..1913968d199af --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Framework/Code/_expected/SourceClassWithNestedNamespaceFactory.php.sample @@ -0,0 +1,48 @@ +_objectManager = $objectManager; + $this->_instanceName = $instanceName; + } + + /** + * Create class instance with specified parameters + * + * @param array $data + * @return \Magento\Framework\Code\GeneratorTest\NestedNamespace\SourceClassWithNestedNamespace + */ + public function create(array $data = []) + { + return $this->_objectManager->create($this->_instanceName, $data); + } +} diff --git a/dev/tests/integration/testsuite/Magento/Framework/Console/CliTest.php b/dev/tests/integration/testsuite/Magento/Framework/Console/CliTest.php deleted file mode 100644 index c6aeaf9e0f927..0000000000000 --- a/dev/tests/integration/testsuite/Magento/Framework/Console/CliTest.php +++ /dev/null @@ -1,130 +0,0 @@ -objectManager = Bootstrap::getObjectManager(); - $this->configFilePool = $this->objectManager->get(ConfigFilePool::class); - $this->filesystem = $this->objectManager->get(Filesystem::class); - $this->reader = $this->objectManager->get(FileReader::class); - $this->writer = $this->objectManager->get(Writer::class); - - $this->envConfig = $this->reader->load(ConfigFilePool::APP_ENV); - } - - /** - * @inheritdoc - */ - protected function tearDown(): void - { - $this->filesystem->getDirectoryWrite(DirectoryList::CONFIG)->writeFile( - $this->configFilePool->getPath(ConfigFilePool::APP_ENV), - "writer->saveConfig([ConfigFilePool::APP_ENV => $this->envConfig], true); - } - - /** - * Checks that settings from env.php config file are applied - * to created application instance. - * - * @magentoAppIsolation enabled - * @param bool $isPub - * @param array $params - * @dataProvider documentRootIsPubProvider - */ - public function testDocumentRootIsPublic($isPub, $params) - { - $config = include __DIR__ . '/_files/env.php'; - $config['directories']['document_root_is_pub'] = $isPub; - $this->writer->saveConfig([ConfigFilePool::APP_ENV => $config], true); - - $cli = new Cli(); - $cliReflection = new \ReflectionClass($cli); - - $serviceManagerProperty = $cliReflection->getProperty('serviceManager'); - $serviceManagerProperty->setAccessible(true); - $serviceManager = $serviceManagerProperty->getValue($cli); - $deploymentConfig = $this->objectManager->get(DeploymentConfig::class); - $serviceManager->setAllowOverride(true); - $serviceManager->setService(DeploymentConfig::class, $deploymentConfig); - $serviceManagerProperty->setAccessible(false); - - $documentRootResolver = $cliReflection->getMethod('documentRootResolver'); - $documentRootResolver->setAccessible(true); - - self::assertEquals($params, $documentRootResolver->invoke($cli)); - } - - /** - * Provides document root setting and expecting - * properties for object manager creation. - * - * @return array - */ - public function documentRootIsPubProvider(): array - { - return [ - [true, [ - 'MAGE_DIRS' => [ - 'pub' => ['uri' => ''], - 'media' => ['uri' => 'media'], - 'static' => ['uri' => 'static'], - 'upload' => ['uri' => 'media/upload'] - ] - ]], - [false, []] - ]; - } -} diff --git a/dev/tests/integration/testsuite/Magento/Framework/Console/_files/env.php b/dev/tests/integration/testsuite/Magento/Framework/Console/_files/env.php deleted file mode 100644 index e314e7638c22c..0000000000000 --- a/dev/tests/integration/testsuite/Magento/Framework/Console/_files/env.php +++ /dev/null @@ -1,46 +0,0 @@ - [ - 'frontName' => 'admin', - ], - 'crypt' => [ - 'key' => 'some_key', - ], - 'session' => [ - 'save' => 'files', - ], - 'db' => [ - 'table_prefix' => '', - 'connection' => [], - ], - 'resource' => [], - 'x-frame-options' => 'SAMEORIGIN', - 'MAGE_MODE' => 'default', - 'cache_types' => [ - 'config' => 1, - 'layout' => 1, - 'block_html' => 1, - 'collections' => 1, - 'reflection' => 1, - 'db_ddl' => 1, - 'eav' => 1, - 'customer_notification' => 1, - 'config_integration' => 1, - 'config_integration_api' => 1, - 'full_page' => 1, - 'translate' => 1, - 'config_webservice' => 1, - ], - 'install' => [ - 'date' => 'Thu, 09 Feb 2017 14:28:00 +0000', - ], - 'directories' => [ - 'document_root_is_pub' => true - ] -]; diff --git a/dev/tests/integration/testsuite/Magento/Framework/Css/_files/css/test-input.html b/dev/tests/integration/testsuite/Magento/Framework/Css/_files/css/test-input.html index d5b6f35421ac6..518926ed52d69 100644 --- a/dev/tests/integration/testsuite/Magento/Framework/Css/_files/css/test-input.html +++ b/dev/tests/integration/testsuite/Magento/Framework/Css/_files/css/test-input.html @@ -1,3 +1,9 @@ + @@ -30,7 +36,7 @@ height="52" - src="http://magento2.vagrant236/pub/static/version1502812784/frontend/Magento/blank/en_US/Magento_Email/logo_email.png" + src="http://magento2.vagrant236/static/version1502812784/frontend/Magento/blank/en_US/Magento_Email/logo_email.png" alt="Main Website Store" border="0" /> diff --git a/dev/tests/integration/testsuite/Magento/Framework/Error/ProcessorTest.php b/dev/tests/integration/testsuite/Magento/Framework/Error/ProcessorTest.php index af2f8208afab1..917b79588312c 100644 --- a/dev/tests/integration/testsuite/Magento/Framework/Error/ProcessorTest.php +++ b/dev/tests/integration/testsuite/Magento/Framework/Error/ProcessorTest.php @@ -31,7 +31,10 @@ protected function setUp(): void protected function tearDown(): void { $reportDir = $this->processor->_reportDir; - $this->removeDirRecursively($reportDir); + + if (is_dir($reportDir)) { + $this->removeDirRecursively($reportDir); + } } /** @@ -137,4 +140,16 @@ private function removeDirRecursively(string $dir, int $i = 0): bool } return rmdir($dir); } + + /** + * @return void + */ + public function testGetViewFileUrl(): void + { + $this->processor->_indexDir = __DIR__ . '/version1/magento2'; + $this->processor->_errorDir = __DIR__ . '/version2/magento2'; + + $this->assertStringNotContainsString('version2/magento2', $this->processor->getViewFileUrl()); + $this->assertStringContainsString('errors/', $this->processor->getViewFileUrl()); + } } diff --git a/dev/tests/integration/testsuite/Magento/Framework/Filesystem/Directory/WriteTest.php b/dev/tests/integration/testsuite/Magento/Framework/Filesystem/Directory/WriteTest.php index fb367fd557416..ca8cca878d091 100644 --- a/dev/tests/integration/testsuite/Magento/Framework/Filesystem/Directory/WriteTest.php +++ b/dev/tests/integration/testsuite/Magento/Framework/Filesystem/Directory/WriteTest.php @@ -7,15 +7,17 @@ */ namespace Magento\Framework\Filesystem\Directory; +use Magento\Framework\Exception\FileSystemException; use Magento\Framework\Exception\ValidatorException; use Magento\Framework\Filesystem\DriverPool; use Magento\TestFramework\Helper\Bootstrap; +use PHPUnit\Framework\TestCase; /** * Class ReadTest * Test for Magento\Framework\Filesystem\Directory\Read class */ -class WriteTest extends \PHPUnit\Framework\TestCase +class WriteTest extends TestCase { /** * Test data to be cleaned @@ -41,6 +43,8 @@ public function testInstance() * @param string $basePath * @param int $permissions * @param string $path + * @throws FileSystemException + * @throws ValidatorException */ public function testCreate($basePath, $permissions, $path) { @@ -64,6 +68,11 @@ public function createProvider() ]; } + /** + * Test for create outside + * + * @throws FileSystemException + */ public function testCreateOutside() { $exceptions = 0; @@ -91,6 +100,8 @@ public function testCreateOutside() * * @dataProvider deleteProvider * @param string $path + * @throws FileSystemException + * @throws ValidatorException */ public function testDelete($path) { @@ -111,6 +122,11 @@ public function deleteProvider() return [['subdir'], ['subdir/subsubdir']]; } + /** + * Test for delete outside + * + * @throws FileSystemException + */ public function testDeleteOutside() { $exceptions = 0; @@ -141,6 +157,8 @@ public function testDeleteOutside() * @param int $permissions * @param string $name * @param string $newName + * @throws FileSystemException + * @throws ValidatorException */ public function testRename($basePath, $permissions, $name, $newName) { @@ -164,6 +182,11 @@ public function renameProvider() return [['newDir1', 0777, 'first_name.txt', 'second_name.txt']]; } + /** + * Test for rename outside + * + * @throws FileSystemException + */ public function testRenameOutside() { $exceptions = 0; @@ -198,6 +221,8 @@ public function testRenameOutside() * @param int $permission * @param string $name * @param string $newName + * @throws FileSystemException + * @throws ValidatorException */ public function testRenameTargetDir($firstDir, $secondDir, $permission, $name, $newName) { @@ -231,6 +256,8 @@ public function renameTargetDirProvider() * @param int $permissions * @param string $name * @param string $newName + * @throws ValidatorException + * @throws FileSystemException */ public function testCopy($basePath, $permissions, $name, $newName) { @@ -255,6 +282,11 @@ public function copyProvider() ]; } + /** + * Test for copy outside + * + * @throws FileSystemException|ValidatorException + */ public function testCopyOutside() { $exceptions = 0; @@ -298,6 +330,8 @@ public function testCopyOutside() * @param int $permission * @param string $name * @param string $newName + * @throws FileSystemException + * @throws ValidatorException */ public function testCopyTargetDir($firstDir, $secondDir, $permission, $name, $newName) { @@ -327,6 +361,8 @@ public function copyTargetDirProvider() /** * Test for changePermissions method + * + * @throws FileSystemException|ValidatorException */ public function testChangePermissions() { @@ -335,6 +371,11 @@ public function testChangePermissions() $this->assertTrue($directory->changePermissions('test_directory', 0644)); } + /** + * Test for changePermissions outside + * + * @throws FileSystemException + */ public function testChangePermissionsOutside() { $exceptions = 0; @@ -359,6 +400,8 @@ public function testChangePermissionsOutside() /** * Test for changePermissionsRecursively method + * + * @throws FileSystemException|ValidatorException */ public function testChangePermissionsRecursively() { @@ -370,6 +413,11 @@ public function testChangePermissionsRecursively() $this->assertTrue($directory->changePermissionsRecursively('test_directory', 0777, 0644)); } + /** + * Test for changePermissionsRecursively outside + * + * @throws FileSystemException + */ public function testChangePermissionsRecursivelyOutside() { $exceptions = 0; @@ -400,6 +448,8 @@ public function testChangePermissionsRecursivelyOutside() * @param int $permissions * @param string $path * @param int $time + * @throws FileSystemException + * @throws ValidatorException */ public function testTouch($basePath, $permissions, $path, $time) { @@ -422,6 +472,11 @@ public function touchProvider() ]; } + /** + * Test for touch outside + * + * @throws FileSystemException + */ public function testTouchOutside() { $exceptions = 0; @@ -446,6 +501,8 @@ public function testTouchOutside() /** * Test isWritable method + * + * @throws FileSystemException|ValidatorException */ public function testIsWritable() { @@ -455,6 +512,11 @@ public function testIsWritable() $this->assertTrue($directory->isWritable('bar')); } + /** + * Test isWritable method outside + * + * @throws FileSystemException + */ public function testIsWritableOutside() { $exceptions = 0; @@ -485,6 +547,8 @@ public function testIsWritableOutside() * @param int $permissions * @param string $path * @param string $mode + * @throws FileSystemException + * @throws ValidatorException */ public function testOpenFile($basePath, $permissions, $path, $mode) { @@ -507,6 +571,11 @@ public function openFileProvider() ]; } + /** + * Test for openFile outside + * + * @throws FileSystemException + */ public function testOpenFileOutside() { $exceptions = 0; @@ -536,6 +605,8 @@ public function testOpenFileOutside() * @param string $path * @param string $content * @param string $extraContent + * @throws FileSystemException + * @throws ValidatorException */ public function testWriteFile($path, $content, $extraContent) { @@ -553,6 +624,8 @@ public function testWriteFile($path, $content, $extraContent) * @param string $path * @param string $content * @param string $extraContent + * @throws FileSystemException + * @throws ValidatorException */ public function testWriteFileAppend($path, $content, $extraContent) { @@ -573,6 +646,11 @@ public function writeFileProvider() return [['file1', '123', '456'], ['folder1/file1', '123', '456']]; } + /** + * Test for writeFile outside + * + * @throws FileSystemException + */ public function testWriteFileOutside() { $exceptions = 0; @@ -595,8 +673,24 @@ public function testWriteFileOutside() $this->assertEquals(3, $exceptions); } + /** + * Test for invalidDeletePath + * + * @throws ValidatorException + */ + public function testInvalidDeletePath() + { + $this->expectException(FileSystemException::class); + $directory = $this->getDirectoryInstance('newDir', 0777); + $invalidPath = 'invalidPath/../'; + $directory->create($invalidPath); + $directory->delete($invalidPath); + } + /** * Tear down + * + * @throws ValidatorException|FileSystemException */ protected function tearDown(): void { @@ -620,8 +714,8 @@ private function getDirectoryInstance($path, $permissions) { $fullPath = __DIR__ . '/../_files/' . $path; $objectManager = Bootstrap::getObjectManager(); - /** @var \Magento\Framework\Filesystem\Directory\WriteFactory $directoryFactory */ - $directoryFactory = $objectManager->create(\Magento\Framework\Filesystem\Directory\WriteFactory::class); + /** @var WriteFactory $directoryFactory */ + $directoryFactory = $objectManager->create(WriteFactory::class); $directory = $directoryFactory->create($fullPath, DriverPool::FILE, $permissions); $this->testDirectories[] = $directory; return $directory; diff --git a/dev/tests/integration/testsuite/Magento/Framework/Filter/Template/Tokenizer/ParameterTest.php b/dev/tests/integration/testsuite/Magento/Framework/Filter/Template/Tokenizer/ParameterTest.php index 8d4ebc40128d1..aad47165a470a 100644 --- a/dev/tests/integration/testsuite/Magento/Framework/Filter/Template/Tokenizer/ParameterTest.php +++ b/dev/tests/integration/testsuite/Magento/Framework/Filter/Template/Tokenizer/ParameterTest.php @@ -3,20 +3,33 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); + namespace Magento\Framework\Filter\Template\Tokenizer; -class ParameterTest extends \PHPUnit\Framework\TestCase +use Magento\Catalog\Block\Product\Widget\NewWidget; +use Magento\TestFramework\Helper\Bootstrap; +use PHPUnit\Framework\TestCase; + +/** + * Test for \Magento\Framework\Filter\Template\Tokenizer\Parameter. + */ +class ParameterTest extends TestCase { /** + * Test for getValue + * + * @dataProvider getValueDataProvider + * * @param string $string * @param array $values - * @dataProvider getValueDataProvider + * @return void */ - public function testGetValue($string, $values) + public function testGetValue($string, $values): void { - $objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); - /** @var \Magento\Framework\Filter\Template\Tokenizer\Parameter $parameter */ - $parameter = $objectManager->create(\Magento\Framework\Filter\Template\Tokenizer\Parameter::class); + $objectManager = Bootstrap::getObjectManager(); + /** @var Parameter $parameter */ + $parameter = $objectManager->create(Parameter::class); $parameter->setString($string); foreach ($values as $value) { @@ -25,30 +38,36 @@ public function testGetValue($string, $values) } /** + * Test for tokenize + * * @dataProvider tokenizeDataProvider + * * @param string $string * @param array $params + * @return void */ - public function testTokenize($string, $params) + public function testTokenize(string $string, array $params): void { - $objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); - /** @var \Magento\Framework\Filter\Template\Tokenizer\Parameter $parameter */ - $parameter = $objectManager->create(\Magento\Framework\Filter\Template\Tokenizer\Parameter::class); + $objectManager = Bootstrap::getObjectManager(); + $parameter = $objectManager->create(Parameter::class); $parameter->setString($string); + $this->assertEquals($params, $parameter->tokenize()); } /** + * DataProvider for testTokenize + * * @return array */ - public function tokenizeDataProvider() + public function tokenizeDataProvider(): array { return [ [ ' type="Magento\\Catalog\\Block\\Product\\Widget\\NewWidget" display_type="all_products"' . ' products_count="10" template="product/widget/new/content/new_grid.phtml"', [ - 'type' => \Magento\Catalog\Block\Product\Widget\NewWidget::class, + 'type' => NewWidget::class, 'display_type' => 'all_products', 'products_count' => 10, 'template' => 'product/widget/new/content/new_grid.phtml' @@ -58,12 +77,24 @@ public function tokenizeDataProvider() ' type="Magento\Catalog\Block\Product\Widget\NewWidget" display_type="all_products"' . ' products_count="10" template="product/widget/new/content/new_grid.phtml"', [ - 'type' => \Magento\Catalog\Block\Product\Widget\NewWidget::class, + 'type' => NewWidget::class, 'display_type' => 'all_products', 'products_count' => 10, 'template' => 'product/widget/new/content/new_grid.phtml' ] - ] + ], + [ + sprintf( + 'type="%s" display_type="all_products" products_count="1" template="content/new_grid.phtml"', + NewWidget::class + ), + [ + 'type' => NewWidget::class, + 'display_type' => 'all_products', + 'products_count' => 1, + 'template' => 'content/new_grid.phtml' + ], + ], ]; } diff --git a/dev/tests/integration/testsuite/Magento/Framework/Mail/TransportBuilderTest.php b/dev/tests/integration/testsuite/Magento/Framework/Mail/TransportBuilderTest.php index 9dc8aa91237d8..47c8da84902d4 100644 --- a/dev/tests/integration/testsuite/Magento/Framework/Mail/TransportBuilderTest.php +++ b/dev/tests/integration/testsuite/Magento/Framework/Mail/TransportBuilderTest.php @@ -116,4 +116,84 @@ public function emailDataProvider(): array ] ]; } + + /** + * Test if invalid email in the queue will not fail the entire queue from being sent + * + * @magentoDataFixture Magento/Email/Model/_files/email_template.php + * @magentoDbIsolation enabled + * + * @param string|array $emails + * @dataProvider invalidEmailDataProvider + * @throws LocalizedException + */ + public function testAddToInvalidEmailInTheQueue($emails) + { + $template = $this->template->load('email_exception_fixture', 'template_code'); + $templateId = $template->getId(); + + switch ($template->getType()) { + case TemplateTypesInterface::TYPE_TEXT: + $templateType = MimeInterface::TYPE_TEXT; + break; + + case TemplateTypesInterface::TYPE_HTML: + $templateType = MimeInterface::TYPE_HTML; + break; + + default: + $templateType = ''; + $this->fail('Unsupported Mime Type'); + } + + $this->builder->setTemplateModel(BackendTemplate::class); + + $vars = ['reason' => 'Reason', 'customer' => 'Customer']; + $options = ['area' => 'frontend', 'store' => 1]; + $this->builder->setTemplateIdentifier($templateId)->setTemplateVars($vars)->setTemplateOptions($options); + + $allEmails = $emails[0]; + $validOnlyEmails = $emails[1]; + + foreach ($allEmails as $email) { + $this->builder->addTo($email); + } + + /** @var EmailMessage $emailMessage */ + $emailMessage = $this->builder->getTransport()->getMessage(); + $this->assertStringContainsStringIgnoringCase($templateType, $emailMessage->getHeaders()['Content-Type']); + + $resultEmails = []; + /** @var Address $toAddress */ + foreach ($emailMessage->getTo() as $address) { + $resultEmails[] = $address->getEmail(); + } + + $this->assertEquals($validOnlyEmails, $resultEmails); + } + + /** + * @return array + */ + public function invalidEmailDataProvider(): array + { + return [ + [ + [ + [ + 'billy.everything@someserver.com', + 'billy.everythingsomeserver.com', + 'billy.everything2@someserver.com', + 'billy.everythin2gsomeserver.com', + 'billy.everything3@someserver.com' + ], + [ + 'billy.everything@someserver.com', + 'billy.everything2@someserver.com', + 'billy.everything3@someserver.com' + ] + ] + ] + ]; + } } diff --git a/dev/tests/integration/testsuite/Magento/Framework/MessageQueue/TopologyTest.php b/dev/tests/integration/testsuite/Magento/Framework/MessageQueue/TopologyTest.php index c8600e1e38faa..cbe48c99da020 100644 --- a/dev/tests/integration/testsuite/Magento/Framework/MessageQueue/TopologyTest.php +++ b/dev/tests/integration/testsuite/Magento/Framework/MessageQueue/TopologyTest.php @@ -3,16 +3,20 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + +declare(strict_types=1); + namespace Magento\Framework\MessageQueue; +use Magento\TestFramework\Helper\Amqp; use Magento\TestFramework\Helper\Bootstrap; -use Magento\TestFramework\MessageQueue\PreconditionFailedException; +use PHPUnit\Framework\TestCase; /** * @see dev/tests/integration/_files/Magento/TestModuleMessageQueueConfiguration * @see dev/tests/integration/_files/Magento/TestModuleMessageQueueConfigOverride */ -class TopologyTest extends \PHPUnit\Framework\TestCase +class TopologyTest extends TestCase { /** * List of declared exchanges. @@ -22,13 +26,16 @@ class TopologyTest extends \PHPUnit\Framework\TestCase private $declaredExchanges; /** - * @var \Magento\TestFramework\Helper\Amqp + * @var Amqp */ private $helper; + /** + * @return void + */ protected function setUp(): void { - $this->helper = Bootstrap::getObjectManager()->create(\Magento\TestFramework\Helper\Amqp::class); + $this->helper = Bootstrap::getObjectManager()->create(Amqp::class); if (!$this->helper->isAvailable()) { $this->fail('This test relies on RabbitMQ Management Plugin.'); @@ -42,12 +49,15 @@ protected function setUp(): void * @param array $expectedConfig * @param array $bindingConfig */ - public function testTopologyInstallation(array $expectedConfig, array $bindingConfig) + public function testTopologyInstallation(array $expectedConfig, array $bindingConfig): void { $name = $expectedConfig['name']; $this->assertArrayHasKey($name, $this->declaredExchanges); - unset($this->declaredExchanges[$name]['message_stats']); - unset($this->declaredExchanges[$name]['user_who_performed_action']); + unset( + $this->declaredExchanges[$name]['message_stats'], + $this->declaredExchanges[$name]['user_who_performed_action'] + ); + $this->assertEquals( $expectedConfig, $this->declaredExchanges[$name], @@ -55,10 +65,11 @@ public function testTopologyInstallation(array $expectedConfig, array $bindingCo ); $bindings = $this->helper->getExchangeBindings($name); - $bindings = array_map(function ($value) { + $bindings = array_map(static function ($value) { unset($value['properties_key']); return $value; }, $bindings); + $this->assertEquals( $bindingConfig, $bindings, @@ -70,7 +81,7 @@ public function testTopologyInstallation(array $expectedConfig, array $bindingCo * @return array * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ - public function exchangeDataProvider() + public function exchangeDataProvider(): array { return [ 'magento-topic-based-exchange1' => [ @@ -121,7 +132,7 @@ public function exchangeDataProvider() 'arguments' => [ 'argument1' => 'value', 'argument2' => true, - 'argument3' => '150', + 'argument3' => 150, ], ], ] diff --git a/dev/tests/integration/testsuite/Magento/Framework/Session/ConfigTest.php b/dev/tests/integration/testsuite/Magento/Framework/Session/ConfigTest.php index bed9a33c73148..095e70a6d0e1a 100644 --- a/dev/tests/integration/testsuite/Magento/Framework/Session/ConfigTest.php +++ b/dev/tests/integration/testsuite/Magento/Framework/Session/ConfigTest.php @@ -12,8 +12,6 @@ use Magento\Framework\App\Filesystem\DirectoryList; - // @codingStandardsIgnoreEnd - /** * Mock ini_get global function * @@ -37,6 +35,8 @@ function ini_get($varName) return call_user_func_array('\ini_get', func_get_args()); } + // @codingStandardsIgnoreEnd + /** * @magentoAppIsolation enabled */ @@ -181,7 +181,7 @@ public function testSettingInvalidCookieLifetime() $model->setCookieLifetime('foobar_bogus'); $this->assertEquals($preVal, $model->getCookieLifetime()); } - + public function testSettingInvalidCookieLifetime2() { $model = $this->getModel(); @@ -193,8 +193,8 @@ public function testSettingInvalidCookieLifetime2() public function testWrongMethodCall() { $model = $this->getModel(); - $this->expectException( - '\BadMethodCallException', + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage( 'Method "methodThatNotExist" does not exist in Magento\Framework\Session\Config' ); $model->methodThatNotExist(); diff --git a/dev/tests/integration/testsuite/Magento/Framework/Stdlib/Cookie/CookieScopeTest.php b/dev/tests/integration/testsuite/Magento/Framework/Stdlib/Cookie/CookieScopeTest.php index 5670c54e1fbd2..3fa318e6cc98e 100644 --- a/dev/tests/integration/testsuite/Magento/Framework/Stdlib/Cookie/CookieScopeTest.php +++ b/dev/tests/integration/testsuite/Magento/Framework/Stdlib/Cookie/CookieScopeTest.php @@ -43,6 +43,7 @@ public function testGetSensitiveCookieMetadataEmpty() [ SensitiveCookieMetadata::KEY_HTTP_ONLY => true, SensitiveCookieMetadata::KEY_SECURE => true, + SensitiveCookieMetadata::KEY_SAME_SITE => 'Lax' ], $cookieScope->getSensitiveCookieMetadata()->__toArray() ); @@ -50,11 +51,13 @@ public function testGetSensitiveCookieMetadataEmpty() $this->request->setServer(new Parameters($serverVal)); } - public function testGetPublicCookieMetadataEmpty() + public function testGetPublicCookieDefaultMetadata() { $cookieScope = $this->createCookieScope(); - - $this->assertEmpty($cookieScope->getPublicCookieMetadata()->__toArray()); + $expected = [ + PublicCookieMetadata::KEY_SAME_SITE => 'Lax' + ]; + $this->assertEquals($expected, $cookieScope->getPublicCookieMetadata()->__toArray()); } public function testGetSensitiveCookieMetadataDefaults() @@ -77,6 +80,7 @@ public function testGetSensitiveCookieMetadataDefaults() SensitiveCookieMetadata::KEY_DOMAIN => 'default domain', SensitiveCookieMetadata::KEY_HTTP_ONLY => true, SensitiveCookieMetadata::KEY_SECURE => false, + SensitiveCookieMetadata::KEY_SAME_SITE => 'Lax' ], $cookieScope->getSensitiveCookieMetadata()->__toArray() ); @@ -90,6 +94,7 @@ public function testGetPublicCookieMetadataDefaults() PublicCookieMetadata::KEY_DURATION => 'default duration', PublicCookieMetadata::KEY_HTTP_ONLY => 'default http', PublicCookieMetadata::KEY_SECURE => 'default secure', + PublicCookieMetadata::KEY_SAME_SITE => 'Lax' ]; $public = $this->createPublicMetadata($defaultValues); $cookieScope = $this->createCookieScope( @@ -139,6 +144,7 @@ public function testGetSensitiveCookieMetadataOverrides() SensitiveCookieMetadata::KEY_DOMAIN => 'override domain', SensitiveCookieMetadata::KEY_HTTP_ONLY => true, SensitiveCookieMetadata::KEY_SECURE => false, + SensitiveCookieMetadata::KEY_SAME_SITE => 'Lax' ], $cookieScope->getSensitiveCookieMetadata($override)->__toArray() ); @@ -159,6 +165,7 @@ public function testGetPublicCookieMetadataOverrides() PublicCookieMetadata::KEY_DURATION => 'override duration', PublicCookieMetadata::KEY_HTTP_ONLY => 'override http', PublicCookieMetadata::KEY_SECURE => 'override secure', + PublicCookieMetadata::KEY_SAME_SITE => 'Lax' ]; $public = $this->createPublicMetadata($defaultValues); $cookieScope = $this->createCookieScope( diff --git a/dev/tests/integration/testsuite/Magento/Framework/Translate/_files/_inline_page_expected.html b/dev/tests/integration/testsuite/Magento/Framework/Translate/_files/_inline_page_expected.html index 573f3b166db35..0afba67d3b031 100644 --- a/dev/tests/integration/testsuite/Magento/Framework/Translate/_files/_inline_page_expected.html +++ b/dev/tests/integration/testsuite/Magento/Framework/Translate/_files/_inline_page_expected.html @@ -14,12 +14,12 @@
some_text_shown_2_in_div
- - - - - - + + + + + + +``` + +The widget can then be initialized on a file upload form the following way: + +```js +$('#fileupload').fileupload(); +``` + +For further information, please refer to the following guides: + +- [Main documentation page](https://github.com/blueimp/jQuery-File-Upload/wiki) +- [List of all available Options](https://github.com/blueimp/jQuery-File-Upload/wiki/Options) +- [The plugin API](https://github.com/blueimp/jQuery-File-Upload/wiki/API) +- [How to setup the plugin on your website](https://github.com/blueimp/jQuery-File-Upload/wiki/Setup) +- [How to use only the basic plugin.](https://github.com/blueimp/jQuery-File-Upload/wiki/Basic-plugin) + +## Requirements + +### Mandatory requirements + +- [jQuery](https://jquery.com/) v1.7+ +- [jQuery UI widget factory](https://api.jqueryui.com/jQuery.widget/) v1.9+ + (included): Required for the basic File Upload plugin, but very lightweight + without any other dependencies from the jQuery UI suite. +- [jQuery Iframe Transport plugin](https://github.com/blueimp/jQuery-File-Upload/blob/master/js/jquery.iframe-transport.js) + (included): Required for + [browsers without XHR file upload support](https://github.com/blueimp/jQuery-File-Upload/wiki/Browser-support). + +### Optional requirements + +- [JavaScript Templates engine](https://github.com/blueimp/JavaScript-Templates) + v3+: Used to render the selected and uploaded files for the Basic Plus UI and + jQuery UI versions. +- [JavaScript Load Image library](https://github.com/blueimp/JavaScript-Load-Image) + v2+: Required for the image previews and resizing functionality. +- [JavaScript Canvas to Blob polyfill](https://github.com/blueimp/JavaScript-Canvas-to-Blob) + v3+:Required for the image previews and resizing functionality. +- [blueimp Gallery](https://github.com/blueimp/Gallery) v2+: Used to display the + uploaded images in a lightbox. +- [Bootstrap](https://getbootstrap.com/) v3+: Used for the demo design. +- [Glyphicons](https://glyphicons.com/) Icon set used by Bootstrap. + +### Cross-domain requirements + +[Cross-domain File Uploads](https://github.com/blueimp/jQuery-File-Upload/wiki/Cross-domain-uploads) +using the +[Iframe Transport plugin](https://github.com/blueimp/jQuery-File-Upload/blob/master/js/jquery.iframe-transport.js) +require a redirect back to the origin server to retrieve the upload results. The +[example implementation](https://github.com/blueimp/jQuery-File-Upload/blob/master/js/main.js) +makes use of +[result.html](https://github.com/blueimp/jQuery-File-Upload/blob/master/cors/result.html) +as a static redirect page for the origin server. + +The repository also includes the +[jQuery XDomainRequest Transport plugin](https://github.com/blueimp/jQuery-File-Upload/blob/master/js/cors/jquery.xdr-transport.js), +which enables limited cross-domain AJAX requests in Microsoft Internet Explorer +8 and 9 (IE 10 supports cross-domain XHR requests). +The XDomainRequest object allows GET and POST requests only and doesn't support +file uploads. It is used on the +[Demo](https://blueimp.github.io/jQuery-File-Upload/) to delete uploaded files +from the cross-domain demo file upload service. + +## Browsers + +### Desktop browsers + +The File Upload plugin is regularly tested with the latest browser versions and +supports the following minimal versions: + +- Google Chrome +- Apple Safari 4.0+ +- Mozilla Firefox 3.0+ +- Opera 11.0+ +- Microsoft Internet Explorer 6.0+ + +### Mobile browsers + +The File Upload plugin has been tested with and supports the following mobile +browsers: + +- Apple Safari on iOS 6.0+ +- Google Chrome on iOS 6.0+ +- Google Chrome on Android 4.0+ +- Default Browser on Android 2.3+ +- Opera Mobile 12.0+ + +### Extended browser support information + +For a detailed overview of the features supported by each browser version and +known operating system / browser bugs, please have a look at the +[Extended browser support information](https://github.com/blueimp/jQuery-File-Upload/wiki/Browser-support). + +## Testing + +The project comes with three sets of tests: + +1. Code linting using [ESLint](https://eslint.org/). +2. Unit tests using [Mocha](https://mochajs.org/). +3. End-to-end tests using [blueimp/wdio](https://github.com/blueimp/wdio). + +To run the tests, follow these steps: + +1. Start [Docker](https://docs.docker.com/). +2. Install development dependencies: + ```sh + npm install + ``` +3. Run the tests: + ```sh + npm test + ``` + +## Support + +This project is actively maintained, but there is no official support channel. +If you have a question that another developer might help you with, please post +to +[Stack Overflow](https://stackoverflow.com/questions/tagged/blueimp+jquery+file-upload) +and tag your question with `blueimp jquery file upload`. + +## License + +Released under the [MIT license](https://opensource.org/licenses/MIT). diff --git a/lib/web/jquery/fileUploader/SECURITY.md b/lib/web/jquery/fileUploader/SECURITY.md new file mode 100644 index 0000000000000..433a6853cdb3a --- /dev/null +++ b/lib/web/jquery/fileUploader/SECURITY.md @@ -0,0 +1,227 @@ +# File Upload Security + +## Contents + +- [Introduction](#introduction) +- [Purpose of this project](#purpose-of-this-project) +- [Mitigations against file upload risks](#mitigations-against-file-upload-risks) + - [Prevent code execution on the server](#prevent-code-execution-on-the-server) + - [Prevent code execution in the browser](#prevent-code-execution-in-the-browser) + - [Prevent distribution of malware](#prevent-distribution-of-malware) +- [Secure file upload serving configurations](#secure-file-upload-serving-configurations) + - [Apache config](#apache-config) + - [NGINX config](#nginx-config) +- [Secure image processing configurations](#secure-image-processing-configurations) +- [ImageMagick config](#imagemagick-config) + +## Introduction + +For an in-depth understanding of the potential security risks of providing file +uploads and possible mitigations, please refer to the +[OWASP - Unrestricted File Upload](https://owasp.org/www-community/vulnerabilities/Unrestricted_File_Upload) +documentation. + +To securely setup the project to serve uploaded files, please refer to the +sample +[Secure file upload serving configurations](#secure-file-upload-serving-configurations). + +To mitigate potential vulnerabilities in image processing libraries, please +refer to the +[Secure image processing configurations](#secure-image-processing-configurations). + +By default, all sample upload handlers allow only upload of image files, which +mitigates some attack vectors, but should not be relied on as the only +protection. + +Please also have a look at the +[list of fixed vulnerabilities](VULNERABILITIES.md) in jQuery File Upload, which +relates mostly to the sample server-side upload handlers and how they have been +configured. + +## Purpose of this project + +Please note that this project is not a complete file management product, but +foremost a client-side file upload library for [jQuery](https://jquery.com/). +The server-side sample upload handlers are just examples to demonstrate the +client-side file upload functionality. + +To make this very clear, there is **no user authentication** by default: + +- **everyone can upload files** +- **everyone can delete uploaded files** + +In some cases this can be acceptable, but for most projects you will want to +extend the sample upload handlers to integrate user authentication, or implement +your own. + +It is also up to you to configure your web server to securely serve the uploaded +files, e.g. using the +[sample server configurations](#secure-file-upload-serving-configurations). + +## Mitigations against file upload risks + +### Prevent code execution on the server + +To prevent execution of scripts or binaries on server-side, the upload directory +must be configured to not execute files in the upload directory (e.g. +`server/php/files` as the default for the PHP upload handler) and only treat +uploaded files as static content. + +The recommended way to do this is to configure the upload directory path to +point outside of the web application root. +Then the web server can be configured to serve files from the upload directory +with their default static files handler only. + +Limiting file uploads to a whitelist of safe file types (e.g. image files) also +mitigates this issue, but should not be the only protection. + +### Prevent code execution in the browser + +To prevent execution of scripts on client-side, the following headers must be +sent when delivering generic uploaded files to the client: + +``` +Content-Type: application/octet-stream +X-Content-Type-Options: nosniff +``` + +The `Content-Type: application/octet-stream` header instructs browsers to +display a download dialog instead of parsing it and possibly executing script +content e.g. in HTML files. + +The `X-Content-Type-Options: nosniff` header prevents browsers to try to detect +the file mime type despite the given content-type header. + +For known safe files, the content-type header can be adjusted using a +**whitelist**, e.g. sending `Content-Type: image/png` for PNG files. + +### Prevent distribution of malware + +To prevent attackers from uploading and distributing malware (e.g. computer +viruses), it is recommended to limit file uploads only to a whitelist of safe +file types. + +Please note that the detection of file types in the sample file upload handlers +is based on the file extension and not the actual file content. This makes it +still possible for attackers to upload malware by giving their files an image +file extension, but should prevent automatic execution on client computers when +opening those files. + +It does not protect at all from exploiting vulnerabilities in image display +programs, nor from users renaming file extensions to inadvertently execute the +contained malicious code. + +## Secure file upload serving configurations + +The following configurations serve uploaded files as static files with the +proper headers as +[mitigation against file upload risks](#mitigations-against-file-upload-risks). +Please do not simply copy&paste these configurations, but make sure you +understand what they are doing and that you have implemented them correctly. + +> Always test your own setup and make sure that it is secure! + +e.g. try uploading PHP scripts (as "example.php", "example.php.png" and +"example.png") to see if they get executed by your web server, e.g. the content +of the following sample: + +```php +GIF89ad + # Some of the directives require the Apache Headers module. If it is not + # already enabled, please execute the following command and reload Apache: + # sudo a2enmod headers + # + # Please note that the order of directives across configuration files matters, + # see also: + # https://httpd.apache.org/docs/current/sections.html#merging + + # The following directive matches all files and forces them to be handled as + # static content, which prevents the server from parsing and executing files + # that are associated with a dynamic runtime, e.g. PHP files. + # It also forces their Content-Type header to "application/octet-stream" and + # adds a "Content-Disposition: attachment" header to force a download dialog, + # which prevents browsers from interpreting files in the context of the + # web server, e.g. HTML files containing JavaScript. + # Lastly it also prevents browsers from MIME-sniffing the Content-Type, + # preventing them from interpreting a file as a different Content-Type than + # the one sent by the webserver. + + SetHandler default-handler + ForceType application/octet-stream + Header set Content-Disposition attachment + Header set X-Content-Type-Options nosniff + + + # The following directive matches known image files and unsets the forced + # Content-Type so they can be served with their original mime type. + # It also unsets the Content-Disposition header to allow displaying them + # inline in the browser. + + ForceType none + Header unset Content-Disposition + + +``` + +### NGINX config + +Add the following directive to the NGINX config, replacing the directory path +with the absolute path to the upload directory: + +```Nginx +location ^~ /path/to/project/server/php/files { + root html; + default_type application/octet-stream; + types { + image/gif gif; + image/jpeg jpg; + image/png png; + } + add_header X-Content-Type-Options 'nosniff'; + if ($request_filename ~ /(((?!\.(jpg)|(png)|(gif)$)[^/])+$)) { + add_header Content-Disposition 'attachment; filename="$1"'; + # Add X-Content-Type-Options again, as using add_header in a new context + # dismisses all previous add_header calls: + add_header X-Content-Type-Options 'nosniff'; + } +} +``` + +## Secure image processing configurations + +The following configuration mitigates +[potential image processing vulnerabilities with ImageMagick](VULNERABILITIES.md#potential-vulnerabilities-with-php-imagemagick) +by limiting the attack vectors to a small subset of image types +(`GIF/JPEG/PNG`). + +Please also consider using alternative, safer image processing libraries like +[libvips](https://github.com/libvips/libvips) or +[imageflow](https://github.com/imazen/imageflow). + +## ImageMagick config + +It is recommended to disable all non-required ImageMagick coders via +[policy.xml](https://wiki.debian.org/imagemagick/security). +To do so, locate the ImageMagick `policy.xml` configuration file and add the +following policies: + +```xml + + + + + + + + +``` diff --git a/lib/web/jquery/fileUploader/canvas-to-blob.js b/lib/web/jquery/fileUploader/canvas-to-blob.js deleted file mode 100644 index 4e855b9f3a592..0000000000000 --- a/lib/web/jquery/fileUploader/canvas-to-blob.js +++ /dev/null @@ -1 +0,0 @@ -(function(a){"use strict";var b=a.HTMLCanvasElement&&a.HTMLCanvasElement.prototype,c=a.Blob&&function(){try{return Boolean(new Blob)}catch(a){return!1}}(),d=c&&a.Uint8Array&&function(){try{return(new Blob([new Uint8Array(100)])).size===100}catch(a){return!1}}(),e=a.BlobBuilder||a.WebKitBlobBuilder||a.MozBlobBuilder||a.MSBlobBuilder,f=(c||e)&&a.atob&&a.ArrayBuffer&&a.Uint8Array&&function(a){var b,f,g,h,i,j;a.split(",")[0].indexOf("base64")>=0?b=atob(a.split(",")[1]):b=decodeURIComponent(a.split(",")[1]),f=new ArrayBuffer(b.length),g=new Uint8Array(f);for(h=0;h').prop('href', options.postMessage)[0], - target = loc.protocol + '//' + loc.host, - xhrUpload = options.xhr().upload; - return { - send: function (_, completeCallback) { - var message = { - id: 'postmessage-transport-' + (counter += 1) - }, - eventName = 'message.' + message.id; - iframe = $( - '' - ).bind('load', function () { - $.each(names, function (i, name) { - message[name] = options[name]; - }); - message.dataType = message.dataType.replace('postmessage ', ''); - $(window).bind(eventName, function (e) { - e = e.originalEvent; - var data = e.data, - ev; - if (e.origin === target && data.id === message.id) { - if (data.type === 'progress') { - ev = document.createEvent('Event'); - ev.initEvent(data.type, false, true); - $.extend(ev, data); - xhrUpload.dispatchEvent(ev); - } else { - completeCallback( - data.status, - data.statusText, - {postmessage: data.result}, - data.headers - ); - iframe.remove(); - $(window).unbind(eventName); - } - } - }); - iframe[0].contentWindow.postMessage( - message, - target - ); - }).appendTo(document.body); - }, - abort: function () { - if (iframe) { - iframe.remove(); - } + $.ajaxTransport('postmessage', function (options) { + if (options.postMessage && window.postMessage) { + var iframe, + loc = $('').prop('href', options.postMessage)[0], + target = loc.protocol + '//' + loc.host, + xhrUpload = options.xhr().upload; + // IE always includes the port for the host property of a link + // element, but not in the location.host or origin property for the + // default http port 80 and https port 443, so we strip it: + if (/^(http:\/\/.+:80)|(https:\/\/.+:443)$/.test(target)) { + target = target.replace(/:(80|443)$/, ''); + } + return { + send: function (_, completeCallback) { + counter += 1; + var message = { + id: 'postmessage-transport-' + counter + }, + eventName = 'message.' + message.id; + iframe = $( + '' + ) + .on('load', function () { + $.each(names, function (i, name) { + message[name] = options[name]; + }); + message.dataType = message.dataType.replace('postmessage ', ''); + $(window).on(eventName, function (event) { + var e = event.originalEvent; + var data = e.data; + var ev; + if (e.origin === target && data.id === message.id) { + if (data.type === 'progress') { + ev = document.createEvent('Event'); + ev.initEvent(data.type, false, true); + $.extend(ev, data); + xhrUpload.dispatchEvent(ev); + } else { + completeCallback( + data.status, + data.statusText, + { postmessage: data.result }, + data.headers + ); + iframe.remove(); + $(window).off(eventName); + } } - }; + }); + iframe[0].contentWindow.postMessage(message, target); + }) + .appendTo(document.body); + }, + abort: function () { + if (iframe) { + iframe.remove(); + } } - }); - -})); + }; + } + }); +}); diff --git a/lib/web/jquery/fileUploader/cors/jquery.xdr-transport.js b/lib/web/jquery/fileUploader/cors/jquery.xdr-transport.js index c42c54828d8ff..9e81860b943fc 100644 --- a/lib/web/jquery/fileUploader/cors/jquery.xdr-transport.js +++ b/lib/web/jquery/fileUploader/cors/jquery.xdr-transport.js @@ -1,85 +1,97 @@ /* - * jQuery XDomainRequest Transport Plugin 1.1.2 + * jQuery XDomainRequest Transport Plugin * https://github.com/blueimp/jQuery-File-Upload * * Copyright 2011, Sebastian Tschan * https://blueimp.net * * Licensed under the MIT license: - * http://www.opensource.org/licenses/MIT + * https://opensource.org/licenses/MIT * * Based on Julian Aubourg's ajaxHooks xdr.js: * https://github.com/jaubourg/ajaxHooks/ */ -/*jslint unparam: true */ -/*global define, window, XDomainRequest */ +/* global define, require, XDomainRequest */ (function (factory) { - 'use strict'; - if (typeof define === 'function' && define.amd) { - // Register as an anonymous AMD module: - define(['jquery'], factory); - } else { - // Browser globals: - factory(window.jQuery); - } -}(function ($) { - 'use strict'; - if (window.XDomainRequest && !$.support.cors) { - $.ajaxTransport(function (s) { - if (s.crossDomain && s.async) { - if (s.timeout) { - s.xdrTimeout = s.timeout; - delete s.timeout; - } - var xdr; - return { - send: function (headers, completeCallback) { - function callback(status, statusText, responses, responseHeaders) { - xdr.onload = xdr.onerror = xdr.ontimeout = $.noop; - xdr = null; - completeCallback(status, statusText, responses, responseHeaders); - } - xdr = new XDomainRequest(); - // XDomainRequest only supports GET and POST: - if (s.type === 'DELETE') { - s.url = s.url + (/\?/.test(s.url) ? '&' : '?') + - '_method=DELETE'; - s.type = 'POST'; - } else if (s.type === 'PUT') { - s.url = s.url + (/\?/.test(s.url) ? '&' : '?') + - '_method=PUT'; - s.type = 'POST'; - } - xdr.open(s.type, s.url); - xdr.onload = function () { - callback( - 200, - 'OK', - {text: xdr.responseText}, - 'Content-Type: ' + xdr.contentType - ); - }; - xdr.onerror = function () { - callback(404, 'Not Found'); - }; - if (s.xdrTimeout) { - xdr.ontimeout = function () { - callback(0, 'timeout'); - }; - xdr.timeout = s.xdrTimeout; - } - xdr.send((s.hasContent && s.data) || null); - }, - abort: function () { - if (xdr) { - xdr.onerror = $.noop(); - xdr.abort(); - } - } - }; + 'use strict'; + if (typeof define === 'function' && define.amd) { + // Register as an anonymous AMD module: + define(['jquery'], factory); + } else if (typeof exports === 'object') { + // Node/CommonJS: + factory(require('jquery')); + } else { + // Browser globals: + factory(window.jQuery); + } +})(function ($) { + 'use strict'; + if (window.XDomainRequest && !$.support.cors) { + $.ajaxTransport(function (s) { + if (s.crossDomain && s.async) { + if (s.timeout) { + s.xdrTimeout = s.timeout; + delete s.timeout; + } + var xdr; + return { + send: function (headers, completeCallback) { + var addParamChar = /\?/.test(s.url) ? '&' : '?'; + /** + * Callback wrapper function + * + * @param {number} status HTTP status code + * @param {string} statusText HTTP status text + * @param {object} [responses] Content-type specific responses + * @param {string} [responseHeaders] Response headers string + */ + function callback(status, statusText, responses, responseHeaders) { + xdr.onload = xdr.onerror = xdr.ontimeout = $.noop; + xdr = null; + completeCallback(status, statusText, responses, responseHeaders); } - }); - } -})); + xdr = new XDomainRequest(); + // XDomainRequest only supports GET and POST: + if (s.type === 'DELETE') { + s.url = s.url + addParamChar + '_method=DELETE'; + s.type = 'POST'; + } else if (s.type === 'PUT') { + s.url = s.url + addParamChar + '_method=PUT'; + s.type = 'POST'; + } else if (s.type === 'PATCH') { + s.url = s.url + addParamChar + '_method=PATCH'; + s.type = 'POST'; + } + xdr.open(s.type, s.url); + xdr.onload = function () { + callback( + 200, + 'OK', + { text: xdr.responseText }, + 'Content-Type: ' + xdr.contentType + ); + }; + xdr.onerror = function () { + callback(404, 'Not Found'); + }; + if (s.xdrTimeout) { + xdr.ontimeout = function () { + callback(0, 'timeout'); + }; + xdr.timeout = s.xdrTimeout; + } + xdr.send((s.hasContent && s.data) || null); + }, + abort: function () { + if (xdr) { + xdr.onerror = $.noop(); + xdr.abort(); + } + } + }; + } + }); + } +}); diff --git a/lib/web/jquery/fileUploader/css/jquery.fileupload-noscript.css b/lib/web/jquery/fileUploader/css/jquery.fileupload-noscript.css new file mode 100644 index 0000000000000..2409bfb0a6942 --- /dev/null +++ b/lib/web/jquery/fileUploader/css/jquery.fileupload-noscript.css @@ -0,0 +1,22 @@ +@charset "UTF-8"; +/* + * jQuery File Upload Plugin NoScript CSS + * https://github.com/blueimp/jQuery-File-Upload + * + * Copyright 2013, Sebastian Tschan + * https://blueimp.net + * + * Licensed under the MIT license: + * https://opensource.org/licenses/MIT + */ + +.fileinput-button input { + position: static; + opacity: 1; + filter: none; + font-size: inherit !important; + direction: inherit; +} +.fileinput-button span { + display: none; +} diff --git a/lib/web/jquery/fileUploader/css/jquery.fileupload-ui-noscript.css b/lib/web/jquery/fileUploader/css/jquery.fileupload-ui-noscript.css new file mode 100644 index 0000000000000..30651acf026c0 --- /dev/null +++ b/lib/web/jquery/fileUploader/css/jquery.fileupload-ui-noscript.css @@ -0,0 +1,17 @@ +@charset "UTF-8"; +/* + * jQuery File Upload UI Plugin NoScript CSS + * https://github.com/blueimp/jQuery-File-Upload + * + * Copyright 2012, Sebastian Tschan + * https://blueimp.net + * + * Licensed under the MIT license: + * https://opensource.org/licenses/MIT + */ + +.fileinput-button i, +.fileupload-buttonbar .delete, +.fileupload-buttonbar .toggle { + display: none; +} diff --git a/lib/web/jquery/fileUploader/css/jquery.fileupload-ui.css b/lib/web/jquery/fileUploader/css/jquery.fileupload-ui.css index 44b628efb481c..a6cfc7529198b 100644 --- a/lib/web/jquery/fileUploader/css/jquery.fileupload-ui.css +++ b/lib/web/jquery/fileUploader/css/jquery.fileupload-ui.css @@ -1,73 +1,68 @@ -@charset 'UTF-8'; +@charset "UTF-8"; /* - * jQuery File Upload UI Plugin CSS 6.3 + * jQuery File Upload UI Plugin CSS * https://github.com/blueimp/jQuery-File-Upload * * Copyright 2010, Sebastian Tschan * https://blueimp.net * * Licensed under the MIT license: - * http://www.opensource.org/licenses/MIT + * https://opensource.org/licenses/MIT */ -.fileinput-button { - position: relative; - overflow: hidden; - float: left; - margin-right: 4px; -} -.fileinput-button input { - position: absolute; - top: 0; - right: 0; - margin: 0; - border: solid transparent; - border-width: 0 0 100px 200px; - opacity: 0; - filter: alpha(opacity=0); - -moz-transform: translate(-300px, 0) scale(4); - direction: ltr; - cursor: pointer; -} -.fileupload-buttonbar .btn, -.fileupload-buttonbar .toggle { - margin-bottom: 5px; -} -.files .progress { - width: 200px; -} +.progress-animated .progress-bar, .progress-animated .bar { - background: url(../img/progressbar.gif) !important; + background: url('../img/progressbar.gif') !important; filter: none; } -.fileupload-loading { - position: absolute; - left: 50%; - width: 128px; - height: 128px; - background: url(../img/loading.gif) center no-repeat; +.fileupload-process { + float: right; display: none; } -.fileupload-processing .fileupload-loading { +.fileupload-processing .fileupload-process, +.files .processing .preview { display: block; + width: 32px; + height: 32px; + background: url('../img/loading.gif') center no-repeat; + background-size: contain; +} +.files audio, +.files video { + max-width: 300px; +} +.files .name { + word-wrap: break-word; + overflow-wrap: anywhere; + -webkit-hyphens: auto; + hyphens: auto; +} +.files button { + margin-bottom: 5px; +} +.toggle[type='checkbox'] { + transform: scale(2); + margin-left: 10px; } -@media (max-width: 480px) { +@media (max-width: 767px) { + .fileupload-buttonbar .btn { + margin-bottom: 5px; + } + .fileupload-buttonbar .delete, + .fileupload-buttonbar .toggle, + .files .toggle, .files .btn span { display: none; } - .files .preview * { - width: 40px; - } - .files .name * { - width: 80px; - display: inline-block; - word-wrap: break-word; - } - .files .progress { - width: 20px; + .files audio, + .files video { + max-width: 80px; } - .files .delete { - width: 60px; +} + +@media (max-width: 480px) { + .files .image td:nth-child(2) { + display: none; } } diff --git a/lib/web/jquery/fileUploader/css/jquery.fileupload.css b/lib/web/jquery/fileUploader/css/jquery.fileupload.css new file mode 100644 index 0000000000000..5716f3e8a8aea --- /dev/null +++ b/lib/web/jquery/fileUploader/css/jquery.fileupload.css @@ -0,0 +1,36 @@ +@charset "UTF-8"; +/* + * jQuery File Upload Plugin CSS + * https://github.com/blueimp/jQuery-File-Upload + * + * Copyright 2013, Sebastian Tschan + * https://blueimp.net + * + * Licensed under the MIT license: + * https://opensource.org/licenses/MIT + */ + +.fileinput-button { + position: relative; + overflow: hidden; + display: inline-block; +} +.fileinput-button input { + position: absolute; + top: 0; + right: 0; + margin: 0; + height: 100%; + opacity: 0; + filter: alpha(opacity=0); + font-size: 200px !important; + direction: ltr; + cursor: pointer; +} + +/* Fixes for IE < 8 */ +@media screen\9 { + .fileinput-button input { + font-size: 150% !important; + } +} diff --git a/lib/web/jquery/fileUploader/img/loading.gif b/lib/web/jquery/fileUploader/img/loading.gif index 4ae663fa730eb..90f28cbdbb390 100644 Binary files a/lib/web/jquery/fileUploader/img/loading.gif and b/lib/web/jquery/fileUploader/img/loading.gif differ diff --git a/lib/web/jquery/fileUploader/img/progressbar.gif b/lib/web/jquery/fileUploader/img/progressbar.gif index 74bb94e8e5d2b..fbcce6bc9abfc 100644 Binary files a/lib/web/jquery/fileUploader/img/progressbar.gif and b/lib/web/jquery/fileUploader/img/progressbar.gif differ diff --git a/lib/web/jquery/fileUploader/jquery.fileupload-audio.js b/lib/web/jquery/fileUploader/jquery.fileupload-audio.js new file mode 100644 index 0000000000000..1435ef20af2f6 --- /dev/null +++ b/lib/web/jquery/fileUploader/jquery.fileupload-audio.js @@ -0,0 +1,101 @@ +/* + * jQuery File Upload Audio Preview Plugin + * https://github.com/blueimp/jQuery-File-Upload + * + * Copyright 2013, Sebastian Tschan + * https://blueimp.net + * + * Licensed under the MIT license: + * https://opensource.org/licenses/MIT + */ + +/* global define, require */ + +(function (factory) { + 'use strict'; + if (typeof define === 'function' && define.amd) { + // Register as an anonymous AMD module: + define(['jquery', 'jquery/fileUploader/vendor/blueimp-load-image/js/load-image', 'jquery/fileUploader/jquery.fileupload-process'], factory); + } else if (typeof exports === 'object') { + // Node/CommonJS: + factory( + require('jquery'), + require('jquery/fileUploader/vendor/blueimp-load-image/js/load-image'), + require('jquery/fileUploader/jquery.fileupload-process') + ); + } else { + // Browser globals: + factory(window.jQuery, window.loadImage); + } +})(function ($, loadImage) { + 'use strict'; + + // Prepend to the default processQueue: + $.blueimp.fileupload.prototype.options.processQueue.unshift( + { + action: 'loadAudio', + // Use the action as prefix for the "@" options: + prefix: true, + fileTypes: '@', + maxFileSize: '@', + disabled: '@disableAudioPreview' + }, + { + action: 'setAudio', + name: '@audioPreviewName', + disabled: '@disableAudioPreview' + } + ); + + // The File Upload Audio Preview plugin extends the fileupload widget + // with audio preview functionality: + $.widget('blueimp.fileupload', $.blueimp.fileupload, { + options: { + // The regular expression for the types of audio files to load, + // matched against the file type: + loadAudioFileTypes: /^audio\/.*$/ + }, + + _audioElement: document.createElement('audio'), + + processActions: { + // Loads the audio file given via data.files and data.index + // as audio element if the browser supports playing it. + // Accepts the options fileTypes (regular expression) + // and maxFileSize (integer) to limit the files to load: + loadAudio: function (data, options) { + if (options.disabled) { + return data; + } + var file = data.files[data.index], + url, + audio; + if ( + this._audioElement.canPlayType && + this._audioElement.canPlayType(file.type) && + ($.type(options.maxFileSize) !== 'number' || + file.size <= options.maxFileSize) && + (!options.fileTypes || options.fileTypes.test(file.type)) + ) { + url = loadImage.createObjectURL(file); + if (url) { + audio = this._audioElement.cloneNode(false); + audio.src = url; + audio.controls = true; + data.audio = audio; + return data; + } + } + return data; + }, + + // Sets the audio element as a property of the file object: + setAudio: function (data, options) { + if (data.audio && !options.disabled) { + data.files[data.index][options.name || 'preview'] = data.audio; + } + return data; + } + } + }); +}); diff --git a/lib/web/jquery/fileUploader/jquery.fileupload-fp.js b/lib/web/jquery/fileUploader/jquery.fileupload-fp.js deleted file mode 100644 index ee8f46342a93a..0000000000000 --- a/lib/web/jquery/fileUploader/jquery.fileupload-fp.js +++ /dev/null @@ -1,219 +0,0 @@ -/* - * jQuery File Upload File Processing Plugin 1.0 - * https://github.com/blueimp/jQuery-File-Upload - * - * Copyright 2012, Sebastian Tschan - * https://blueimp.net - * - * Licensed under the MIT license: - * http://www.opensource.org/licenses/MIT - */ - -/*jslint nomen: true, unparam: true, regexp: true */ -/*global define, window, document */ - -(function (factory) { - 'use strict'; - if (typeof define === 'function' && define.amd) { - // Register as an anonymous AMD module: - define([ - 'jquery', - 'jquery/fileUploader/load-image', - 'jquery/fileUploader/canvas-to-blob', - 'jquery/fileUploader/jquery.fileupload' - ], factory); - } else { - // Browser globals: - factory( - window.jQuery, - window.loadImage - ); - } -}(function ($, loadImage) { - 'use strict'; - - // The File Upload IP version extends the basic fileupload widget - // with file processing functionality: - $.widget('blueimpFP.fileupload', $.blueimp.fileupload, { - - options: { - // The list of file processing actions: - process: [ - /* - { - action: 'load', - fileTypes: /^image\/(gif|jpeg|png)$/, - maxFileSize: 20000000 // 20MB - }, - { - action: 'resize', - maxWidth: 1920, - maxHeight: 1200, - minWidth: 800, - minHeight: 600 - }, - { - action: 'save' - } - */ - ], - - // The add callback is invoked as soon as files are added to the - // fileupload widget (via file input selection, drag & drop or add - // API call). See the basic file upload widget for more information: - add: function (e, data) { - $(this).fileupload('process', data).done(function () { - data.submit(); - }); - } - }, - - processActions: { - // Loads the image given via data.files and data.index - // as canvas element. - // Accepts the options fileTypes (regular expression) - // and maxFileSize (integer) to limit the files to load: - load: function (data, options) { - var that = this, - file = data.files[data.index], - dfd = $.Deferred(); - if (window.HTMLCanvasElement && - window.HTMLCanvasElement.prototype.toBlob && - ($.type(options.maxFileSize) !== 'number' || - file.size < options.maxFileSize) && - (!options.fileTypes || - options.fileTypes.test(file.type))) { - loadImage( - file, - function (canvas) { - data.canvas = canvas; - dfd.resolveWith(that, [data]); - }, - {canvas: true} - ); - } else { - dfd.rejectWith(that, [data]); - } - return dfd.promise(); - }, - // Resizes the image given as data.canvas and updates - // data.canvas with the resized image. - // Accepts the options maxWidth, maxHeight, minWidth and - // minHeight to scale the given image: - resize: function (data, options) { - if (data.canvas) { - var canvas = loadImage.scale(data.canvas, options); - if (canvas.width !== data.canvas.width || - canvas.height !== data.canvas.height) { - data.canvas = canvas; - data.processed = true; - } - } - return data; - }, - // Saves the processed image given as data.canvas - // inplace at data.index of data.files: - save: function (data, options) { - // Do nothing if no processing has happened: - if (!data.canvas || !data.processed) { - return data; - } - var that = this, - file = data.files[data.index], - name = file.name, - dfd = $.Deferred(), - callback = function (blob) { - if (!blob.name) { - if (file.type === blob.type) { - blob.name = file.name; - } else if (file.name) { - blob.name = file.name.replace( - /\..+$/, - '.' + blob.type.substr(6) - ); - } - } - // Store the created blob at the position - // of the original file in the files list: - data.files[data.index] = blob; - dfd.resolveWith(that, [data]); - }; - // Use canvas.mozGetAsFile directly, to retain the filename, as - // Gecko doesn't support the filename option for FormData.append: - if (data.canvas.mozGetAsFile) { - callback(data.canvas.mozGetAsFile( - (/^image\/(jpeg|png)$/.test(file.type) && name) || - ((name && name.replace(/\..+$/, '')) || - 'blob') + '.png', - file.type - )); - } else { - data.canvas.toBlob(callback, file.type); - } - return dfd.promise(); - } - }, - - // Resizes the file at the given index and stores the created blob at - // the original position of the files list, returns a Promise object: - _processFile: function (files, index, options) { - var that = this, - dfd = $.Deferred().resolveWith(that, [{ - files: files, - index: index - }]), - chain = dfd.promise(); - that._processing += 1; - $.each(options.process, function (i, settings) { - chain = chain.pipe(function (data) { - return that.processActions[settings.action] - .call(this, data, settings); - }); - }); - chain.always(function () { - that._processing -= 1; - if (that._processing === 0) { - that.element - .removeClass('fileupload-processing'); - } - }); - if (that._processing === 1) { - that.element.addClass('fileupload-processing'); - } - return chain; - }, - - // Processes the files given as files property of the data parameter, - // returns a Promise object that allows to bind a done handler, which - // will be invoked after processing all files (inplace) is done: - process: function (data) { - var that = this, - options = $.extend({}, this.options, data); - if (options.process && options.process.length && - this._isXHRUpload(options)) { - $.each(data.files, function (index, file) { - that._processingQueue = that._processingQueue.pipe( - function () { - var dfd = $.Deferred(); - that._processFile(data.files, index, options) - .always(function () { - dfd.resolveWith(that); - }); - return dfd.promise(); - } - ); - }); - } - return this._processingQueue; - }, - - _create: function () { - $.blueimp.fileupload.prototype._create.call(this); - this._processing = 0; - this._processingQueue = $.Deferred().resolveWith(this) - .promise(); - } - - }); - -})); diff --git a/lib/web/jquery/fileUploader/jquery.fileupload-image.js b/lib/web/jquery/fileUploader/jquery.fileupload-image.js new file mode 100644 index 0000000000000..11c63c236247c --- /dev/null +++ b/lib/web/jquery/fileUploader/jquery.fileupload-image.js @@ -0,0 +1,346 @@ +/* + * jQuery File Upload Image Preview & Resize Plugin + * https://github.com/blueimp/jQuery-File-Upload + * + * Copyright 2013, Sebastian Tschan + * https://blueimp.net + * + * Licensed under the MIT license: + * https://opensource.org/licenses/MIT + */ + +/* global define, require */ + +(function (factory) { + 'use strict'; + if (typeof define === 'function' && define.amd) { + // Register as an anonymous AMD module: + define([ + 'jquery', + 'jquery/fileUploader/vendor/blueimp-load-image/js/load-image', + 'jquery/fileUploader/vendor/blueimp-load-image/js/load-image-meta', + 'jquery/fileUploader/vendor/blueimp-load-image/js/load-image-scale', + 'jquery/fileUploader/vendor/blueimp-load-image/js/load-image-exif', + 'jquery/fileUploader/vendor/blueimp-load-image/js/load-image-orientation', + 'jquery/fileUploader/vendor/blueimp-canvas-to-blob/js/canvas-to-blob', + 'jquery/fileUploader/jquery.fileupload-process' + ], factory); + } else if (typeof exports === 'object') { + // Node/CommonJS: + factory( + require('jquery'), + require('jquery/fileUploader/vendor/blueimp-load-image/js/load-image'), + require('jquery/fileUploader/vendor/blueimp-load-image/js/load-image-meta'), + require('jquery/fileUploader/vendor/blueimp-load-image/js/load-image-scale'), + require('jquery/fileUploader/vendor/blueimp-load-image/js/load-image-exif'), + require('jquery/fileUploader/vendor/blueimp-load-image/js/load-image-orientation'), + require('jquery/fileUploader/vendor/blueimp-canvas-to-blob/js/canvas-to-blob'), + require('jquery/fileUploader/jquery.fileupload-process') + ); + } else { + // Browser globals: + factory(window.jQuery, window.loadImage); + } +})(function ($, loadImage) { + 'use strict'; + + // Prepend to the default processQueue: + $.blueimp.fileupload.prototype.options.processQueue.unshift( + { + action: 'loadImageMetaData', + maxMetaDataSize: '@', + disableImageHead: '@', + disableMetaDataParsers: '@', + disableExif: '@', + disableExifOffsets: '@', + includeExifTags: '@', + excludeExifTags: '@', + disableIptc: '@', + disableIptcOffsets: '@', + includeIptcTags: '@', + excludeIptcTags: '@', + disabled: '@disableImageMetaDataLoad' + }, + { + action: 'loadImage', + // Use the action as prefix for the "@" options: + prefix: true, + fileTypes: '@', + maxFileSize: '@', + noRevoke: '@', + disabled: '@disableImageLoad' + }, + { + action: 'resizeImage', + // Use "image" as prefix for the "@" options: + prefix: 'image', + maxWidth: '@', + maxHeight: '@', + minWidth: '@', + minHeight: '@', + crop: '@', + orientation: '@', + forceResize: '@', + disabled: '@disableImageResize' + }, + { + action: 'saveImage', + quality: '@imageQuality', + type: '@imageType', + disabled: '@disableImageResize' + }, + { + action: 'saveImageMetaData', + disabled: '@disableImageMetaDataSave' + }, + { + action: 'resizeImage', + // Use "preview" as prefix for the "@" options: + prefix: 'preview', + maxWidth: '@', + maxHeight: '@', + minWidth: '@', + minHeight: '@', + crop: '@', + orientation: '@', + thumbnail: '@', + canvas: '@', + disabled: '@disableImagePreview' + }, + { + action: 'setImage', + name: '@imagePreviewName', + disabled: '@disableImagePreview' + }, + { + action: 'deleteImageReferences', + disabled: '@disableImageReferencesDeletion' + } + ); + + // The File Upload Resize plugin extends the fileupload widget + // with image resize functionality: + $.widget('blueimp.fileupload', $.blueimp.fileupload, { + options: { + // The regular expression for the types of images to load: + // matched against the file type: + loadImageFileTypes: /^image\/(gif|jpeg|png|svg\+xml)$/, + // The maximum file size of images to load: + loadImageMaxFileSize: 10000000, // 10MB + // The maximum width of resized images: + imageMaxWidth: 1920, + // The maximum height of resized images: + imageMaxHeight: 1080, + // Defines the image orientation (1-8) or takes the orientation + // value from Exif data if set to true: + imageOrientation: true, + // Define if resized images should be cropped or only scaled: + imageCrop: false, + // Disable the resize image functionality by default: + disableImageResize: true, + // The maximum width of the preview images: + previewMaxWidth: 80, + // The maximum height of the preview images: + previewMaxHeight: 80, + // Defines the preview orientation (1-8) or takes the orientation + // value from Exif data if set to true: + previewOrientation: true, + // Create the preview using the Exif data thumbnail: + previewThumbnail: true, + // Define if preview images should be cropped or only scaled: + previewCrop: false, + // Define if preview images should be resized as canvas elements: + previewCanvas: true + }, + + processActions: { + // Loads the image given via data.files and data.index + // as img element, if the browser supports the File API. + // Accepts the options fileTypes (regular expression) + // and maxFileSize (integer) to limit the files to load: + loadImage: function (data, options) { + if (options.disabled) { + return data; + } + var that = this, + file = data.files[data.index], + // eslint-disable-next-line new-cap + dfd = $.Deferred(); + if ( + ($.type(options.maxFileSize) === 'number' && + file.size > options.maxFileSize) || + (options.fileTypes && !options.fileTypes.test(file.type)) || + !loadImage( + file, + function (img) { + if (img.src) { + data.img = img; + } + dfd.resolveWith(that, [data]); + }, + options + ) + ) { + return data; + } + return dfd.promise(); + }, + + // Resizes the image given as data.canvas or data.img + // and updates data.canvas or data.img with the resized image. + // Also stores the resized image as preview property. + // Accepts the options maxWidth, maxHeight, minWidth, + // minHeight, canvas and crop: + resizeImage: function (data, options) { + if (options.disabled || !(data.canvas || data.img)) { + return data; + } + // eslint-disable-next-line no-param-reassign + options = $.extend({ canvas: true }, options); + var that = this, + // eslint-disable-next-line new-cap + dfd = $.Deferred(), + img = (options.canvas && data.canvas) || data.img, + resolve = function (newImg) { + if ( + newImg && + (newImg.width !== img.width || + newImg.height !== img.height || + options.forceResize) + ) { + data[newImg.getContext ? 'canvas' : 'img'] = newImg; + } + data.preview = newImg; + dfd.resolveWith(that, [data]); + }, + thumbnail, + thumbnailBlob; + if (data.exif && options.thumbnail) { + thumbnail = data.exif.get('Thumbnail'); + thumbnailBlob = thumbnail && thumbnail.get('Blob'); + if (thumbnailBlob) { + options.orientation = data.exif.get('Orientation'); + loadImage(thumbnailBlob, resolve, options); + return dfd.promise(); + } + } + if (data.orientation) { + // Prevent orienting the same image twice: + delete options.orientation; + } else { + data.orientation = options.orientation || loadImage.orientation; + } + if (img) { + resolve(loadImage.scale(img, options, data)); + return dfd.promise(); + } + return data; + }, + + // Saves the processed image given as data.canvas + // inplace at data.index of data.files: + saveImage: function (data, options) { + if (!data.canvas || options.disabled) { + return data; + } + var that = this, + file = data.files[data.index], + // eslint-disable-next-line new-cap + dfd = $.Deferred(); + if (data.canvas.toBlob) { + data.canvas.toBlob( + function (blob) { + if (!blob.name) { + if (file.type === blob.type) { + blob.name = file.name; + } else if (file.name) { + blob.name = file.name.replace( + /\.\w+$/, + '.' + blob.type.substr(6) + ); + } + } + // Don't restore invalid meta data: + if (file.type !== blob.type) { + delete data.imageHead; + } + // Store the created blob at the position + // of the original file in the files list: + data.files[data.index] = blob; + dfd.resolveWith(that, [data]); + }, + options.type || file.type, + options.quality + ); + } else { + return data; + } + return dfd.promise(); + }, + + loadImageMetaData: function (data, options) { + if (options.disabled) { + return data; + } + var that = this, + // eslint-disable-next-line new-cap + dfd = $.Deferred(); + loadImage.parseMetaData( + data.files[data.index], + function (result) { + $.extend(data, result); + dfd.resolveWith(that, [data]); + }, + options + ); + return dfd.promise(); + }, + + saveImageMetaData: function (data, options) { + if ( + !( + data.imageHead && + data.canvas && + data.canvas.toBlob && + !options.disabled + ) + ) { + return data; + } + var that = this, + file = data.files[data.index], + // eslint-disable-next-line new-cap + dfd = $.Deferred(); + if (data.orientation === true && data.exifOffsets) { + // Reset Exif Orientation data: + loadImage.writeExifData(data.imageHead, data, 'Orientation', 1); + } + loadImage.replaceHead(file, data.imageHead, function (blob) { + blob.name = file.name; + data.files[data.index] = blob; + dfd.resolveWith(that, [data]); + }); + return dfd.promise(); + }, + + // Sets the resized version of the image as a property of the + // file object, must be called after "saveImage": + setImage: function (data, options) { + if (data.preview && !options.disabled) { + data.files[data.index][options.name || 'preview'] = data.preview; + } + return data; + }, + + deleteImageReferences: function (data, options) { + if (!options.disabled) { + delete data.img; + delete data.canvas; + delete data.preview; + delete data.imageHead; + } + return data; + } + } + }); +}); diff --git a/lib/web/jquery/fileUploader/jquery.fileupload-process.js b/lib/web/jquery/fileUploader/jquery.fileupload-process.js new file mode 100644 index 0000000000000..a2f1009e508d1 --- /dev/null +++ b/lib/web/jquery/fileUploader/jquery.fileupload-process.js @@ -0,0 +1,170 @@ +/* + * jQuery File Upload Processing Plugin + * https://github.com/blueimp/jQuery-File-Upload + * + * Copyright 2012, Sebastian Tschan + * https://blueimp.net + * + * Licensed under the MIT license: + * https://opensource.org/licenses/MIT + */ + +/* global define, require */ + +(function (factory) { + 'use strict'; + if (typeof define === 'function' && define.amd) { + // Register as an anonymous AMD module: + define(['jquery', 'jquery/fileUploader/jquery.fileupload'], factory); + } else if (typeof exports === 'object') { + // Node/CommonJS: + factory(require('jquery'), require('jquery/fileUploader/jquery.fileupload')); + } else { + // Browser globals: + factory(window.jQuery); + } +})(function ($) { + 'use strict'; + + var originalAdd = $.blueimp.fileupload.prototype.options.add; + + // The File Upload Processing plugin extends the fileupload widget + // with file processing functionality: + $.widget('blueimp.fileupload', $.blueimp.fileupload, { + options: { + // The list of processing actions: + processQueue: [ + /* + { + action: 'log', + type: 'debug' + } + */ + ], + add: function (e, data) { + var $this = $(this); + data.process(function () { + return $this.fileupload('process', data); + }); + originalAdd.call(this, e, data); + } + }, + + processActions: { + /* + log: function (data, options) { + console[options.type]( + 'Processing "' + data.files[data.index].name + '"' + ); + } + */ + }, + + _processFile: function (data, originalData) { + var that = this, + // eslint-disable-next-line new-cap + dfd = $.Deferred().resolveWith(that, [data]), + chain = dfd.promise(); + this._trigger('process', null, data); + $.each(data.processQueue, function (i, settings) { + var func = function (data) { + if (originalData.errorThrown) { + // eslint-disable-next-line new-cap + return $.Deferred().rejectWith(that, [originalData]).promise(); + } + return that.processActions[settings.action].call( + that, + data, + settings + ); + }; + chain = chain[that._promisePipe](func, settings.always && func); + }); + chain + .done(function () { + that._trigger('processdone', null, data); + that._trigger('processalways', null, data); + }) + .fail(function () { + that._trigger('processfail', null, data); + that._trigger('processalways', null, data); + }); + return chain; + }, + + // Replaces the settings of each processQueue item that + // are strings starting with an "@", using the remaining + // substring as key for the option map, + // e.g. "@autoUpload" is replaced with options.autoUpload: + _transformProcessQueue: function (options) { + var processQueue = []; + $.each(options.processQueue, function () { + var settings = {}, + action = this.action, + prefix = this.prefix === true ? action : this.prefix; + $.each(this, function (key, value) { + if ($.type(value) === 'string' && value.charAt(0) === '@') { + settings[key] = + options[ + value.slice(1) || + (prefix + ? prefix + key.charAt(0).toUpperCase() + key.slice(1) + : key) + ]; + } else { + settings[key] = value; + } + }); + processQueue.push(settings); + }); + options.processQueue = processQueue; + }, + + // Returns the number of files currently in the processsing queue: + processing: function () { + return this._processing; + }, + + // Processes the files given as files property of the data parameter, + // returns a Promise object that allows to bind callbacks: + process: function (data) { + var that = this, + options = $.extend({}, this.options, data); + if (options.processQueue && options.processQueue.length) { + this._transformProcessQueue(options); + if (this._processing === 0) { + this._trigger('processstart'); + } + $.each(data.files, function (index) { + var opts = index ? $.extend({}, options) : options, + func = function () { + if (data.errorThrown) { + // eslint-disable-next-line new-cap + return $.Deferred().rejectWith(that, [data]).promise(); + } + return that._processFile(opts, data); + }; + opts.index = index; + that._processing += 1; + that._processingQueue = that._processingQueue[that._promisePipe]( + func, + func + ).always(function () { + that._processing -= 1; + if (that._processing === 0) { + that._trigger('processstop'); + } + }); + }); + } + return this._processingQueue; + }, + + _create: function () { + this._super(); + this._processing = 0; + // eslint-disable-next-line new-cap + this._processingQueue = $.Deferred().resolveWith(this).promise(); + } + }); +}); diff --git a/lib/web/jquery/fileUploader/jquery.fileupload-ui.js b/lib/web/jquery/fileUploader/jquery.fileupload-ui.js index 8dca3ce992671..a4665f8392fe0 100644 --- a/lib/web/jquery/fileUploader/jquery.fileupload-ui.js +++ b/lib/web/jquery/fileUploader/jquery.fileupload-ui.js @@ -1,745 +1,759 @@ /* - * jQuery File Upload User Interface Plugin 6.9.5 + * jQuery File Upload User Interface Plugin * https://github.com/blueimp/jQuery-File-Upload * * Copyright 2010, Sebastian Tschan * https://blueimp.net * * Licensed under the MIT license: - * http://www.opensource.org/licenses/MIT + * https://opensource.org/licenses/MIT */ -/*jslint nomen: true, unparam: true, regexp: true */ -/*global define, window, document, URL, webkitURL, FileReader */ +/* global define, require */ (function (factory) { - 'use strict'; - if (typeof define === 'function' && define.amd) { - // Register as an anonymous AMD module: - define([ - 'jquery', - 'mage/template', - 'jquery/fileUploader/load-image', - 'jquery/fileUploader/jquery.fileupload-fp', - 'jquery/fileUploader/jquery.iframe-transport' - ], factory); - } else { - // Browser globals: - factory( - window.jQuery, - window.mageTemplate, - window.loadImage + 'use strict'; + if (typeof define === 'function' && define.amd) { + // Register as an anonymous AMD module: + define([ + 'jquery', + 'jquery/fileUploader/vendor/blueimp-tmpl/js/tmpl', + 'jquery/fileUploader/jquery.fileupload-image', + 'jquery/fileUploader/jquery.fileupload-audio', + 'jquery/fileUploader/jquery.fileupload-video', + 'jquery/fileUploader/jquery.fileupload-validate' + ], factory); + } else if (typeof exports === 'object') { + // Node/CommonJS: + factory( + require('jquery'), + require('jquery/fileUploader/vendor/blueimp-tmpl/js/tmpl'), + require('jquery/fileUploader/jquery.fileupload-image'), + require('jquery/fileUploader/jquery.fileupload-audio'), + require('jquery/fileUploader/jquery.fileupload-video'), + require('jquery/fileUploader/jquery.fileupload-validate') + ); + } else { + // Browser globals: + factory(window.jQuery, window.tmpl); + } +})(function ($, tmpl) { + 'use strict'; + + $.blueimp.fileupload.prototype._specialOptions.push( + 'filesContainer', + 'uploadTemplateId', + 'downloadTemplateId' + ); + + // The UI version extends the file upload widget + // and adds complete user interface interaction: + $.widget('blueimp.fileupload', $.blueimp.fileupload, { + options: { + // By default, files added to the widget are uploaded as soon + // as the user clicks on the start buttons. To enable automatic + // uploads, set the following option to true: + autoUpload: false, + // The class to show/hide UI elements: + showElementClass: 'in', + // The ID of the upload template: + uploadTemplateId: 'template-upload', + // The ID of the download template: + downloadTemplateId: 'template-download', + // The container for the list of files. If undefined, it is set to + // an element with class "files" inside of the widget element: + filesContainer: undefined, + // By default, files are appended to the files container. + // Set the following option to true, to prepend files instead: + prependFiles: false, + // The expected data type of the upload response, sets the dataType + // option of the $.ajax upload requests: + dataType: 'json', + + // Error and info messages: + messages: { + unknownError: 'Unknown error' + }, + + // Function returning the current number of files, + // used by the maxNumberOfFiles validation: + getNumberOfFiles: function () { + return this.filesContainer.children().not('.processing').length; + }, + + // Callback to retrieve the list of files from the server response: + getFilesFromResponse: function (data) { + if (data.result && $.isArray(data.result.files)) { + return data.result.files; + } + return []; + }, + + // The add callback is invoked as soon as files are added to the fileupload + // widget (via file input selection, drag & drop or add API call). + // See the basic file upload widget for more information: + add: function (e, data) { + if (e.isDefaultPrevented()) { + return false; + } + var $this = $(this), + that = $this.data('blueimp-fileupload') || $this.data('fileupload'), + options = that.options; + data.context = that + ._renderUpload(data.files) + .data('data', data) + .addClass('processing'); + options.filesContainer[options.prependFiles ? 'prepend' : 'append']( + data.context ); - } -}(function ($, tmpl, loadImage) { - 'use strict'; - - // The UI version extends the FP (file processing) version or the basic - // file upload widget and adds complete user interface interaction: - var parentWidget = ($.blueimpFP || $.blueimp).fileupload; - $.widget('blueimpUI.fileupload', parentWidget, { - - options: { - // By default, files added to the widget are uploaded as soon - // as the user clicks on the start buttons. To enable automatic - // uploads, set the following option to true: - autoUpload: false, - // The following option limits the number of files that are - // allowed to be uploaded using this widget: - maxNumberOfFiles: undefined, - // The maximum allowed file size: - maxFileSize: undefined, - // The minimum allowed file size: - minFileSize: undefined, - // The regular expression for allowed file types, matches - // against either file type or file name: - acceptFileTypes: /.+$/i, - // The regular expression to define for which files a preview - // image is shown, matched against the file type: - previewSourceFileTypes: /^image\/(gif|jpeg|png)$/, - // The maximum file size of images that are to be displayed as preview: - previewSourceMaxFileSize: 5000000, // 5MB - // The maximum width of the preview images: - previewMaxWidth: 80, - // The maximum height of the preview images: - previewMaxHeight: 80, - // By default, preview images are displayed as canvas elements - // if supported by the browser. Set the following option to false - // to always display preview images as img elements: - previewAsCanvas: true, - // The ID of the upload template: - uploadTemplateId: 'template-upload', - // The ID of the download template: - downloadTemplateId: 'template-download', - // The container for the list of files. If undefined, it is set to - // an element with class "files" inside of the widget element: - filesContainer: undefined, - // By default, files are appended to the files container. - // Set the following option to true, to prepend files instead: - prependFiles: false, - // The expected data type of the upload response, sets the dataType - // option of the $.ajax upload requests: - dataType: 'json', - - // The add callback is invoked as soon as files are added to the fileupload - // widget (via file input selection, drag & drop or add API call). - // See the basic file upload widget for more information: - add: function (e, data) { - var that = $(this).data('fileupload'), - options = that.options, - files = data.files; - $(this).fileupload('process', data).done(function () { - that._adjustMaxNumberOfFiles(-files.length); - data.maxNumberOfFilesAdjusted = true; - data.files.valid = data.isValidated = that._validate(files); - data.context = that._renderUpload(files).data('data', data); - options.filesContainer[ - options.prependFiles ? 'prepend' : 'append' - ](data.context); - that._renderPreviews(files, data.context); - that._forceReflow(data.context); - that._transition(data.context).done( - function () { - if ((that._trigger('added', e, data) !== false) && - (options.autoUpload || data.autoUpload) && - data.autoUpload !== false && data.isValidated) { - data.submit(); - } - } - ); - }); - }, - // Callback for the start of each file upload request: - send: function (e, data) { - var that = $(this).data('fileupload'); - if (!data.isValidated) { - if (!data.maxNumberOfFilesAdjusted) { - that._adjustMaxNumberOfFiles(-data.files.length); - data.maxNumberOfFilesAdjusted = true; - } - if (!that._validate(data.files)) { - return false; - } - } - if (data.context && data.dataType && - data.dataType.substr(0, 6) === 'iframe') { - // Iframe Transport does not support progress events. - // In lack of an indeterminate progress bar, we set - // the progress to 100%, showing the full animated bar: - data.context - .find('.progress').addClass( - !$.support.transition && 'progress-animated' - ) - .attr('aria-valuenow', 100) - .find('.bar').css( - 'width', - '100%' - ); - } - return that._trigger('sent', e, data); - }, - // Callback for successful uploads: - done: function (e, data) { - var that = $(this).data('fileupload'), - template; - if (data.context) { - data.context.each(function (index) { - var file = ($.isArray(data.result) && - data.result[index]) || {error: 'emptyResult'}; - if (file.error) { - that._adjustMaxNumberOfFiles(1); - } - that._transition($(this)).done( - function () { - var node = $(this); - template = that._renderDownload([file]) - .replaceAll(node); - that._forceReflow(template); - that._transition(template).done( - function () { - data.context = $(this); - that._trigger('completed', e, data); - } - ); - } - ); - }); - } else { - if ($.isArray(data.result)) { - $.each(data.result, function (index, file) { - if (data.maxNumberOfFilesAdjusted && file.error) { - that._adjustMaxNumberOfFiles(1); - } else if (!data.maxNumberOfFilesAdjusted && - !file.error) { - that._adjustMaxNumberOfFiles(-1); - } - }); - data.maxNumberOfFilesAdjusted = true; - } - template = that._renderDownload(data.result) - .appendTo(that.options.filesContainer); - that._forceReflow(template); - that._transition(template).done( - function () { - data.context = $(this); - that._trigger('completed', e, data); - } - ); - } - }, - // Callback for failed (abort or error) uploads: - fail: function (e, data) { - var that = $(this).data('fileupload'), - template; - if (data.maxNumberOfFilesAdjusted) { - that._adjustMaxNumberOfFiles(data.files.length); - } - if (data.context) { - data.context.each(function (index) { - if (data.errorThrown !== 'abort') { - var file = data.files[index]; - file.error = file.error || data.errorThrown || - true; - that._transition($(this)).done( - function () { - var node = $(this); - template = that._renderDownload([file]) - .replaceAll(node); - that._forceReflow(template); - that._transition(template).done( - function () { - data.context = $(this); - that._trigger('failed', e, data); - } - ); - } - ); - } else { - that._transition($(this)).done( - function () { - $(this).remove(); - that._trigger('failed', e, data); - } - ); - } - }); - } else if (data.errorThrown !== 'abort') { - data.context = that._renderUpload(data.files) - .appendTo(that.options.filesContainer) - .data('data', data); - that._forceReflow(data.context); - that._transition(data.context).done( - function () { - data.context = $(this); - that._trigger('failed', e, data); - } - ); - } else { - that._trigger('failed', e, data); - } - }, - // Callback for upload progress events: - progress: function (e, data) { - if (data.context) { - var progress = parseInt(data.loaded / data.total * 100, 10); - data.context.find('.progress') - .attr('aria-valuenow', progress) - .find('.bar').css( - 'width', - progress + '%' - ); - } - }, - // Callback for global upload progress events: - progressall: function (e, data) { - var $this = $(this), - progress = parseInt(data.loaded / data.total * 100, 10), - globalProgressNode = $this.find('.fileupload-progress'), - extendedProgressNode = globalProgressNode - .find('.progress-extended'); - if (extendedProgressNode.length) { - extendedProgressNode.html( - $this.data('fileupload')._renderExtendedProgress(data) - ); - } - globalProgressNode - .find('.progress') - .attr('aria-valuenow', progress) - .find('.bar').css( - 'width', - progress + '%' - ); - }, - // Callback for uploads start, equivalent to the global ajaxStart event: - start: function (e) { - var that = $(this).data('fileupload'); - that._transition($(this).find('.fileupload-progress')).done( - function () { - that._trigger('started', e); - } - ); - }, - // Callback for uploads stop, equivalent to the global ajaxStop event: - stop: function (e) { - var that = $(this).data('fileupload'); - that._transition($(this).find('.fileupload-progress')).done( - function () { - $(this).find('.progress') - .attr('aria-valuenow', '0') - .find('.bar').css('width', '0%'); - $(this).find('.progress-extended').html(' '); - that._trigger('stopped', e); - } - ); - }, - // Callback for file deletion: - destroy: function (e, data) { - var that = $(this).data('fileupload'); - if (data.url) { - $.ajax(data); - that._adjustMaxNumberOfFiles(1); - } - that._transition(data.context).done( - function () { - $(this).remove(); - that._trigger('destroyed', e, data); - } - ); - } - }, - - // Link handler, that allows to download files - // by drag & drop of the links to the desktop: - _enableDragToDesktop: function () { - var link = $(this), - url = link.prop('href'), - name = link.prop('download'), - type = 'application/octet-stream'; - link.bind('dragstart', function (e) { - try { - e.originalEvent.dataTransfer.setData( - 'DownloadURL', - [type, name, url].join(':') - ); - } catch (err) {} - }); - }, - - _adjustMaxNumberOfFiles: function (operand) { - if (typeof this.options.maxNumberOfFiles === 'number') { - this.options.maxNumberOfFiles += operand; - if (this.options.maxNumberOfFiles < 1) { - this._disableFileInputButton(); - } else { - this._enableFileInputButton(); - } - } - }, - - _formatFileSize: function (bytes) { - if (typeof bytes !== 'number') { - return ''; - } - if (bytes >= 1000000000) { - return (bytes / 1000000000).toFixed(2) + ' GB'; - } - if (bytes >= 1000000) { - return (bytes / 1000000).toFixed(2) + ' MB'; - } - return (bytes / 1000).toFixed(2) + ' KB'; - }, - - _formatBitrate: function (bits) { - if (typeof bits !== 'number') { - return ''; + that._forceReflow(data.context); + that._transition(data.context); + data + .process(function () { + return $this.fileupload('process', data); + }) + .always(function () { + data.context + .each(function (index) { + $(this) + .find('.size') + .text(that._formatFileSize(data.files[index].size)); + }) + .removeClass('processing'); + that._renderPreviews(data); + }) + .done(function () { + data.context.find('.edit,.start').prop('disabled', false); + if ( + that._trigger('added', e, data) !== false && + (options.autoUpload || data.autoUpload) && + data.autoUpload !== false + ) { + data.submit(); } - if (bits >= 1000000000) { - return (bits / 1000000000).toFixed(2) + ' Gbit/s'; - } - if (bits >= 1000000) { - return (bits / 1000000).toFixed(2) + ' Mbit/s'; - } - if (bits >= 1000) { - return (bits / 1000).toFixed(2) + ' kbit/s'; - } - return bits + ' bit/s'; - }, - - _formatTime: function (seconds) { - var date = new Date(seconds * 1000), - days = parseInt(seconds / 86400, 10); - days = days ? days + 'd ' : ''; - return days + - ('0' + date.getUTCHours()).slice(-2) + ':' + - ('0' + date.getUTCMinutes()).slice(-2) + ':' + - ('0' + date.getUTCSeconds()).slice(-2); - }, - - _formatPercentage: function (floatValue) { - return (floatValue * 100).toFixed(2) + ' %'; - }, - - _renderExtendedProgress: function (data) { - return this._formatBitrate(data.bitrate) + ' | ' + - this._formatTime( - (data.total - data.loaded) * 8 / data.bitrate - ) + ' | ' + - this._formatPercentage( - data.loaded / data.total - ) + ' | ' + - this._formatFileSize(data.loaded) + ' / ' + - this._formatFileSize(data.total); - }, - - _hasError: function (file) { - if (file.error) { - return file.error; - } - // The number of added files is subtracted from - // maxNumberOfFiles before validation, so we check if - // maxNumberOfFiles is below 0 (instead of below 1): - if (this.options.maxNumberOfFiles < 0) { - return 'maxNumberOfFiles'; - } - // Files are accepted if either the file type or the file name - // matches against the acceptFileTypes regular expression, as - // only browsers with support for the File API report the type: - if (!(this.options.acceptFileTypes.test(file.type) || - this.options.acceptFileTypes.test(file.name))) { - return 'acceptFileTypes'; - } - if (this.options.maxFileSize && - file.size > this.options.maxFileSize) { - return 'maxFileSize'; - } - if (typeof file.size === 'number' && - file.size < this.options.minFileSize) { - return 'minFileSize'; - } - return null; - }, - - _validate: function (files) { - var that = this, - valid = !!files.length; - $.each(files, function (index, file) { - file.error = that._hasError(file); - if (file.error) { - valid = false; + }) + .fail(function () { + if (data.files.error) { + data.context.each(function (index) { + var error = data.files[index].error; + if (error) { + $(this).find('.error').text(error); } - }); - return valid; - }, - - _renderTemplate: function (func, files) { - if (!func) { - return $(); + }); } - var result = func({ - files: files, - formatFileSize: this._formatFileSize, - options: this.options - }); - if (result instanceof $) { - return result; - } - return $(this.options.templatesContainer).html(result).children(); - }, - - _renderPreview: function (file, node) { - var that = this, - options = this.options, - dfd = $.Deferred(); - return ((loadImage && loadImage( - file, - function (img) { - node.append(img); - that._forceReflow(node); - that._transition(node).done(function () { - dfd.resolveWith(node); - }); - if (!$.contains(document.body, node[0])) { - // If the element is not part of the DOM, - // transition events are not triggered, - // so we have to resolve manually: - dfd.resolveWith(node); - } - }, - { - maxWidth: options.previewMaxWidth, - maxHeight: options.previewMaxHeight, - canvas: options.previewAsCanvas - } - )) || dfd.resolveWith(node)) && dfd; - }, - - _renderPreviews: function (files, nodes) { - var that = this, - options = this.options; - nodes.find('.preview span').each(function (index, element) { - var file = files[index]; - if (options.previewSourceFileTypes.test(file.type) && - ($.type(options.previewSourceMaxFileSize) !== 'number' || - file.size < options.previewSourceMaxFileSize)) { - that._processingQueue = that._processingQueue.pipe(function () { - var dfd = $.Deferred(); - that._renderPreview(file, $(element)).done( - function () { - dfd.resolveWith(that); - } - ); - return dfd.promise(); - }); - } + }); + }, + // Callback for the start of each file upload request: + send: function (e, data) { + if (e.isDefaultPrevented()) { + return false; + } + var that = + $(this).data('blueimp-fileupload') || $(this).data('fileupload'); + if ( + data.context && + data.dataType && + data.dataType.substr(0, 6) === 'iframe' + ) { + // Iframe Transport does not support progress events. + // In lack of an indeterminate progress bar, we set + // the progress to 100%, showing the full animated bar: + data.context + .find('.progress') + .addClass(!$.support.transition && 'progress-animated') + .attr('aria-valuenow', 100) + .children() + .first() + .css('width', '100%'); + } + return that._trigger('sent', e, data); + }, + // Callback for successful uploads: + done: function (e, data) { + if (e.isDefaultPrevented()) { + return false; + } + var that = + $(this).data('blueimp-fileupload') || $(this).data('fileupload'), + getFilesFromResponse = + data.getFilesFromResponse || that.options.getFilesFromResponse, + files = getFilesFromResponse(data), + template, + deferred; + if (data.context) { + data.context.each(function (index) { + var file = files[index] || { error: 'Empty file upload result' }; + deferred = that._addFinishedDeferreds(); + that._transition($(this)).done(function () { + var node = $(this); + template = that._renderDownload([file]).replaceAll(node); + that._forceReflow(template); + that._transition(template).done(function () { + data.context = $(this); + that._trigger('completed', e, data); + that._trigger('finished', e, data); + deferred.resolve(); + }); }); - return this._processingQueue; - }, - - _renderUpload: function (files) { - return this._renderTemplate( - this.options.uploadTemplate, - files + }); + } else { + template = that + ._renderDownload(files) + [that.options.prependFiles ? 'prependTo' : 'appendTo']( + that.options.filesContainer ); - }, - - _renderDownload: function (files) { - return this._renderTemplate( - this.options.downloadTemplate, - files - ).find('a[download]').each(this._enableDragToDesktop).end(); - }, - - _startHandler: function (e) { - e.preventDefault(); - var button = $(this), - template = button.closest('.template-upload'), - data = template.data('data'); - if (data && data.submit && !data.jqXHR && data.submit()) { - button.prop('disabled', true); - } - }, - - _cancelHandler: function (e) { - e.preventDefault(); - var template = $(this).closest('.template-upload'), - data = template.data('data') || {}; - if (!data.jqXHR) { - data.errorThrown = 'abort'; - e.data.fileupload._trigger('fail', e, data); + that._forceReflow(template); + deferred = that._addFinishedDeferreds(); + that._transition(template).done(function () { + data.context = $(this); + that._trigger('completed', e, data); + that._trigger('finished', e, data); + deferred.resolve(); + }); + } + }, + // Callback for failed (abort or error) uploads: + fail: function (e, data) { + if (e.isDefaultPrevented()) { + return false; + } + var that = + $(this).data('blueimp-fileupload') || $(this).data('fileupload'), + template, + deferred; + if (data.context) { + data.context.each(function (index) { + if (data.errorThrown !== 'abort') { + var file = data.files[index]; + file.error = + file.error || data.errorThrown || data.i18n('unknownError'); + deferred = that._addFinishedDeferreds(); + that._transition($(this)).done(function () { + var node = $(this); + template = that._renderDownload([file]).replaceAll(node); + that._forceReflow(template); + that._transition(template).done(function () { + data.context = $(this); + that._trigger('failed', e, data); + that._trigger('finished', e, data); + deferred.resolve(); + }); + }); } else { - data.jqXHR.abort(); + deferred = that._addFinishedDeferreds(); + that._transition($(this)).done(function () { + $(this).remove(); + that._trigger('failed', e, data); + that._trigger('finished', e, data); + deferred.resolve(); + }); } - }, - - _deleteHandler: function (e) { - e.preventDefault(); - var button = $(this); - e.data.fileupload._trigger('destroy', e, { - context: button.closest('.template-download'), - url: button.attr('data-url'), - type: button.attr('data-type') || 'DELETE', - dataType: e.data.fileupload.options.dataType + }); + } else if (data.errorThrown !== 'abort') { + data.context = that + ._renderUpload(data.files) + [that.options.prependFiles ? 'prependTo' : 'appendTo']( + that.options.filesContainer + ) + .data('data', data); + that._forceReflow(data.context); + deferred = that._addFinishedDeferreds(); + that._transition(data.context).done(function () { + data.context = $(this); + that._trigger('failed', e, data); + that._trigger('finished', e, data); + deferred.resolve(); + }); + } else { + that._trigger('failed', e, data); + that._trigger('finished', e, data); + that._addFinishedDeferreds().resolve(); + } + }, + // Callback for upload progress events: + progress: function (e, data) { + if (e.isDefaultPrevented()) { + return false; + } + var progress = Math.floor((data.loaded / data.total) * 100); + if (data.context) { + data.context.each(function () { + $(this) + .find('.progress') + .attr('aria-valuenow', progress) + .children() + .first() + .css('width', progress + '%'); + }); + } + }, + // Callback for global upload progress events: + progressall: function (e, data) { + if (e.isDefaultPrevented()) { + return false; + } + var $this = $(this), + progress = Math.floor((data.loaded / data.total) * 100), + globalProgressNode = $this.find('.fileupload-progress'), + extendedProgressNode = globalProgressNode.find('.progress-extended'); + if (extendedProgressNode.length) { + extendedProgressNode.html( + ( + $this.data('blueimp-fileupload') || $this.data('fileupload') + )._renderExtendedProgress(data) + ); + } + globalProgressNode + .find('.progress') + .attr('aria-valuenow', progress) + .children() + .first() + .css('width', progress + '%'); + }, + // Callback for uploads start, equivalent to the global ajaxStart event: + start: function (e) { + if (e.isDefaultPrevented()) { + return false; + } + var that = + $(this).data('blueimp-fileupload') || $(this).data('fileupload'); + that._resetFinishedDeferreds(); + that + ._transition($(this).find('.fileupload-progress')) + .done(function () { + that._trigger('started', e); + }); + }, + // Callback for uploads stop, equivalent to the global ajaxStop event: + stop: function (e) { + if (e.isDefaultPrevented()) { + return false; + } + var that = + $(this).data('blueimp-fileupload') || $(this).data('fileupload'), + deferred = that._addFinishedDeferreds(); + $.when.apply($, that._getFinishedDeferreds()).done(function () { + that._trigger('stopped', e); + }); + that + ._transition($(this).find('.fileupload-progress')) + .done(function () { + $(this) + .find('.progress') + .attr('aria-valuenow', '0') + .children() + .first() + .css('width', '0%'); + $(this).find('.progress-extended').html(' '); + deferred.resolve(); + }); + }, + processstart: function (e) { + if (e.isDefaultPrevented()) { + return false; + } + $(this).addClass('fileupload-processing'); + }, + processstop: function (e) { + if (e.isDefaultPrevented()) { + return false; + } + $(this).removeClass('fileupload-processing'); + }, + // Callback for file deletion: + destroy: function (e, data) { + if (e.isDefaultPrevented()) { + return false; + } + var that = + $(this).data('blueimp-fileupload') || $(this).data('fileupload'), + removeNode = function () { + that._transition(data.context).done(function () { + $(this).remove(); + that._trigger('destroyed', e, data); }); - }, - - _forceReflow: function (node) { - return $.support.transition && node.length && - node[0].offsetWidth; - }, - - _transition: function (node) { - var dfd = $.Deferred(); - if ($.support.transition && node.hasClass('fade')) { - node.bind( - $.support.transition.end, - function (e) { - // Make sure we don't respond to other transitions events - // in the container element, e.g. from button elements: - if (e.target === node[0]) { - node.unbind($.support.transition.end); - dfd.resolveWith(node); - } - } - ).toggleClass('in'); - } else { - node.toggleClass('in'); - dfd.resolveWith(node); - } - return dfd; - }, - - _initButtonBarEventHandlers: function () { - var fileUploadButtonBar = this.element.find('.fileupload-buttonbar'), - filesList = this.options.filesContainer, - ns = this.options.namespace; - fileUploadButtonBar.find('.start') - .bind('click.' + ns, function (e) { - e.preventDefault(); - filesList.find('.start button').click(); - }); - fileUploadButtonBar.find('.cancel') - .bind('click.' + ns, function (e) { - e.preventDefault(); - filesList.find('.cancel button').click(); - }); - fileUploadButtonBar.find('.delete') - .bind('click.' + ns, function (e) { - e.preventDefault(); - filesList.find('.delete input:checked') - .siblings('button').click(); - fileUploadButtonBar.find('.toggle') - .prop('checked', false); - }); - fileUploadButtonBar.find('.toggle') - .bind('change.' + ns, function (e) { - filesList.find('.delete input').prop( - 'checked', - $(this).is(':checked') - ); - }); - }, - - _destroyButtonBarEventHandlers: function () { - this.element.find('.fileupload-buttonbar button') - .unbind('click.' + this.options.namespace); - this.element.find('.fileupload-buttonbar .toggle') - .unbind('change.' + this.options.namespace); - }, - - _initEventHandlers: function () { - parentWidget.prototype._initEventHandlers.call(this); - var eventData = {fileupload: this}; - this.options.filesContainer - .delegate( - '.start button', - 'click.' + this.options.namespace, - eventData, - this._startHandler - ) - .delegate( - '.cancel button', - 'click.' + this.options.namespace, - eventData, - this._cancelHandler - ) - .delegate( - '.delete button', - 'click.' + this.options.namespace, - eventData, - this._deleteHandler - ); - this._initButtonBarEventHandlers(); - }, - - _destroyEventHandlers: function () { - var options = this.options; - this._destroyButtonBarEventHandlers(); - options.filesContainer - .undelegate('.start button', 'click.' + options.namespace) - .undelegate('.cancel button', 'click.' + options.namespace) - .undelegate('.delete button', 'click.' + options.namespace); - parentWidget.prototype._destroyEventHandlers.call(this); - }, - - _enableFileInputButton: function () { - this.element.find('.fileinput-button input') - .prop('disabled', false) - .parent().removeClass('disabled'); - }, - - _disableFileInputButton: function () { - this.element.find('.fileinput-button input') - .prop('disabled', true) - .parent().addClass('disabled'); - }, - - _initTemplates: function () { - var options = this.options; - options.templatesContainer = document.createElement( - options.filesContainer.prop('nodeName') - ); - if (tmpl) { - if (options.uploadTemplateId) { - options.uploadTemplate = tmpl(options.uploadTemplateId); - } - if (options.downloadTemplateId) { - options.downloadTemplate = tmpl(options.downloadTemplateId); - } - } - }, - - _initFilesContainer: function () { - var options = this.options; - if (options.filesContainer === undefined) { - options.filesContainer = this.element.find('.files'); - } else if (!(options.filesContainer instanceof $)) { - options.filesContainer = $(options.filesContainer); - } - }, - - _stringToRegExp: function (str) { - var parts = str.split('/'), - modifiers = parts.pop(); - parts.shift(); - return new RegExp(parts.join('/'), modifiers); - }, - - _initRegExpOptions: function () { - var options = this.options; - if ($.type(options.acceptFileTypes) === 'string') { - options.acceptFileTypes = this._stringToRegExp( - options.acceptFileTypes - ); - } - if ($.type(options.previewSourceFileTypes) === 'string') { - options.previewSourceFileTypes = this._stringToRegExp( - options.previewSourceFileTypes - ); - } - }, - - _initSpecialOptions: function () { - parentWidget.prototype._initSpecialOptions.call(this); - this._initFilesContainer(); - this._initTemplates(); - this._initRegExpOptions(); - }, - - _create: function () { - parentWidget.prototype._create.call(this); - this._refreshOptionsList.push( - 'filesContainer', - 'uploadTemplateId', - 'downloadTemplateId' - ); - if (!$.blueimpFP) { - this._processingQueue = $.Deferred().resolveWith(this).promise(); - this.process = function () { - return this._processingQueue; - }; - } - }, - - enable: function () { - var wasDisabled = false; - if (this.options.disabled) { - wasDisabled = true; - } - parentWidget.prototype.enable.call(this); - if (wasDisabled) { - this.element.find('input, button').prop('disabled', false); - this._enableFileInputButton(); - } - }, - - disable: function () { - if (!this.options.disabled) { - this.element.find('input, button').prop('disabled', true); - this._disableFileInputButton(); + }; + if (data.url) { + data.dataType = data.dataType || that.options.dataType; + $.ajax(data) + .done(removeNode) + .fail(function () { + that._trigger('destroyfailed', e, data); + }); + } else { + removeNode(); + } + } + }, + + _resetFinishedDeferreds: function () { + this._finishedUploads = []; + }, + + _addFinishedDeferreds: function (deferred) { + // eslint-disable-next-line new-cap + var promise = deferred || $.Deferred(); + this._finishedUploads.push(promise); + return promise; + }, + + _getFinishedDeferreds: function () { + return this._finishedUploads; + }, + + // Link handler, that allows to download files + // by drag & drop of the links to the desktop: + _enableDragToDesktop: function () { + var link = $(this), + url = link.prop('href'), + name = link.prop('download'), + type = 'application/octet-stream'; + link.on('dragstart', function (e) { + try { + e.originalEvent.dataTransfer.setData( + 'DownloadURL', + [type, name, url].join(':') + ); + } catch (ignore) { + // Ignore exceptions + } + }); + }, + + _formatFileSize: function (bytes) { + if (typeof bytes !== 'number') { + return ''; + } + if (bytes >= 1000000000) { + return (bytes / 1000000000).toFixed(2) + ' GB'; + } + if (bytes >= 1000000) { + return (bytes / 1000000).toFixed(2) + ' MB'; + } + return (bytes / 1000).toFixed(2) + ' KB'; + }, + + _formatBitrate: function (bits) { + if (typeof bits !== 'number') { + return ''; + } + if (bits >= 1000000000) { + return (bits / 1000000000).toFixed(2) + ' Gbit/s'; + } + if (bits >= 1000000) { + return (bits / 1000000).toFixed(2) + ' Mbit/s'; + } + if (bits >= 1000) { + return (bits / 1000).toFixed(2) + ' kbit/s'; + } + return bits.toFixed(2) + ' bit/s'; + }, + + _formatTime: function (seconds) { + var date = new Date(seconds * 1000), + days = Math.floor(seconds / 86400); + days = days ? days + 'd ' : ''; + return ( + days + + ('0' + date.getUTCHours()).slice(-2) + + ':' + + ('0' + date.getUTCMinutes()).slice(-2) + + ':' + + ('0' + date.getUTCSeconds()).slice(-2) + ); + }, + + _formatPercentage: function (floatValue) { + return (floatValue * 100).toFixed(2) + ' %'; + }, + + _renderExtendedProgress: function (data) { + return ( + this._formatBitrate(data.bitrate) + + ' | ' + + this._formatTime(((data.total - data.loaded) * 8) / data.bitrate) + + ' | ' + + this._formatPercentage(data.loaded / data.total) + + ' | ' + + this._formatFileSize(data.loaded) + + ' / ' + + this._formatFileSize(data.total) + ); + }, + + _renderTemplate: function (func, files) { + if (!func) { + return $(); + } + var result = func({ + files: files, + formatFileSize: this._formatFileSize, + options: this.options + }); + if (result instanceof $) { + return result; + } + return $(this.options.templatesContainer).html(result).children(); + }, + + _renderPreviews: function (data) { + data.context.find('.preview').each(function (index, elm) { + $(elm).empty().append(data.files[index].preview); + }); + }, + + _renderUpload: function (files) { + return this._renderTemplate(this.options.uploadTemplate, files); + }, + + _renderDownload: function (files) { + return this._renderTemplate(this.options.downloadTemplate, files) + .find('a[download]') + .each(this._enableDragToDesktop) + .end(); + }, + + _editHandler: function (e) { + e.preventDefault(); + if (!this.options.edit) return; + var that = this, + button = $(e.currentTarget), + template = button.closest('.template-upload'), + data = template.data('data'), + index = button.data().index; + this.options.edit(data.files[index]).then(function (file) { + if (!file) return; + data.files[index] = file; + data.context.addClass('processing'); + template.find('.edit,.start').prop('disabled', true); + $(that.element) + .fileupload('process', data) + .always(function () { + template + .find('.size') + .text(that._formatFileSize(data.files[index].size)); + data.context.removeClass('processing'); + that._renderPreviews(data); + }) + .done(function () { + template.find('.edit,.start').prop('disabled', false); + }) + .fail(function () { + template.find('.edit').prop('disabled', false); + var error = data.files[index].error; + if (error) { + template.find('.error').text(error); } - parentWidget.prototype.disable.call(this); + }); + }); + }, + + _startHandler: function (e) { + e.preventDefault(); + var button = $(e.currentTarget), + template = button.closest('.template-upload'), + data = template.data('data'); + button.prop('disabled', true); + if (data && data.submit) { + data.submit(); + } + }, + + _cancelHandler: function (e) { + e.preventDefault(); + var template = $(e.currentTarget).closest( + '.template-upload,.template-download' + ), + data = template.data('data') || {}; + data.context = data.context || template; + if (data.abort) { + data.abort(); + } else { + data.errorThrown = 'abort'; + this._trigger('fail', e, data); + } + }, + + _deleteHandler: function (e) { + e.preventDefault(); + var button = $(e.currentTarget); + this._trigger( + 'destroy', + e, + $.extend( + { + context: button.closest('.template-download'), + type: 'DELETE' + }, + button.data() + ) + ); + }, + + _forceReflow: function (node) { + return $.support.transition && node.length && node[0].offsetWidth; + }, + + _transition: function (node) { + // eslint-disable-next-line new-cap + var dfd = $.Deferred(); + if ( + $.support.transition && + node.hasClass('fade') && + node.is(':visible') + ) { + var transitionEndHandler = function (e) { + // Make sure we don't respond to other transition events + // in the container element, e.g. from button elements: + if (e.target === node[0]) { + node.off($.support.transition.end, transitionEndHandler); + dfd.resolveWith(node); + } + }; + node + .on($.support.transition.end, transitionEndHandler) + .toggleClass(this.options.showElementClass); + } else { + node.toggleClass(this.options.showElementClass); + dfd.resolveWith(node); + } + return dfd; + }, + + _initButtonBarEventHandlers: function () { + var fileUploadButtonBar = this.element.find('.fileupload-buttonbar'), + filesList = this.options.filesContainer; + this._on(fileUploadButtonBar.find('.start'), { + click: function (e) { + e.preventDefault(); + filesList.find('.start').trigger('click'); } - - }); - -})); + }); + this._on(fileUploadButtonBar.find('.cancel'), { + click: function (e) { + e.preventDefault(); + filesList.find('.cancel').trigger('click'); + } + }); + this._on(fileUploadButtonBar.find('.delete'), { + click: function (e) { + e.preventDefault(); + filesList + .find('.toggle:checked') + .closest('.template-download') + .find('.delete') + .trigger('click'); + fileUploadButtonBar.find('.toggle').prop('checked', false); + } + }); + this._on(fileUploadButtonBar.find('.toggle'), { + change: function (e) { + filesList + .find('.toggle') + .prop('checked', $(e.currentTarget).is(':checked')); + } + }); + }, + + _destroyButtonBarEventHandlers: function () { + this._off( + this.element + .find('.fileupload-buttonbar') + .find('.start, .cancel, .delete'), + 'click' + ); + this._off(this.element.find('.fileupload-buttonbar .toggle'), 'change.'); + }, + + _initEventHandlers: function () { + this._super(); + this._on(this.options.filesContainer, { + 'click .edit': this._editHandler, + 'click .start': this._startHandler, + 'click .cancel': this._cancelHandler, + 'click .delete': this._deleteHandler + }); + this._initButtonBarEventHandlers(); + }, + + _destroyEventHandlers: function () { + this._destroyButtonBarEventHandlers(); + this._off(this.options.filesContainer, 'click'); + this._super(); + }, + + _enableFileInputButton: function () { + this.element + .find('.fileinput-button input') + .prop('disabled', false) + .parent() + .removeClass('disabled'); + }, + + _disableFileInputButton: function () { + this.element + .find('.fileinput-button input') + .prop('disabled', true) + .parent() + .addClass('disabled'); + }, + + _initTemplates: function () { + var options = this.options; + options.templatesContainer = this.document[0].createElement( + options.filesContainer.prop('nodeName') + ); + if (tmpl) { + if (options.uploadTemplateId) { + options.uploadTemplate = tmpl(options.uploadTemplateId); + } + if (options.downloadTemplateId) { + options.downloadTemplate = tmpl(options.downloadTemplateId); + } + } + }, + + _initFilesContainer: function () { + var options = this.options; + if (options.filesContainer === undefined) { + options.filesContainer = this.element.find('.files'); + } else if (!(options.filesContainer instanceof $)) { + options.filesContainer = $(options.filesContainer); + } + }, + + _initSpecialOptions: function () { + this._super(); + this._initFilesContainer(); + // this._initTemplates(); + }, + + _create: function () { + this._super(); + this._resetFinishedDeferreds(); + if (!$.support.fileInput) { + this._disableFileInputButton(); + } + }, + + enable: function () { + var wasDisabled = false; + if (this.options.disabled) { + wasDisabled = true; + } + this._super(); + if (wasDisabled) { + this.element.find('input, button').prop('disabled', false); + this._enableFileInputButton(); + } + }, + + disable: function () { + if (!this.options.disabled) { + this.element.find('input, button').prop('disabled', true); + this._disableFileInputButton(); + } + this._super(); + } + }); +}); diff --git a/lib/web/jquery/fileUploader/jquery.fileupload-validate.js b/lib/web/jquery/fileUploader/jquery.fileupload-validate.js new file mode 100644 index 0000000000000..d23e0f4a24ef7 --- /dev/null +++ b/lib/web/jquery/fileUploader/jquery.fileupload-validate.js @@ -0,0 +1,119 @@ +/* + * jQuery File Upload Validation Plugin + * https://github.com/blueimp/jQuery-File-Upload + * + * Copyright 2013, Sebastian Tschan + * https://blueimp.net + * + * Licensed under the MIT license: + * https://opensource.org/licenses/MIT + */ + +/* global define, require */ + +(function (factory) { + 'use strict'; + if (typeof define === 'function' && define.amd) { + // Register as an anonymous AMD module: + define(['jquery', 'jquery/fileUploader/jquery.fileupload-process'], factory); + } else if (typeof exports === 'object') { + // Node/CommonJS: + factory(require('jquery'), require('jquery/fileUploader/jquery.fileupload-process')); + } else { + // Browser globals: + factory(window.jQuery); + } +})(function ($) { + 'use strict'; + + // Append to the default processQueue: + $.blueimp.fileupload.prototype.options.processQueue.push({ + action: 'validate', + // Always trigger this action, + // even if the previous action was rejected: + always: true, + // Options taken from the global options map: + acceptFileTypes: '@', + maxFileSize: '@', + minFileSize: '@', + maxNumberOfFiles: '@', + disabled: '@disableValidation' + }); + + // The File Upload Validation plugin extends the fileupload widget + // with file validation functionality: + $.widget('blueimp.fileupload', $.blueimp.fileupload, { + options: { + /* + // The regular expression for allowed file types, matches + // against either file type or file name: + acceptFileTypes: /(\.|\/)(gif|jpe?g|png)$/i, + // The maximum allowed file size in bytes: + maxFileSize: 10000000, // 10 MB + // The minimum allowed file size in bytes: + minFileSize: undefined, // No minimal file size + // The limit of files to be uploaded: + maxNumberOfFiles: 10, + */ + + // Function returning the current number of files, + // has to be overridden for maxNumberOfFiles validation: + getNumberOfFiles: $.noop, + + // Error and info messages: + messages: { + maxNumberOfFiles: 'Maximum number of files exceeded', + acceptFileTypes: 'File type not allowed', + maxFileSize: 'File is too large', + minFileSize: 'File is too small' + } + }, + + processActions: { + validate: function (data, options) { + if (options.disabled) { + return data; + } + // eslint-disable-next-line new-cap + var dfd = $.Deferred(), + settings = this.options, + file = data.files[data.index], + fileSize; + if (options.minFileSize || options.maxFileSize) { + fileSize = file.size; + } + if ( + $.type(options.maxNumberOfFiles) === 'number' && + (settings.getNumberOfFiles() || 0) + data.files.length > + options.maxNumberOfFiles + ) { + file.error = settings.i18n('maxNumberOfFiles'); + } else if ( + options.acceptFileTypes && + !( + options.acceptFileTypes.test(file.type) || + options.acceptFileTypes.test(file.name) + ) + ) { + file.error = settings.i18n('acceptFileTypes'); + } else if (fileSize > options.maxFileSize) { + file.error = settings.i18n('maxFileSize'); + } else if ( + $.type(fileSize) === 'number' && + fileSize < options.minFileSize + ) { + file.error = settings.i18n('minFileSize'); + } else { + delete file.error; + } + if (file.error || data.files.error) { + data.files.error = true; + dfd.rejectWith(this, [data]); + } else { + dfd.resolveWith(this, [data]); + } + return dfd.promise(); + } + } + }); +}); diff --git a/lib/web/jquery/fileUploader/jquery.fileupload-video.js b/lib/web/jquery/fileUploader/jquery.fileupload-video.js new file mode 100644 index 0000000000000..bf247f38280a5 --- /dev/null +++ b/lib/web/jquery/fileUploader/jquery.fileupload-video.js @@ -0,0 +1,101 @@ +/* + * jQuery File Upload Video Preview Plugin + * https://github.com/blueimp/jQuery-File-Upload + * + * Copyright 2013, Sebastian Tschan + * https://blueimp.net + * + * Licensed under the MIT license: + * https://opensource.org/licenses/MIT + */ + +/* global define, require */ + +(function (factory) { + 'use strict'; + if (typeof define === 'function' && define.amd) { + // Register as an anonymous AMD module: + define(['jquery', 'jquery/fileUploader/vendor/blueimp-load-image/js/load-image', 'jquery/fileUploader/jquery.fileupload-process'], factory); + } else if (typeof exports === 'object') { + // Node/CommonJS: + factory( + require('jquery'), + require('jquery/fileUploader/vendor/blueimp-load-image/js/load-image'), + require('jquery/fileUploader/jquery.fileupload-process') + ); + } else { + // Browser globals: + factory(window.jQuery, window.loadImage); + } +})(function ($, loadImage) { + 'use strict'; + + // Prepend to the default processQueue: + $.blueimp.fileupload.prototype.options.processQueue.unshift( + { + action: 'loadVideo', + // Use the action as prefix for the "@" options: + prefix: true, + fileTypes: '@', + maxFileSize: '@', + disabled: '@disableVideoPreview' + }, + { + action: 'setVideo', + name: '@videoPreviewName', + disabled: '@disableVideoPreview' + } + ); + + // The File Upload Video Preview plugin extends the fileupload widget + // with video preview functionality: + $.widget('blueimp.fileupload', $.blueimp.fileupload, { + options: { + // The regular expression for the types of video files to load, + // matched against the file type: + loadVideoFileTypes: /^video\/.*$/ + }, + + _videoElement: document.createElement('video'), + + processActions: { + // Loads the video file given via data.files and data.index + // as video element if the browser supports playing it. + // Accepts the options fileTypes (regular expression) + // and maxFileSize (integer) to limit the files to load: + loadVideo: function (data, options) { + if (options.disabled) { + return data; + } + var file = data.files[data.index], + url, + video; + if ( + this._videoElement.canPlayType && + this._videoElement.canPlayType(file.type) && + ($.type(options.maxFileSize) !== 'number' || + file.size <= options.maxFileSize) && + (!options.fileTypes || options.fileTypes.test(file.type)) + ) { + url = loadImage.createObjectURL(file); + if (url) { + video = this._videoElement.cloneNode(false); + video.src = url; + video.controls = true; + data.video = video; + return data; + } + } + return data; + }, + + // Sets the video element as a property of the file object: + setVideo: function (data, options) { + if (data.video && !options.disabled) { + data.files[data.index][options.name || 'preview'] = data.video; + } + return data; + } + } + }); +}); diff --git a/lib/web/jquery/fileUploader/jquery.fileupload.js b/lib/web/jquery/fileUploader/jquery.fileupload.js index 676f8aa1e8058..8f0ff0d4faf03 100644 --- a/lib/web/jquery/fileUploader/jquery.fileupload.js +++ b/lib/web/jquery/fileUploader/jquery.fileupload.js @@ -1,1081 +1,1606 @@ /* - * jQuery File Upload Plugin 5.16.4 + * jQuery File Upload Plugin * https://github.com/blueimp/jQuery-File-Upload * * Copyright 2010, Sebastian Tschan * https://blueimp.net * * Licensed under the MIT license: - * http://www.opensource.org/licenses/MIT + * https://opensource.org/licenses/MIT */ -/*jslint nomen: true, unparam: true, regexp: true */ -/*global define, window, document, Blob, FormData, location */ +/* global define, require */ +/* eslint-disable new-cap */ (function (factory) { - 'use strict'; - if (typeof define === 'function' && define.amd) { - // Register as an anonymous AMD module: - define([ - 'jquery', - 'jquery-ui-modules/widget', - 'jquery/fileUploader/jquery.iframe-transport' - ], factory); - } else { - // Browser globals: - factory(window.jQuery); - } -}(function ($) { - 'use strict'; - - // The FileReader API is not actually used, but works as feature detection, - // as e.g. Safari supports XHR file uploads via the FormData API, - // but not non-multipart XHR file uploads: - $.support.xhrFileUpload = !!(window.XMLHttpRequestUpload && window.FileReader); - $.support.xhrFormDataFileUpload = !!window.FormData; - - // The fileupload widget listens for change events on file input fields defined - // via fileInput setting and paste or drop events of the given dropZone. - // In addition to the default jQuery Widget methods, the fileupload widget - // exposes the "add" and "send" methods, to add or directly send files using - // the fileupload API. - // By default, files added via file input selection, paste, drag & drop or - // "add" method are uploaded immediately, but it is possible to override - // the "add" callback option to queue file uploads. - $.widget('blueimp.fileupload', { - - options: { - // The namespace used for event handler binding on the dropZone and - // fileInput collections. - // If not set, the name of the widget ("fileupload") is used. - namespace: undefined, - // The drop target collection, by the default the complete document. - // Set to null or an empty collection to disable drag & drop support: - dropZone: $(document), - // The file input field collection, that is listened for change events. - // If undefined, it is set to the file input fields inside - // of the widget element on plugin initialization. - // Set to null or an empty collection to disable the change listener. - fileInput: undefined, - // By default, the file input field is replaced with a clone after - // each input field change event. This is required for iframe transport - // queues and allows change events to be fired for the same file - // selection, but can be disabled by setting the following option to false: - replaceFileInput: true, - // The parameter name for the file form data (the request argument name). - // If undefined or empty, the name property of the file input field is - // used, or "files[]" if the file input name property is also empty, - // can be a string or an array of strings: - paramName: undefined, - // By default, each file of a selection is uploaded using an individual - // request for XHR type uploads. Set to false to upload file - // selections in one request each: - singleFileUploads: true, - // To limit the number of files uploaded with one XHR request, - // set the following option to an integer greater than 0: - limitMultiFileUploads: undefined, - // Set the following option to true to issue all file upload requests - // in a sequential order: - sequentialUploads: false, - // To limit the number of concurrent uploads, - // set the following option to an integer greater than 0: - limitConcurrentUploads: undefined, - // Set the following option to true to force iframe transport uploads: - forceIframeTransport: false, - // Set the following option to the location of a redirect url on the - // origin server, for cross-domain iframe transport uploads: - redirect: undefined, - // The parameter name for the redirect url, sent as part of the form - // data and set to 'redirect' if this option is empty: - redirectParamName: undefined, - // Set the following option to the location of a postMessage window, - // to enable postMessage transport uploads: - postMessage: undefined, - // By default, XHR file uploads are sent as multipart/form-data. - // The iframe transport is always using multipart/form-data. - // Set to false to enable non-multipart XHR uploads: - multipart: true, - // To upload large files in smaller chunks, set the following option - // to a preferred maximum chunk size. If set to 0, null or undefined, - // or the browser does not support the required Blob API, files will - // be uploaded as a whole. - maxChunkSize: undefined, - // When a non-multipart upload or a chunked multipart upload has been - // aborted, this option can be used to resume the upload by setting - // it to the size of the already uploaded bytes. This option is most - // useful when modifying the options object inside of the "add" or - // "send" callbacks, as the options are cloned for each file upload. - uploadedBytes: undefined, - // By default, failed (abort or error) file uploads are removed from the - // global progress calculation. Set the following option to false to - // prevent recalculating the global progress data: - recalculateProgress: true, - // Interval in milliseconds to calculate and trigger progress events: - progressInterval: 100, - // Interval in milliseconds to calculate progress bitrate: - bitrateInterval: 500, - - // Additional form data to be sent along with the file uploads can be set - // using this option, which accepts an array of objects with name and - // value properties, a function returning such an array, a FormData - // object (for XHR file uploads), or a simple object. - // The form of the first fileInput is given as parameter to the function: - formData: function (form) { - return form.serializeArray(); - }, - - // The add callback is invoked as soon as files are added to the fileupload - // widget (via file input selection, drag & drop, paste or add API call). - // If the singleFileUploads option is enabled, this callback will be - // called once for each file in the selection for XHR file uplaods, else - // once for each file selection. - // The upload starts when the submit method is invoked on the data parameter. - // The data object contains a files property holding the added files - // and allows to override plugin options as well as define ajax settings. - // Listeners for this callback can also be bound the following way: - // .bind('fileuploadadd', func); - // data.submit() returns a Promise object and allows to attach additional - // handlers using jQuery's Deferred callbacks: - // data.submit().done(func).fail(func).always(func); - add: function (e, data) { - data.submit(); - }, - - // Other callbacks: - // Callback for the submit event of each file upload: - // submit: function (e, data) {}, // .bind('fileuploadsubmit', func); - // Callback for the start of each file upload request: - // send: function (e, data) {}, // .bind('fileuploadsend', func); - // Callback for successful uploads: - // done: function (e, data) {}, // .bind('fileuploaddone', func); - // Callback for failed (abort or error) uploads: - // fail: function (e, data) {}, // .bind('fileuploadfail', func); - // Callback for completed (success, abort or error) requests: - // always: function (e, data) {}, // .bind('fileuploadalways', func); - // Callback for upload progress events: - // progress: function (e, data) {}, // .bind('fileuploadprogress', func); - // Callback for global upload progress events: - // progressall: function (e, data) {}, // .bind('fileuploadprogressall', func); - // Callback for uploads start, equivalent to the global ajaxStart event: - // start: function (e) {}, // .bind('fileuploadstart', func); - // Callback for uploads stop, equivalent to the global ajaxStop event: - // stop: function (e) {}, // .bind('fileuploadstop', func); - // Callback for change events of the fileInput collection: - // change: function (e, data) {}, // .bind('fileuploadchange', func); - // Callback for paste events to the dropZone collection: - // paste: function (e, data) {}, // .bind('fileuploadpaste', func); - // Callback for drop events of the dropZone collection: - // drop: function (e, data) {}, // .bind('fileuploaddrop', func); - // Callback for dragover events of the dropZone collection: - // dragover: function (e) {}, // .bind('fileuploaddragover', func); - - // The plugin options are used as settings object for the ajax calls. - // The following are jQuery ajax settings required for the file uploads: - processData: false, - contentType: false, - cache: false - }, + 'use strict'; + if (typeof define === 'function' && define.amd) { + // Register as an anonymous AMD module: + define(['jquery', 'jquery/fileUploader/vendor/jquery.ui.widget'], factory); + } else if (typeof exports === 'object') { + // Node/CommonJS: + factory(require('jquery'), require('jquery/fileUploader/vendor/jquery.ui.widget')); + } else { + // Browser globals: + factory(window.jQuery); + } +})(function ($) { + 'use strict'; - // A list of options that require a refresh after assigning a new value: - _refreshOptionsList: [ - 'namespace', - 'dropZone', - 'fileInput', - 'multipart', - 'forceIframeTransport' - ], - - _BitrateTimer: function () { - this.timestamp = +(new Date()); - this.loaded = 0; - this.bitrate = 0; - this.getBitrate = function (now, loaded, interval) { - var timeDiff = now - this.timestamp; - if (!this.bitrate || !interval || timeDiff > interval) { - this.bitrate = (loaded - this.loaded) * (1000 / timeDiff) * 8; - this.loaded = loaded; - this.timestamp = now; - } - return this.bitrate; - }; - }, + // Detect file input support, based on + // https://viljamis.com/2012/file-upload-support-on-mobile/ + $.support.fileInput = !( + new RegExp( + // Handle devices which give false positives for the feature detection: + '(Android (1\\.[0156]|2\\.[01]))' + + '|(Windows Phone (OS 7|8\\.0))|(XBLWP)|(ZuneWP)|(WPDesktop)' + + '|(w(eb)?OSBrowser)|(webOS)' + + '|(Kindle/(1\\.0|2\\.[05]|3\\.0))' + ).test(window.navigator.userAgent) || + // Feature detection for all other devices: + $('').prop('disabled') + ); - _isXHRUpload: function (options) { - return !options.forceIframeTransport && - ((!options.multipart && $.support.xhrFileUpload) || - $.support.xhrFormDataFileUpload); - }, + // The FileReader API is not actually used, but works as feature detection, + // as some Safari versions (5?) support XHR file uploads via the FormData API, + // but not non-multipart XHR file uploads. + // window.XMLHttpRequestUpload is not available on IE10, so we check for + // window.ProgressEvent instead to detect XHR2 file upload capability: + $.support.xhrFileUpload = !!(window.ProgressEvent && window.FileReader); + $.support.xhrFormDataFileUpload = !!window.FormData; - _getFormData: function (options) { - var formData; - if (typeof options.formData === 'function') { - return options.formData(options.form); - } - if ($.isArray(options.formData)) { - return options.formData; - } - if (options.formData) { - formData = []; - $.each(options.formData, function (name, value) { - formData.push({name: name, value: value}); - }); - return formData; - } - return []; - }, + // Detect support for Blob slicing (required for chunked uploads): + $.support.blobSlice = + window.Blob && + (Blob.prototype.slice || + Blob.prototype.webkitSlice || + Blob.prototype.mozSlice); - _getTotal: function (files) { - var total = 0; - $.each(files, function (index, file) { - total += file.size || 1; - }); - return total; - }, + /** + * Helper function to create drag handlers for dragover/dragenter/dragleave + * + * @param {string} type Event type + * @returns {Function} Drag handler + */ + function getDragHandler(type) { + var isDragOver = type === 'dragover'; + return function (e) { + e.dataTransfer = e.originalEvent && e.originalEvent.dataTransfer; + var dataTransfer = e.dataTransfer; + if ( + dataTransfer && + $.inArray('Files', dataTransfer.types) !== -1 && + this._trigger(type, $.Event(type, { delegatedEvent: e })) !== false + ) { + e.preventDefault(); + if (isDragOver) { + dataTransfer.dropEffect = 'copy'; + } + } + }; + } + + // The fileupload widget listens for change events on file input fields defined + // via fileInput setting and paste or drop events of the given dropZone. + // In addition to the default jQuery Widget methods, the fileupload widget + // exposes the "add" and "send" methods, to add or directly send files using + // the fileupload API. + // By default, files added via file input selection, paste, drag & drop or + // "add" method are uploaded immediately, but it is possible to override + // the "add" callback option to queue file uploads. + $.widget('blueimp.fileupload', { + options: { + // The drop target element(s), by the default the complete document. + // Set to null to disable drag & drop support: + dropZone: $(document), + // The paste target element(s), by the default undefined. + // Set to a DOM node or jQuery object to enable file pasting: + pasteZone: undefined, + // The file input field(s), that are listened to for change events. + // If undefined, it is set to the file input fields inside + // of the widget element on plugin initialization. + // Set to null to disable the change listener. + fileInput: undefined, + // By default, the file input field is replaced with a clone after + // each input field change event. This is required for iframe transport + // queues and allows change events to be fired for the same file + // selection, but can be disabled by setting the following option to false: + replaceFileInput: true, + // The parameter name for the file form data (the request argument name). + // If undefined or empty, the name property of the file input field is + // used, or "files[]" if the file input name property is also empty, + // can be a string or an array of strings: + paramName: undefined, + // By default, each file of a selection is uploaded using an individual + // request for XHR type uploads. Set to false to upload file + // selections in one request each: + singleFileUploads: true, + // To limit the number of files uploaded with one XHR request, + // set the following option to an integer greater than 0: + limitMultiFileUploads: undefined, + // The following option limits the number of files uploaded with one + // XHR request to keep the request size under or equal to the defined + // limit in bytes: + limitMultiFileUploadSize: undefined, + // Multipart file uploads add a number of bytes to each uploaded file, + // therefore the following option adds an overhead for each file used + // in the limitMultiFileUploadSize configuration: + limitMultiFileUploadSizeOverhead: 512, + // Set the following option to true to issue all file upload requests + // in a sequential order: + sequentialUploads: false, + // To limit the number of concurrent uploads, + // set the following option to an integer greater than 0: + limitConcurrentUploads: undefined, + // Set the following option to true to force iframe transport uploads: + forceIframeTransport: false, + // Set the following option to the location of a redirect url on the + // origin server, for cross-domain iframe transport uploads: + redirect: undefined, + // The parameter name for the redirect url, sent as part of the form + // data and set to 'redirect' if this option is empty: + redirectParamName: undefined, + // Set the following option to the location of a postMessage window, + // to enable postMessage transport uploads: + postMessage: undefined, + // By default, XHR file uploads are sent as multipart/form-data. + // The iframe transport is always using multipart/form-data. + // Set to false to enable non-multipart XHR uploads: + multipart: true, + // To upload large files in smaller chunks, set the following option + // to a preferred maximum chunk size. If set to 0, null or undefined, + // or the browser does not support the required Blob API, files will + // be uploaded as a whole. + maxChunkSize: undefined, + // When a non-multipart upload or a chunked multipart upload has been + // aborted, this option can be used to resume the upload by setting + // it to the size of the already uploaded bytes. This option is most + // useful when modifying the options object inside of the "add" or + // "send" callbacks, as the options are cloned for each file upload. + uploadedBytes: undefined, + // By default, failed (abort or error) file uploads are removed from the + // global progress calculation. Set the following option to false to + // prevent recalculating the global progress data: + recalculateProgress: true, + // Interval in milliseconds to calculate and trigger progress events: + progressInterval: 100, + // Interval in milliseconds to calculate progress bitrate: + bitrateInterval: 500, + // By default, uploads are started automatically when adding files: + autoUpload: true, + // By default, duplicate file names are expected to be handled on + // the server-side. If this is not possible (e.g. when uploading + // files directly to Amazon S3), the following option can be set to + // an empty object or an object mapping existing filenames, e.g.: + // { "image.jpg": true, "image (1).jpg": true } + // If it is set, all files will be uploaded with unique filenames, + // adding increasing number suffixes if necessary, e.g.: + // "image (2).jpg" + uniqueFilenames: undefined, + + // Error and info messages: + messages: { + uploadedBytes: 'Uploaded bytes exceed file size' + }, + + // Translation function, gets the message key to be translated + // and an object with context specific data as arguments: + i18n: function (message, context) { + // eslint-disable-next-line no-param-reassign + message = this.messages[message] || message.toString(); + if (context) { + $.each(context, function (key, value) { + // eslint-disable-next-line no-param-reassign + message = message.replace('{' + key + '}', value); + }); + } + return message; + }, + + // Additional form data to be sent along with the file uploads can be set + // using this option, which accepts an array of objects with name and + // value properties, a function returning such an array, a FormData + // object (for XHR file uploads), or a simple object. + // The form of the first fileInput is given as parameter to the function: + formData: function (form) { + return form.serializeArray(); + }, + + // The add callback is invoked as soon as files are added to the fileupload + // widget (via file input selection, drag & drop, paste or add API call). + // If the singleFileUploads option is enabled, this callback will be + // called once for each file in the selection for XHR file uploads, else + // once for each file selection. + // + // The upload starts when the submit method is invoked on the data parameter. + // The data object contains a files property holding the added files + // and allows you to override plugin options as well as define ajax settings. + // + // Listeners for this callback can also be bound the following way: + // .on('fileuploadadd', func); + // + // data.submit() returns a Promise object and allows to attach additional + // handlers using jQuery's Deferred callbacks: + // data.submit().done(func).fail(func).always(func); + add: function (e, data) { + if (e.isDefaultPrevented()) { + return false; + } + if ( + data.autoUpload || + (data.autoUpload !== false && + $(this).fileupload('option', 'autoUpload')) + ) { + data.process().done(function () { + data.submit(); + }); + } + }, + + // Other callbacks: + + // Callback for the submit event of each file upload: + // submit: function (e, data) {}, // .on('fileuploadsubmit', func); + + // Callback for the start of each file upload request: + // send: function (e, data) {}, // .on('fileuploadsend', func); + + // Callback for successful uploads: + // done: function (e, data) {}, // .on('fileuploaddone', func); + + // Callback for failed (abort or error) uploads: + // fail: function (e, data) {}, // .on('fileuploadfail', func); + + // Callback for completed (success, abort or error) requests: + // always: function (e, data) {}, // .on('fileuploadalways', func); - _onProgress: function (e, data) { - if (e.lengthComputable) { - var now = +(new Date()), - total, - loaded; - if (data._time && data.progressInterval && - (now - data._time < data.progressInterval) && - e.loaded !== e.total) { - return; + // Callback for upload progress events: + // progress: function (e, data) {}, // .on('fileuploadprogress', func); + + // Callback for global upload progress events: + // progressall: function (e, data) {}, // .on('fileuploadprogressall', func); + + // Callback for uploads start, equivalent to the global ajaxStart event: + // start: function (e) {}, // .on('fileuploadstart', func); + + // Callback for uploads stop, equivalent to the global ajaxStop event: + // stop: function (e) {}, // .on('fileuploadstop', func); + + // Callback for change events of the fileInput(s): + // change: function (e, data) {}, // .on('fileuploadchange', func); + + // Callback for paste events to the pasteZone(s): + // paste: function (e, data) {}, // .on('fileuploadpaste', func); + + // Callback for drop events of the dropZone(s): + // drop: function (e, data) {}, // .on('fileuploaddrop', func); + + // Callback for dragover events of the dropZone(s): + // dragover: function (e) {}, // .on('fileuploaddragover', func); + + // Callback before the start of each chunk upload request (before form data initialization): + // chunkbeforesend: function (e, data) {}, // .on('fileuploadchunkbeforesend', func); + + // Callback for the start of each chunk upload request: + // chunksend: function (e, data) {}, // .on('fileuploadchunksend', func); + + // Callback for successful chunk uploads: + // chunkdone: function (e, data) {}, // .on('fileuploadchunkdone', func); + + // Callback for failed (abort or error) chunk uploads: + // chunkfail: function (e, data) {}, // .on('fileuploadchunkfail', func); + + // Callback for completed (success, abort or error) chunk upload requests: + // chunkalways: function (e, data) {}, // .on('fileuploadchunkalways', func); + + // The plugin options are used as settings object for the ajax calls. + // The following are jQuery ajax settings required for the file uploads: + processData: false, + contentType: false, + cache: false, + timeout: 0 + }, + + // jQuery versions before 1.8 require promise.pipe if the return value is + // used, as promise.then in older versions has a different behavior, see: + // https://blog.jquery.com/2012/08/09/jquery-1-8-released/ + // https://bugs.jquery.com/ticket/11010 + // https://github.com/blueimp/jQuery-File-Upload/pull/3435 + _promisePipe: (function () { + var parts = $.fn.jquery.split('.'); + return Number(parts[0]) > 1 || Number(parts[1]) > 7 ? 'then' : 'pipe'; + })(), + + // A list of options that require reinitializing event listeners and/or + // special initialization code: + _specialOptions: [ + 'fileInput', + 'dropZone', + 'pasteZone', + 'multipart', + 'forceIframeTransport' + ], + + _blobSlice: + $.support.blobSlice && + function () { + var slice = this.slice || this.webkitSlice || this.mozSlice; + return slice.apply(this, arguments); + }, + + _BitrateTimer: function () { + this.timestamp = Date.now ? Date.now() : new Date().getTime(); + this.loaded = 0; + this.bitrate = 0; + this.getBitrate = function (now, loaded, interval) { + var timeDiff = now - this.timestamp; + if (!this.bitrate || !interval || timeDiff > interval) { + this.bitrate = (loaded - this.loaded) * (1000 / timeDiff) * 8; + this.loaded = loaded; + this.timestamp = now; + } + return this.bitrate; + }; + }, + + _isXHRUpload: function (options) { + return ( + !options.forceIframeTransport && + ((!options.multipart && $.support.xhrFileUpload) || + $.support.xhrFormDataFileUpload) + ); + }, + + _getFormData: function (options) { + var formData; + if ($.type(options.formData) === 'function') { + return options.formData(options.form); + } + if ($.isArray(options.formData)) { + return options.formData; + } + if ($.type(options.formData) === 'object') { + formData = []; + $.each(options.formData, function (name, value) { + formData.push({ name: name, value: value }); + }); + return formData; + } + return []; + }, + + _getTotal: function (files) { + var total = 0; + $.each(files, function (index, file) { + total += file.size || 1; + }); + return total; + }, + + _initProgressObject: function (obj) { + var progress = { + loaded: 0, + total: 0, + bitrate: 0 + }; + if (obj._progress) { + $.extend(obj._progress, progress); + } else { + obj._progress = progress; + } + }, + + _initResponseObject: function (obj) { + var prop; + if (obj._response) { + for (prop in obj._response) { + if (Object.prototype.hasOwnProperty.call(obj._response, prop)) { + delete obj._response[prop]; + } + } + } else { + obj._response = {}; + } + }, + + _onProgress: function (e, data) { + if (e.lengthComputable) { + var now = Date.now ? Date.now() : new Date().getTime(), + loaded; + if ( + data._time && + data.progressInterval && + now - data._time < data.progressInterval && + e.loaded !== e.total + ) { + return; + } + data._time = now; + loaded = + Math.floor( + (e.loaded / e.total) * (data.chunkSize || data._progress.total) + ) + (data.uploadedBytes || 0); + // Add the difference from the previously loaded state + // to the global loaded counter: + this._progress.loaded += loaded - data._progress.loaded; + this._progress.bitrate = this._bitrateTimer.getBitrate( + now, + this._progress.loaded, + data.bitrateInterval + ); + data._progress.loaded = data.loaded = loaded; + data._progress.bitrate = data.bitrate = data._bitrateTimer.getBitrate( + now, + loaded, + data.bitrateInterval + ); + // Trigger a custom progress event with a total data property set + // to the file size(s) of the current upload and a loaded data + // property calculated accordingly: + this._trigger( + 'progress', + $.Event('progress', { delegatedEvent: e }), + data + ); + // Trigger a global progress event for all current file uploads, + // including ajax calls queued for sequential file uploads: + this._trigger( + 'progressall', + $.Event('progressall', { delegatedEvent: e }), + this._progress + ); + } + }, + + _initProgressListener: function (options) { + var that = this, + xhr = options.xhr ? options.xhr() : $.ajaxSettings.xhr(); + // Accesss to the native XHR object is required to add event listeners + // for the upload progress event: + if (xhr.upload) { + $(xhr.upload).on('progress', function (e) { + var oe = e.originalEvent; + // Make sure the progress event properties get copied over: + e.lengthComputable = oe.lengthComputable; + e.loaded = oe.loaded; + e.total = oe.total; + that._onProgress(e, options); + }); + options.xhr = function () { + return xhr; + }; + } + }, + + _deinitProgressListener: function (options) { + var xhr = options.xhr ? options.xhr() : $.ajaxSettings.xhr(); + if (xhr.upload) { + $(xhr.upload).off('progress'); + } + }, + + _isInstanceOf: function (type, obj) { + // Cross-frame instanceof check + return Object.prototype.toString.call(obj) === '[object ' + type + ']'; + }, + + _getUniqueFilename: function (name, map) { + // eslint-disable-next-line no-param-reassign + name = String(name); + if (map[name]) { + // eslint-disable-next-line no-param-reassign + name = name.replace(/(?: \(([\d]+)\))?(\.[^.]+)?$/, function ( + _, + p1, + p2 + ) { + var index = p1 ? Number(p1) + 1 : 1; + var ext = p2 || ''; + return ' (' + index + ')' + ext; + }); + return this._getUniqueFilename(name, map); + } + map[name] = true; + return name; + }, + + _initXHRData: function (options) { + var that = this, + formData, + file = options.files[0], + // Ignore non-multipart setting if not supported: + multipart = options.multipart || !$.support.xhrFileUpload, + paramName = + $.type(options.paramName) === 'array' + ? options.paramName[0] + : options.paramName; + options.headers = $.extend({}, options.headers); + if (options.contentRange) { + options.headers['Content-Range'] = options.contentRange; + } + if (!multipart || options.blob || !this._isInstanceOf('File', file)) { + options.headers['Content-Disposition'] = + 'attachment; filename="' + + encodeURI(file.uploadName || file.name) + + '"'; + } + if (!multipart) { + options.contentType = file.type || 'application/octet-stream'; + options.data = options.blob || file; + } else if ($.support.xhrFormDataFileUpload) { + if (options.postMessage) { + // window.postMessage does not allow sending FormData + // objects, so we just add the File/Blob objects to + // the formData array and let the postMessage window + // create the FormData object out of this array: + formData = this._getFormData(options); + if (options.blob) { + formData.push({ + name: paramName, + value: options.blob + }); + } else { + $.each(options.files, function (index, file) { + formData.push({ + name: + ($.type(options.paramName) === 'array' && + options.paramName[index]) || + paramName, + value: file + }); + }); + } + } else { + if (that._isInstanceOf('FormData', options.formData)) { + formData = options.formData; + } else { + formData = new FormData(); + $.each(this._getFormData(options), function (index, field) { + formData.append(field.name, field.value); + }); + } + if (options.blob) { + formData.append( + paramName, + options.blob, + file.uploadName || file.name + ); + } else { + $.each(options.files, function (index, file) { + // This check allows the tests to run with + // dummy objects: + if ( + that._isInstanceOf('File', file) || + that._isInstanceOf('Blob', file) + ) { + var fileName = file.uploadName || file.name; + if (options.uniqueFilenames) { + fileName = that._getUniqueFilename( + fileName, + options.uniqueFilenames + ); } - data._time = now; - total = data.total || this._getTotal(data.files); - loaded = parseInt( - e.loaded / e.total * (data.chunkSize || total), - 10 - ) + (data.uploadedBytes || 0); - this._loaded += loaded - (data.loaded || data.uploadedBytes || 0); - data.lengthComputable = true; - data.loaded = loaded; - data.total = total; - data.bitrate = data._bitrateTimer.getBitrate( - now, - loaded, - data.bitrateInterval + formData.append( + ($.type(options.paramName) === 'array' && + options.paramName[index]) || + paramName, + file, + fileName ); - // Trigger a custom progress event with a total data property set - // to the file size(s) of the current upload and a loaded data - // property calculated accordingly: - this._trigger('progress', e, data); - // Trigger a global progress event for all current file uploads, - // including ajax calls queued for sequential file uploads: - this._trigger('progressall', e, { - lengthComputable: true, - loaded: this._loaded, - total: this._total, - bitrate: this._bitrateTimer.getBitrate( - now, - this._loaded, - data.bitrateInterval - ) - }); - } - }, + } + }); + } + } + options.data = formData; + } + // Blob reference is not needed anymore, free memory: + options.blob = null; + }, - _initProgressListener: function (options) { - var that = this, - xhr = options.xhr ? options.xhr() : $.ajaxSettings.xhr(); - // Accesss to the native XHR object is required to add event listeners - // for the upload progress event: - if (xhr.upload) { - $(xhr.upload).bind('progress', function (e) { - var oe = e.originalEvent; - // Make sure the progress event properties get copied over: - e.lengthComputable = oe.lengthComputable; - e.loaded = oe.loaded; - e.total = oe.total; - that._onProgress(e, options); - }); - options.xhr = function () { - return xhr; - }; - } - }, + _initIframeSettings: function (options) { + var targetHost = $('').prop('href', options.url).prop('host'); + // Setting the dataType to iframe enables the iframe transport: + options.dataType = 'iframe ' + (options.dataType || ''); + // The iframe transport accepts a serialized array as form data: + options.formData = this._getFormData(options); + // Add redirect url to form data on cross-domain uploads: + if (options.redirect && targetHost && targetHost !== location.host) { + options.formData.push({ + name: options.redirectParamName || 'redirect', + value: options.redirect + }); + } + }, - _initXHRData: function (options) { - var formData, - file = options.files[0], - // Ignore non-multipart setting if not supported: - multipart = options.multipart || !$.support.xhrFileUpload, - paramName = options.paramName[0]; - if (!multipart || options.blob) { - // For non-multipart uploads and chunked uploads, - // file meta data is not part of the request body, - // so we transmit this data as part of the HTTP headers. - // For cross domain requests, these headers must be allowed - // via Access-Control-Allow-Headers or removed using - // the beforeSend callback: - options.headers = $.extend(options.headers, { - 'X-File-Name': file.name, - 'X-File-Type': file.type, - 'X-File-Size': file.size - }); - if (!options.blob) { - // Non-chunked non-multipart upload: - options.contentType = file.type; - options.data = file; - } else if (!multipart) { - // Chunked non-multipart upload: - options.contentType = 'application/octet-stream'; - options.data = options.blob; - } - } - if (multipart && $.support.xhrFormDataFileUpload) { - if (options.postMessage) { - // window.postMessage does not allow sending FormData - // objects, so we just add the File/Blob objects to - // the formData array and let the postMessage window - // create the FormData object out of this array: - formData = this._getFormData(options); - if (options.blob) { - formData.push({ - name: paramName, - value: options.blob - }); - } else { - $.each(options.files, function (index, file) { - formData.push({ - name: options.paramName[index] || paramName, - value: file - }); - }); - } - } else { - if (options.formData instanceof FormData) { - formData = options.formData; - } else { - formData = new FormData(); - $.each(this._getFormData(options), function (index, field) { - formData.append(field.name, field.value); - }); - } - if (options.blob) { - formData.append(paramName, options.blob, file.name); - } else { - $.each(options.files, function (index, file) { - // File objects are also Blob instances. - // This check allows the tests to run with - // dummy objects: - if (file instanceof Blob) { - formData.append( - options.paramName[index] || paramName, - file, - file.name - ); - } - }); - } - } - options.data = formData; - } - // Blob reference is not needed anymore, free memory: - options.blob = null; - }, + _initDataSettings: function (options) { + if (this._isXHRUpload(options)) { + if (!this._chunkedUpload(options, true)) { + if (!options.data) { + this._initXHRData(options); + } + this._initProgressListener(options); + } + if (options.postMessage) { + // Setting the dataType to postmessage enables the + // postMessage transport: + options.dataType = 'postmessage ' + (options.dataType || ''); + } + } else { + this._initIframeSettings(options); + } + }, - _initIframeSettings: function (options) { - // Setting the dataType to iframe enables the iframe transport: - options.dataType = 'iframe ' + (options.dataType || ''); - // The iframe transport accepts a serialized array as form data: - options.formData = this._getFormData(options); - // Add redirect url to form data on cross-domain uploads: - if (options.redirect && $('').prop('href', options.url) - .prop('host') !== location.host) { - options.formData.push({ - name: options.redirectParamName || 'redirect', - value: options.redirect - }); - } - }, + _getParamName: function (options) { + var fileInput = $(options.fileInput), + paramName = options.paramName; + if (!paramName) { + paramName = []; + fileInput.each(function () { + var input = $(this), + name = input.prop('name') || 'files[]', + i = (input.prop('files') || [1]).length; + while (i) { + paramName.push(name); + i -= 1; + } + }); + if (!paramName.length) { + paramName = [fileInput.prop('name') || 'files[]']; + } + } else if (!$.isArray(paramName)) { + paramName = [paramName]; + } + return paramName; + }, - _initDataSettings: function (options) { - if (this._isXHRUpload(options)) { - if (!this._chunkedUpload(options, true)) { - if (!options.data) { - this._initXHRData(options); - } - this._initProgressListener(options); - } - if (options.postMessage) { - // Setting the dataType to postmessage enables the - // postMessage transport: - options.dataType = 'postmessage ' + (options.dataType || ''); - } - } else { - this._initIframeSettings(options, 'iframe'); - } - }, + _initFormSettings: function (options) { + // Retrieve missing options from the input field and the + // associated form, if available: + if (!options.form || !options.form.length) { + options.form = $(options.fileInput.prop('form')); + // If the given file input doesn't have an associated form, + // use the default widget file input's form: + if (!options.form.length) { + options.form = $(this.options.fileInput.prop('form')); + } + } + options.paramName = this._getParamName(options); + if (!options.url) { + options.url = options.form.prop('action') || location.href; + } + // The HTTP request method must be "POST" or "PUT": + options.type = ( + options.type || + ($.type(options.form.prop('method')) === 'string' && + options.form.prop('method')) || + '' + ).toUpperCase(); + if ( + options.type !== 'POST' && + options.type !== 'PUT' && + options.type !== 'PATCH' + ) { + options.type = 'POST'; + } + if (!options.formAcceptCharset) { + options.formAcceptCharset = options.form.attr('accept-charset'); + } + }, - _getParamName: function (options) { - var fileInput = $(options.fileInput), - paramName = options.paramName; - if (!paramName) { - paramName = []; - fileInput.each(function () { - var input = $(this), - name = input.prop('name') || 'files[]', - i = (input.prop('files') || [1]).length; - while (i) { - paramName.push(name); - i -= 1; - } - }); - if (!paramName.length) { - paramName = [fileInput.prop('name') || 'files[]']; - } - } else if (!$.isArray(paramName)) { - paramName = [paramName]; - } - return paramName; - }, + _getAJAXSettings: function (data) { + var options = $.extend({}, this.options, data); + this._initFormSettings(options); + this._initDataSettings(options); + return options; + }, - _initFormSettings: function (options) { - // Retrieve missing options from the input field and the - // associated form, if available: - if (!options.form || !options.form.length) { - options.form = $(options.fileInput.prop('form')); - } - options.paramName = this._getParamName(options); - if (!options.url) { - options.url = options.form.prop('action') || location.href; - } - // The HTTP request method must be "POST" or "PUT": - options.type = (options.type || options.form.prop('method') || '') - .toUpperCase(); - if (options.type !== 'POST' && options.type !== 'PUT') { - options.type = 'POST'; - } - if (!options.formAcceptCharset) { - options.formAcceptCharset = options.form.attr('accept-charset'); - } - }, + // jQuery 1.6 doesn't provide .state(), + // while jQuery 1.8+ removed .isRejected() and .isResolved(): + _getDeferredState: function (deferred) { + if (deferred.state) { + return deferred.state(); + } + if (deferred.isResolved()) { + return 'resolved'; + } + if (deferred.isRejected()) { + return 'rejected'; + } + return 'pending'; + }, - _getAJAXSettings: function (data) { - var options = $.extend({}, this.options, data); - this._initFormSettings(options); - this._initDataSettings(options); - return options; - }, + // Maps jqXHR callbacks to the equivalent + // methods of the given Promise object: + _enhancePromise: function (promise) { + promise.success = promise.done; + promise.error = promise.fail; + promise.complete = promise.always; + return promise; + }, - // Maps jqXHR callbacks to the equivalent - // methods of the given Promise object: - _enhancePromise: function (promise) { - promise.success = promise.done; - promise.error = promise.fail; - promise.complete = promise.always; - return promise; - }, + // Creates and returns a Promise object enhanced with + // the jqXHR methods abort, success, error and complete: + _getXHRPromise: function (resolveOrReject, context, args) { + var dfd = $.Deferred(), + promise = dfd.promise(); + // eslint-disable-next-line no-param-reassign + context = context || this.options.context || promise; + if (resolveOrReject === true) { + dfd.resolveWith(context, args); + } else if (resolveOrReject === false) { + dfd.rejectWith(context, args); + } + promise.abort = dfd.promise; + return this._enhancePromise(promise); + }, - // Creates and returns a Promise object enhanced with - // the jqXHR methods abort, success, error and complete: - _getXHRPromise: function (resolveOrReject, context, args) { - var dfd = $.Deferred(), - promise = dfd.promise(); - context = context || this.options.context || promise; - if (resolveOrReject === true) { - dfd.resolveWith(context, args); - } else if (resolveOrReject === false) { - dfd.rejectWith(context, args); - } - promise.abort = dfd.promise; - return this._enhancePromise(promise); - }, + // Adds convenience methods to the data callback argument: + _addConvenienceMethods: function (e, data) { + var that = this, + getPromise = function (args) { + return $.Deferred().resolveWith(that, args).promise(); + }; + data.process = function (resolveFunc, rejectFunc) { + if (resolveFunc || rejectFunc) { + data._processQueue = this._processQueue = (this._processQueue || + getPromise([this])) + [that._promisePipe](function () { + if (data.errorThrown) { + return $.Deferred().rejectWith(that, [data]).promise(); + } + return getPromise(arguments); + }) + [that._promisePipe](resolveFunc, rejectFunc); + } + return this._processQueue || getPromise([this]); + }; + data.submit = function () { + if (this.state() !== 'pending') { + data.jqXHR = this.jqXHR = + that._trigger( + 'submit', + $.Event('submit', { delegatedEvent: e }), + this + ) !== false && that._onSend(e, this); + } + return this.jqXHR || that._getXHRPromise(); + }; + data.abort = function () { + if (this.jqXHR) { + return this.jqXHR.abort(); + } + this.errorThrown = 'abort'; + that._trigger('fail', null, this); + return that._getXHRPromise(false); + }; + data.state = function () { + if (this.jqXHR) { + return that._getDeferredState(this.jqXHR); + } + if (this._processQueue) { + return that._getDeferredState(this._processQueue); + } + }; + data.processing = function () { + return ( + !this.jqXHR && + this._processQueue && + that._getDeferredState(this._processQueue) === 'pending' + ); + }; + data.progress = function () { + return this._progress; + }; + data.response = function () { + return this._response; + }; + }, - // Uploads a file in multiple, sequential requests - // by splitting the file up in multiple blob chunks. - // If the second parameter is true, only tests if the file - // should be uploaded in chunks, but does not invoke any - // upload requests: - _chunkedUpload: function (options, testOnly) { - var that = this, - file = options.files[0], - fs = file.size, - ub = options.uploadedBytes = options.uploadedBytes || 0, - mcs = options.maxChunkSize || fs, - // Use the Blob methods with the slice implementation - // according to the W3C Blob API specification: - slice = file.webkitSlice || file.mozSlice || file.slice, - upload, - n, - jqXHR, - pipe; - if (!(this._isXHRUpload(options) && slice && (ub || mcs < fs)) || - options.data) { - return false; - } - if (testOnly) { - return true; + // Parses the Range header from the server response + // and returns the uploaded bytes: + _getUploadedBytes: function (jqXHR) { + var range = jqXHR.getResponseHeader('Range'), + parts = range && range.split('-'), + upperBytesPos = parts && parts.length > 1 && parseInt(parts[1], 10); + return upperBytesPos && upperBytesPos + 1; + }, + + // Uploads a file in multiple, sequential requests + // by splitting the file up in multiple blob chunks. + // If the second parameter is true, only tests if the file + // should be uploaded in chunks, but does not invoke any + // upload requests: + _chunkedUpload: function (options, testOnly) { + options.uploadedBytes = options.uploadedBytes || 0; + var that = this, + file = options.files[0], + fs = file.size, + ub = options.uploadedBytes, + mcs = options.maxChunkSize || fs, + slice = this._blobSlice, + dfd = $.Deferred(), + promise = dfd.promise(), + jqXHR, + upload; + if ( + !( + this._isXHRUpload(options) && + slice && + (ub || ($.type(mcs) === 'function' ? mcs(options) : mcs) < fs) + ) || + options.data + ) { + return false; + } + if (testOnly) { + return true; + } + if (ub >= fs) { + file.error = options.i18n('uploadedBytes'); + return this._getXHRPromise(false, options.context, [ + null, + 'error', + file.error + ]); + } + // The chunk upload method: + upload = function () { + // Clone the options object for each chunk upload: + var o = $.extend({}, options), + currentLoaded = o._progress.loaded; + o.blob = slice.call( + file, + ub, + ub + ($.type(mcs) === 'function' ? mcs(o) : mcs), + file.type + ); + // Store the current chunk size, as the blob itself + // will be dereferenced after data processing: + o.chunkSize = o.blob.size; + // Expose the chunk bytes position range: + o.contentRange = + 'bytes ' + ub + '-' + (ub + o.chunkSize - 1) + '/' + fs; + // Trigger chunkbeforesend to allow form data to be updated for this chunk + that._trigger('chunkbeforesend', null, o); + // Process the upload data (the blob and potential form data): + that._initXHRData(o); + // Add progress listeners for this chunk upload: + that._initProgressListener(o); + jqXHR = ( + (that._trigger('chunksend', null, o) !== false && $.ajax(o)) || + that._getXHRPromise(false, o.context) + ) + .done(function (result, textStatus, jqXHR) { + ub = that._getUploadedBytes(jqXHR) || ub + o.chunkSize; + // Create a progress event if no final progress event + // with loaded equaling total has been triggered + // for this chunk: + if (currentLoaded + o.chunkSize - o._progress.loaded) { + that._onProgress( + $.Event('progress', { + lengthComputable: true, + loaded: ub - o.uploadedBytes, + total: ub - o.uploadedBytes + }), + o + ); } - if (ub >= fs) { - file.error = 'uploadedBytes'; - return this._getXHRPromise( - false, - options.context, - [null, 'error', file.error] - ); + options.uploadedBytes = o.uploadedBytes = ub; + o.result = result; + o.textStatus = textStatus; + o.jqXHR = jqXHR; + that._trigger('chunkdone', null, o); + that._trigger('chunkalways', null, o); + if (ub < fs) { + // File upload not yet complete, + // continue with the next chunk: + upload(); + } else { + dfd.resolveWith(o.context, [result, textStatus, jqXHR]); } - // n is the number of blobs to upload, - // calculated via filesize, uploaded bytes and max chunk size: - n = Math.ceil((fs - ub) / mcs); - // The chunk upload method accepting the chunk number as parameter: - upload = function (i) { - if (!i) { - return that._getXHRPromise(true, options.context); - } - // Upload the blobs in sequential order: - return upload(i -= 1).pipe(function () { - // Clone the options object for each chunk upload: - var o = $.extend({}, options); - o.blob = slice.call( - file, - ub + i * mcs, - ub + (i + 1) * mcs - ); - // Expose the chunk index: - o.chunkIndex = i; - // Expose the number of chunks: - o.chunksNumber = n; - // Store the current chunk size, as the blob itself - // will be dereferenced after data processing: - o.chunkSize = o.blob.size; - // Process the upload data (the blob and potential form data): - that._initXHRData(o); - // Add progress listeners for this chunk upload: - that._initProgressListener(o); - jqXHR = ($.ajax(o) || that._getXHRPromise(false, o.context)) - .done(function () { - // Create a progress event if upload is done and - // no progress event has been invoked for this chunk: - if (!o.loaded) { - that._onProgress($.Event('progress', { - lengthComputable: true, - loaded: o.chunkSize, - total: o.chunkSize - }), o); - } - options.uploadedBytes = o.uploadedBytes += - o.chunkSize; - }); - return jqXHR; - }); - }; - // Return the piped Promise object, enhanced with an abort method, - // which is delegated to the jqXHR object of the current upload, - // and jqXHR callbacks mapped to the equivalent Promise methods: - pipe = upload(n); - pipe.abort = function () { - return jqXHR.abort(); - }; - return this._enhancePromise(pipe); - }, + }) + .fail(function (jqXHR, textStatus, errorThrown) { + o.jqXHR = jqXHR; + o.textStatus = textStatus; + o.errorThrown = errorThrown; + that._trigger('chunkfail', null, o); + that._trigger('chunkalways', null, o); + dfd.rejectWith(o.context, [jqXHR, textStatus, errorThrown]); + }) + .always(function () { + that._deinitProgressListener(o); + }); + }; + this._enhancePromise(promise); + promise.abort = function () { + return jqXHR.abort(); + }; + upload(); + return promise; + }, - _beforeSend: function (e, data) { - if (this._active === 0) { - // the start callback is triggered when an upload starts - // and no other uploads are currently running, - // equivalent to the global ajaxStart event: - this._trigger('start'); - // Set timer for global bitrate progress calculation: - this._bitrateTimer = new this._BitrateTimer(); - } - this._active += 1; - // Initialize the global progress values: - this._loaded += data.uploadedBytes || 0; - this._total += this._getTotal(data.files); - }, + _beforeSend: function (e, data) { + if (this._active === 0) { + // the start callback is triggered when an upload starts + // and no other uploads are currently running, + // equivalent to the global ajaxStart event: + this._trigger('start'); + // Set timer for global bitrate progress calculation: + this._bitrateTimer = new this._BitrateTimer(); + // Reset the global progress values: + this._progress.loaded = this._progress.total = 0; + this._progress.bitrate = 0; + } + // Make sure the container objects for the .response() and + // .progress() methods on the data object are available + // and reset to their initial state: + this._initResponseObject(data); + this._initProgressObject(data); + data._progress.loaded = data.loaded = data.uploadedBytes || 0; + data._progress.total = data.total = this._getTotal(data.files) || 1; + data._progress.bitrate = data.bitrate = 0; + this._active += 1; + // Initialize the global progress values: + this._progress.loaded += data.loaded; + this._progress.total += data.total; + }, - _onDone: function (result, textStatus, jqXHR, options) { - if (!this._isXHRUpload(options)) { - // Create a progress event for each iframe load: - this._onProgress($.Event('progress', { - lengthComputable: true, - loaded: 1, - total: 1 - }), options); - } - options.result = result; - options.textStatus = textStatus; - options.jqXHR = jqXHR; - this._trigger('done', null, options); - }, + _onDone: function (result, textStatus, jqXHR, options) { + var total = options._progress.total, + response = options._response; + if (options._progress.loaded < total) { + // Create a progress event if no final progress event + // with loaded equaling total has been triggered: + this._onProgress( + $.Event('progress', { + lengthComputable: true, + loaded: total, + total: total + }), + options + ); + } + response.result = options.result = result; + response.textStatus = options.textStatus = textStatus; + response.jqXHR = options.jqXHR = jqXHR; + this._trigger('done', null, options); + }, - _onFail: function (jqXHR, textStatus, errorThrown, options) { - options.jqXHR = jqXHR; - options.textStatus = textStatus; - options.errorThrown = errorThrown; - this._trigger('fail', null, options); - if (options.recalculateProgress) { - // Remove the failed (error or abort) file upload from - // the global progress calculation: - this._loaded -= options.loaded || options.uploadedBytes || 0; - this._total -= options.total || this._getTotal(options.files); - } - }, + _onFail: function (jqXHR, textStatus, errorThrown, options) { + var response = options._response; + if (options.recalculateProgress) { + // Remove the failed (error or abort) file upload from + // the global progress calculation: + this._progress.loaded -= options._progress.loaded; + this._progress.total -= options._progress.total; + } + response.jqXHR = options.jqXHR = jqXHR; + response.textStatus = options.textStatus = textStatus; + response.errorThrown = options.errorThrown = errorThrown; + this._trigger('fail', null, options); + }, - _onAlways: function (jqXHRorResult, textStatus, jqXHRorError, options) { - this._active -= 1; - options.textStatus = textStatus; - if (jqXHRorError && jqXHRorError.always) { - options.jqXHR = jqXHRorError; - options.result = jqXHRorResult; - } else { - options.jqXHR = jqXHRorResult; - options.errorThrown = jqXHRorError; - } - this._trigger('always', null, options); - if (this._active === 0) { - // The stop callback is triggered when all uploads have - // been completed, equivalent to the global ajaxStop event: - this._trigger('stop'); - // Reset the global progress values: - this._loaded = this._total = 0; - this._bitrateTimer = null; - } - }, + _onAlways: function (jqXHRorResult, textStatus, jqXHRorError, options) { + // jqXHRorResult, textStatus and jqXHRorError are added to the + // options object via done and fail callbacks + this._trigger('always', null, options); + }, - _onSend: function (e, data) { - var that = this, - jqXHR, - slot, - pipe, - options = that._getAJAXSettings(data), - send = function (resolve, args) { - that._sending += 1; - // Set timer for bitrate progress calculation: - options._bitrateTimer = new that._BitrateTimer(); - jqXHR = jqXHR || ( - (resolve !== false && - that._trigger('send', e, options) !== false && - (that._chunkedUpload(options) || $.ajax(options))) || - that._getXHRPromise(false, options.context, args) - ).done(function (result, textStatus, jqXHR) { - that._onDone(result, textStatus, jqXHR, options); - }).fail(function (jqXHR, textStatus, errorThrown) { - that._onFail(jqXHR, textStatus, errorThrown, options); - }).always(function (jqXHRorResult, textStatus, jqXHRorError) { - that._sending -= 1; - that._onAlways( - jqXHRorResult, - textStatus, - jqXHRorError, - options - ); - if (options.limitConcurrentUploads && - options.limitConcurrentUploads > that._sending) { - // Start the next queued upload, - // that has not been aborted: - var nextSlot = that._slots.shift(), - isPending; - while (nextSlot) { - // jQuery 1.6 doesn't provide .state(), - // while jQuery 1.8+ removed .isRejected(): - isPending = nextSlot.state ? - nextSlot.state() === 'pending' : - !nextSlot.isRejected(); - if (isPending) { - nextSlot.resolve(); - break; - } - nextSlot = that._slots.shift(); - } - } - }); - return jqXHR; - }; - this._beforeSend(e, options); - if (this.options.sequentialUploads || - (this.options.limitConcurrentUploads && - this.options.limitConcurrentUploads <= this._sending)) { - if (this.options.limitConcurrentUploads > 1) { - slot = $.Deferred(); - this._slots.push(slot); - pipe = slot.pipe(send); - } else { - pipe = (this._sequence = this._sequence.pipe(send, send)); - } - // Return the piped Promise object, enhanced with an abort method, - // which is delegated to the jqXHR object of the current upload, - // and jqXHR callbacks mapped to the equivalent Promise methods: - pipe.abort = function () { - var args = [undefined, 'abort', 'abort']; - if (!jqXHR) { - if (slot) { - slot.rejectWith(pipe, args); - } - return send(false, args); + _onSend: function (e, data) { + if (!data.submit) { + this._addConvenienceMethods(e, data); + } + var that = this, + jqXHR, + aborted, + slot, + pipe, + options = that._getAJAXSettings(data), + send = function () { + that._sending += 1; + // Set timer for bitrate progress calculation: + options._bitrateTimer = new that._BitrateTimer(); + jqXHR = + jqXHR || + ( + ((aborted || + that._trigger( + 'send', + $.Event('send', { delegatedEvent: e }), + options + ) === false) && + that._getXHRPromise(false, options.context, aborted)) || + that._chunkedUpload(options) || + $.ajax(options) + ) + .done(function (result, textStatus, jqXHR) { + that._onDone(result, textStatus, jqXHR, options); + }) + .fail(function (jqXHR, textStatus, errorThrown) { + that._onFail(jqXHR, textStatus, errorThrown, options); + }) + .always(function (jqXHRorResult, textStatus, jqXHRorError) { + that._deinitProgressListener(options); + that._onAlways( + jqXHRorResult, + textStatus, + jqXHRorError, + options + ); + that._sending -= 1; + that._active -= 1; + if ( + options.limitConcurrentUploads && + options.limitConcurrentUploads > that._sending + ) { + // Start the next queued upload, + // that has not been aborted: + var nextSlot = that._slots.shift(); + while (nextSlot) { + if (that._getDeferredState(nextSlot) === 'pending') { + nextSlot.resolve(); + break; } - return jqXHR.abort(); - }; - return this._enhancePromise(pipe); + nextSlot = that._slots.shift(); + } + } + if (that._active === 0) { + // The stop callback is triggered when all uploads have + // been completed, equivalent to the global ajaxStop event: + that._trigger('stop'); + } + }); + return jqXHR; + }; + this._beforeSend(e, options); + if ( + this.options.sequentialUploads || + (this.options.limitConcurrentUploads && + this.options.limitConcurrentUploads <= this._sending) + ) { + if (this.options.limitConcurrentUploads > 1) { + slot = $.Deferred(); + this._slots.push(slot); + pipe = slot[that._promisePipe](send); + } else { + this._sequence = this._sequence[that._promisePipe](send, send); + pipe = this._sequence; + } + // Return the piped Promise object, enhanced with an abort method, + // which is delegated to the jqXHR object of the current upload, + // and jqXHR callbacks mapped to the equivalent Promise methods: + pipe.abort = function () { + aborted = [undefined, 'abort', 'abort']; + if (!jqXHR) { + if (slot) { + slot.rejectWith(options.context, aborted); } return send(); - }, + } + return jqXHR.abort(); + }; + return this._enhancePromise(pipe); + } + return send(); + }, - _onAdd: function (e, data) { - var that = this, - result = true, - options = $.extend({}, this.options, data), - limit = options.limitMultiFileUploads, - paramName = this._getParamName(options), - paramNameSet, - paramNameSlice, - fileSet, - i; - if (!(options.singleFileUploads || limit) || - !this._isXHRUpload(options)) { - fileSet = [data.files]; - paramNameSet = [paramName]; - } else if (!options.singleFileUploads && limit) { - fileSet = []; - paramNameSet = []; - for (i = 0; i < data.files.length; i += limit) { - fileSet.push(data.files.slice(i, i + limit)); - paramNameSlice = paramName.slice(i, i + limit); - if (!paramNameSlice.length) { - paramNameSlice = paramName; - } - paramNameSet.push(paramNameSlice); - } - } else { - paramNameSet = paramName; + _onAdd: function (e, data) { + var that = this, + result = true, + options = $.extend({}, this.options, data), + files = data.files, + filesLength = files.length, + limit = options.limitMultiFileUploads, + limitSize = options.limitMultiFileUploadSize, + overhead = options.limitMultiFileUploadSizeOverhead, + batchSize = 0, + paramName = this._getParamName(options), + paramNameSet, + paramNameSlice, + fileSet, + i, + j = 0; + if (!filesLength) { + return false; + } + if (limitSize && files[0].size === undefined) { + limitSize = undefined; + } + if ( + !(options.singleFileUploads || limit || limitSize) || + !this._isXHRUpload(options) + ) { + fileSet = [files]; + paramNameSet = [paramName]; + } else if (!(options.singleFileUploads || limitSize) && limit) { + fileSet = []; + paramNameSet = []; + for (i = 0; i < filesLength; i += limit) { + fileSet.push(files.slice(i, i + limit)); + paramNameSlice = paramName.slice(i, i + limit); + if (!paramNameSlice.length) { + paramNameSlice = paramName; + } + paramNameSet.push(paramNameSlice); + } + } else if (!options.singleFileUploads && limitSize) { + fileSet = []; + paramNameSet = []; + for (i = 0; i < filesLength; i = i + 1) { + batchSize += files[i].size + overhead; + if ( + i + 1 === filesLength || + batchSize + files[i + 1].size + overhead > limitSize || + (limit && i + 1 - j >= limit) + ) { + fileSet.push(files.slice(j, i + 1)); + paramNameSlice = paramName.slice(j, i + 1); + if (!paramNameSlice.length) { + paramNameSlice = paramName; } - data.originalFiles = data.files; - $.each(fileSet || data.files, function (index, element) { - var newData = $.extend({}, data); - newData.files = fileSet ? element : [element]; - newData.paramName = paramNameSet[index]; - newData.submit = function () { - newData.jqXHR = this.jqXHR = - (that._trigger('submit', e, this) !== false) && - that._onSend(e, this); - return this.jqXHR; - }; - return (result = that._trigger('add', e, newData)); - }); - return result; - }, + paramNameSet.push(paramNameSlice); + j = i + 1; + batchSize = 0; + } + } + } else { + paramNameSet = paramName; + } + data.originalFiles = files; + $.each(fileSet || files, function (index, element) { + var newData = $.extend({}, data); + newData.files = fileSet ? element : [element]; + newData.paramName = paramNameSet[index]; + that._initResponseObject(newData); + that._initProgressObject(newData); + that._addConvenienceMethods(e, newData); + result = that._trigger( + 'add', + $.Event('add', { delegatedEvent: e }), + newData + ); + return result; + }); + return result; + }, - _replaceFileInput: function (input) { - var inputClone = input.clone(true); - $('
').append(inputClone)[0].reset(); - // Detaching allows to insert the fileInput on another form - // without loosing the file input value: - input.after(inputClone).detach(); - // Avoid memory leaks with the detached file input: - $.cleanData(input.unbind('remove')); - // Replace the original file input element in the fileInput - // collection with the clone, which has been copied including - // event handlers: - this.options.fileInput = this.options.fileInput.map(function (i, el) { - if (el === input[0]) { - return inputClone[0]; - } - return el; - }); - // If the widget has been initialized on the file input itself, - // override this.element with the file input clone: - if (input[0] === this.element[0]) { - this.element = inputClone; - } - }, + _replaceFileInput: function (data) { + var input = data.fileInput, + inputClone = input.clone(true), + restoreFocus = input.is(document.activeElement); + // Add a reference for the new cloned file input to the data argument: + data.fileInputClone = inputClone; + $('
').append(inputClone)[0].reset(); + // Detaching allows to insert the fileInput on another form + // without loosing the file input value: + input.after(inputClone).detach(); + // If the fileInput had focus before it was detached, + // restore focus to the inputClone. + if (restoreFocus) { + inputClone.trigger('focus'); + } + // Avoid memory leaks with the detached file input: + $.cleanData(input.off('remove')); + // Replace the original file input element in the fileInput + // elements set with the clone, which has been copied including + // event handlers: + this.options.fileInput = this.options.fileInput.map(function (i, el) { + if (el === input[0]) { + return inputClone[0]; + } + return el; + }); + // If the widget has been initialized on the file input itself, + // override this.element with the file input clone: + if (input[0] === this.element[0]) { + this.element = inputClone; + } + }, - _handleFileTreeEntry: function (entry, path) { - var that = this, - dfd = $.Deferred(), - errorHandler = function () { - dfd.reject(); - }, - dirReader; - path = path || ''; - if (entry.isFile) { - entry.file(function (file) { - file.relativePath = path; - dfd.resolve(file); - }, errorHandler); - } else if (entry.isDirectory) { - dirReader = entry.createReader(); - dirReader.readEntries(function (entries) { - that._handleFileTreeEntries( - entries, - path + entry.name + '/' - ).done(function (files) { - dfd.resolve(files); - }).fail(errorHandler); - }, errorHandler); + _handleFileTreeEntry: function (entry, path) { + var that = this, + dfd = $.Deferred(), + entries = [], + dirReader, + errorHandler = function (e) { + if (e && !e.entry) { + e.entry = entry; + } + // Since $.when returns immediately if one + // Deferred is rejected, we use resolve instead. + // This allows valid files and invalid items + // to be returned together in one set: + dfd.resolve([e]); + }, + successHandler = function (entries) { + that + ._handleFileTreeEntries(entries, path + entry.name + '/') + .done(function (files) { + dfd.resolve(files); + }) + .fail(errorHandler); + }, + readEntries = function () { + dirReader.readEntries(function (results) { + if (!results.length) { + successHandler(entries); } else { - errorHandler(); + entries = entries.concat(results); + readEntries(); } - return dfd.promise(); - }, + }, errorHandler); + }; + // eslint-disable-next-line no-param-reassign + path = path || ''; + if (entry.isFile) { + if (entry._file) { + // Workaround for Chrome bug #149735 + entry._file.relativePath = path; + dfd.resolve(entry._file); + } else { + entry.file(function (file) { + file.relativePath = path; + dfd.resolve(file); + }, errorHandler); + } + } else if (entry.isDirectory) { + dirReader = entry.createReader(); + readEntries(); + } else { + // Return an empty list for file system items + // other than files or directories: + dfd.resolve([]); + } + return dfd.promise(); + }, - _handleFileTreeEntries: function (entries, path) { - var that = this; - return $.when.apply( - $, - $.map(entries, function (entry) { - return that._handleFileTreeEntry(entry, path); - }) - ).pipe(function () { - return Array.prototype.concat.apply( - [], - arguments - ); - }); - }, + _handleFileTreeEntries: function (entries, path) { + var that = this; + return $.when + .apply( + $, + $.map(entries, function (entry) { + return that._handleFileTreeEntry(entry, path); + }) + ) + [this._promisePipe](function () { + return Array.prototype.concat.apply([], arguments); + }); + }, - _getDroppedFiles: function (dataTransfer) { - dataTransfer = dataTransfer || {}; - var items = dataTransfer.items; - if (items && items.length && (items[0].webkitGetAsEntry || - items[0].getAsEntry)) { - return this._handleFileTreeEntries( - $.map(items, function (item) { - if (item.webkitGetAsEntry) { - return item.webkitGetAsEntry(); - } - return item.getAsEntry(); - }) - ); + _getDroppedFiles: function (dataTransfer) { + // eslint-disable-next-line no-param-reassign + dataTransfer = dataTransfer || {}; + var items = dataTransfer.items; + if ( + items && + items.length && + (items[0].webkitGetAsEntry || items[0].getAsEntry) + ) { + return this._handleFileTreeEntries( + $.map(items, function (item) { + var entry; + if (item.webkitGetAsEntry) { + entry = item.webkitGetAsEntry(); + if (entry) { + // Workaround for Chrome bug #149735: + entry._file = item.getAsFile(); + } + return entry; } - return $.Deferred().resolve( - $.makeArray(dataTransfer.files) - ).promise(); - }, + return item.getAsEntry(); + }) + ); + } + return $.Deferred().resolve($.makeArray(dataTransfer.files)).promise(); + }, - _getFileInputFiles: function (fileInput) { - fileInput = $(fileInput); - var entries = fileInput.prop('webkitEntries') || - fileInput.prop('entries'), - files, - value; - if (entries && entries.length) { - return this._handleFileTreeEntries(entries); - } - files = $.makeArray(fileInput.prop('files')); - if (!files.length) { - value = fileInput.prop('value'); - if (!value) { - return $.Deferred().reject([]).promise(); - } - // If the files property is not available, the browser does not - // support the File API and we add a pseudo File object with - // the input value as name with path information removed: - files = [{name: value.replace(/^.*\\/, '')}]; - } - return $.Deferred().resolve(files).promise(); - }, + _getSingleFileInputFiles: function (fileInput) { + // eslint-disable-next-line no-param-reassign + fileInput = $(fileInput); + var entries = + fileInput.prop('webkitEntries') || fileInput.prop('entries'), + files, + value; + if (entries && entries.length) { + return this._handleFileTreeEntries(entries); + } + files = $.makeArray(fileInput.prop('files')); + if (!files.length) { + value = fileInput.prop('value'); + if (!value) { + return $.Deferred().resolve([]).promise(); + } + // If the files property is not available, the browser does not + // support the File API and we add a pseudo File object with + // the input value as name with path information removed: + files = [{ name: value.replace(/^.*\\/, '') }]; + } else if (files[0].name === undefined && files[0].fileName) { + // File normalization for Safari 4 and Firefox 3: + $.each(files, function (index, file) { + file.name = file.fileName; + file.size = file.fileSize; + }); + } + return $.Deferred().resolve(files).promise(); + }, - _onChange: function (e) { - var that = e.data.fileupload, - data = { - fileInput: $(e.target), - form: $(e.target.form) - }; - that._getFileInputFiles(data.fileInput).always(function (files) { - data.files = files; - if (that.options.replaceFileInput) { - that._replaceFileInput(data.fileInput); - } - if (that._trigger('change', e, data) !== false) { - that._onAdd(e, data); - } - }); - }, + _getFileInputFiles: function (fileInput) { + if (!(fileInput instanceof $) || fileInput.length === 1) { + return this._getSingleFileInputFiles(fileInput); + } + return $.when + .apply($, $.map(fileInput, this._getSingleFileInputFiles)) + [this._promisePipe](function () { + return Array.prototype.concat.apply([], arguments); + }); + }, - _onPaste: function (e) { - var that = e.data.fileupload, - cbd = e.originalEvent.clipboardData, - items = (cbd && cbd.items) || [], - data = {files: []}; - $.each(items, function (index, item) { - var file = item.getAsFile && item.getAsFile(); - if (file) { - data.files.push(file); - } - }); - if (that._trigger('paste', e, data) === false || - that._onAdd(e, data) === false) { - return false; - } - }, + _onChange: function (e) { + var that = this, + data = { + fileInput: $(e.target), + form: $(e.target.form) + }; + this._getFileInputFiles(data.fileInput).always(function (files) { + data.files = files; + if (that.options.replaceFileInput) { + that._replaceFileInput(data); + } + if ( + that._trigger( + 'change', + $.Event('change', { delegatedEvent: e }), + data + ) !== false + ) { + that._onAdd(e, data); + } + }); + }, - _onDrop: function (e) { - e.preventDefault(); - var that = e.data.fileupload, - dataTransfer = e.dataTransfer = e.originalEvent.dataTransfer, - data = {}; - that._getDroppedFiles(dataTransfer).always(function (files) { - data.files = files; - if (that._trigger('drop', e, data) !== false) { - that._onAdd(e, data); - } - }); - }, + _onPaste: function (e) { + var items = + e.originalEvent && + e.originalEvent.clipboardData && + e.originalEvent.clipboardData.items, + data = { files: [] }; + if (items && items.length) { + $.each(items, function (index, item) { + var file = item.getAsFile && item.getAsFile(); + if (file) { + data.files.push(file); + } + }); + if ( + this._trigger( + 'paste', + $.Event('paste', { delegatedEvent: e }), + data + ) !== false + ) { + this._onAdd(e, data); + } + } + }, - _onDragOver: function (e) { - var that = e.data.fileupload, - dataTransfer = e.dataTransfer = e.originalEvent.dataTransfer; - if (that._trigger('dragover', e) === false) { - return false; - } - if (dataTransfer) { - dataTransfer.dropEffect = 'copy'; - } - e.preventDefault(); - }, + _onDrop: function (e) { + e.dataTransfer = e.originalEvent && e.originalEvent.dataTransfer; + var that = this, + dataTransfer = e.dataTransfer, + data = {}; + if (dataTransfer && dataTransfer.files && dataTransfer.files.length) { + e.preventDefault(); + this._getDroppedFiles(dataTransfer).always(function (files) { + data.files = files; + if ( + that._trigger( + 'drop', + $.Event('drop', { delegatedEvent: e }), + data + ) !== false + ) { + that._onAdd(e, data); + } + }); + } + }, - _initEventHandlers: function () { - var ns = this.options.namespace; - if (this._isXHRUpload(this.options)) { - this.options.dropZone - .bind('dragover.' + ns, {fileupload: this}, this._onDragOver) - .bind('drop.' + ns, {fileupload: this}, this._onDrop) - .bind('paste.' + ns, {fileupload: this}, this._onPaste); - } - this.options.fileInput - .bind('change.' + ns, {fileupload: this}, this._onChange); - }, + _onDragOver: getDragHandler('dragover'), - _destroyEventHandlers: function () { - var ns = this.options.namespace; - this.options.dropZone - .unbind('dragover.' + ns, this._onDragOver) - .unbind('drop.' + ns, this._onDrop) - .unbind('paste.' + ns, this._onPaste); - this.options.fileInput - .unbind('change.' + ns, this._onChange); - }, + _onDragEnter: getDragHandler('dragenter'), - _setOption: function (key, value) { - var refresh = $.inArray(key, this._refreshOptionsList) !== -1; - if (refresh) { - this._destroyEventHandlers(); - } - $.Widget.prototype._setOption.call(this, key, value); - if (refresh) { - this._initSpecialOptions(); - this._initEventHandlers(); - } - }, + _onDragLeave: getDragHandler('dragleave'), - _initSpecialOptions: function () { - var options = this.options; - if (options.fileInput === undefined) { - options.fileInput = this.element.is('input[type="file"]') ? - this.element : this.element.find('input[type="file"]'); - } else if (!(options.fileInput instanceof $)) { - options.fileInput = $(options.fileInput); - } - if (!(options.dropZone instanceof $)) { - options.dropZone = $(options.dropZone); - } - }, + _initEventHandlers: function () { + if (this._isXHRUpload(this.options)) { + this._on(this.options.dropZone, { + dragover: this._onDragOver, + drop: this._onDrop, + // event.preventDefault() on dragenter is required for IE10+: + dragenter: this._onDragEnter, + // dragleave is not required, but added for completeness: + dragleave: this._onDragLeave + }); + this._on(this.options.pasteZone, { + paste: this._onPaste + }); + } + if ($.support.fileInput) { + this._on(this.options.fileInput, { + change: this._onChange + }); + } + }, - _create: function () { - var options = this.options; - // Initialize options set via HTML5 data-attributes: - $.extend(options, $(this.element[0].cloneNode(false)).data()); - options.namespace = options.namespace || this.widgetName; - this._initSpecialOptions(); - this._slots = []; - this._sequence = this._getXHRPromise(true); - this._sending = this._active = this._loaded = this._total = 0; - this._initEventHandlers(); - }, + _destroyEventHandlers: function () { + this._off(this.options.dropZone, 'dragenter dragleave dragover drop'); + this._off(this.options.pasteZone, 'paste'); + this._off(this.options.fileInput, 'change'); + }, - destroy: function () { - this._destroyEventHandlers(); - $.Widget.prototype.destroy.call(this); - }, + _destroy: function () { + this._destroyEventHandlers(); + }, - enable: function () { - var wasDisabled = false; - if (this.options.disabled) { - wasDisabled = true; - } - $.Widget.prototype.enable.call(this); - if (wasDisabled) { - this._initEventHandlers(); - } - }, + _setOption: function (key, value) { + var reinit = $.inArray(key, this._specialOptions) !== -1; + if (reinit) { + this._destroyEventHandlers(); + } + this._super(key, value); + if (reinit) { + this._initSpecialOptions(); + this._initEventHandlers(); + } + }, - disable: function () { - if (!this.options.disabled) { - this._destroyEventHandlers(); - } - $.Widget.prototype.disable.call(this); - }, + _initSpecialOptions: function () { + var options = this.options; + if (options.fileInput === undefined) { + options.fileInput = this.element.is('input[type="file"]') + ? this.element + : this.element.find('input[type="file"]'); + } else if (!(options.fileInput instanceof $)) { + options.fileInput = $(options.fileInput); + } + if (!(options.dropZone instanceof $)) { + options.dropZone = $(options.dropZone); + } + if (!(options.pasteZone instanceof $)) { + options.pasteZone = $(options.pasteZone); + } + }, - // This method is exposed to the widget API and allows adding files - // using the fileupload API. The data parameter accepts an object which - // must have a files property and can contain additional options: - // .fileupload('add', {files: filesList}); - add: function (data) { - var that = this; - if (!data || this.options.disabled) { - return; - } - if (data.fileInput && !data.files) { - this._getFileInputFiles(data.fileInput).always(function (files) { - data.files = files; - that._onAdd(null, data); - }); - } else { - data.files = $.makeArray(data.files); - this._onAdd(null, data); - } - }, + _getRegExp: function (str) { + var parts = str.split('/'), + modifiers = parts.pop(); + parts.shift(); + return new RegExp(parts.join('/'), modifiers); + }, - // This method is exposed to the widget API and allows sending files - // using the fileupload API. The data parameter accepts an object which - // must have a files or fileInput property and can contain additional options: - // .fileupload('send', {files: filesList}); - // The method returns a Promise object for the file upload call. - send: function (data) { - if (data && !this.options.disabled) { - if (data.fileInput && !data.files) { - var that = this, - dfd = $.Deferred(), - promise = dfd.promise(), - jqXHR, - aborted; - promise.abort = function () { - aborted = true; - if (jqXHR) { - return jqXHR.abort(); - } - dfd.reject(null, 'abort', 'abort'); - return promise; - }; - this._getFileInputFiles(data.fileInput).always( - function (files) { - if (aborted) { - return; - } - data.files = files; - jqXHR = that._onSend(null, data).then( - function (result, textStatus, jqXHR) { - dfd.resolve(result, textStatus, jqXHR); - }, - function (jqXHR, textStatus, errorThrown) { - dfd.reject(jqXHR, textStatus, errorThrown); - } - ); - } - ); - return this._enhancePromise(promise); - } - data.files = $.makeArray(data.files); - if (data.files.length) { - return this._onSend(null, data); - } - } - return this._getXHRPromise(false, data && data.context); + _isRegExpOption: function (key, value) { + return ( + key !== 'url' && + $.type(value) === 'string' && + /^\/.*\/[igm]{0,3}$/.test(value) + ); + }, + + _initDataAttributes: function () { + var that = this, + options = this.options, + data = this.element.data(); + // Initialize options set via HTML5 data-attributes: + $.each(this.element[0].attributes, function (index, attr) { + var key = attr.name.toLowerCase(), + value; + if (/^data-/.test(key)) { + // Convert hyphen-ated key to camelCase: + key = key.slice(5).replace(/-[a-z]/g, function (str) { + return str.charAt(1).toUpperCase(); + }); + value = data[key]; + if (that._isRegExpOption(key, value)) { + value = that._getRegExp(value); + } + options[key] = value; } + }); + }, + + _create: function () { + this._initDataAttributes(); + this._initSpecialOptions(); + this._slots = []; + this._sequence = this._getXHRPromise(true); + this._sending = this._active = 0; + this._initProgressObject(this); + this._initEventHandlers(); + }, + + // This method is exposed to the widget API and allows to query + // the number of active uploads: + active: function () { + return this._active; + }, - }); + // This method is exposed to the widget API and allows to query + // the widget upload progress. + // It returns an object with loaded, total and bitrate properties + // for the running uploads: + progress: function () { + return this._progress; + }, -})); + // This method is exposed to the widget API and allows adding files + // using the fileupload API. The data parameter accepts an object which + // must have a files property and can contain additional options: + // .fileupload('add', {files: filesList}); + add: function (data) { + var that = this; + if (!data || this.options.disabled) { + return; + } + if (data.fileInput && !data.files) { + this._getFileInputFiles(data.fileInput).always(function (files) { + data.files = files; + that._onAdd(null, data); + }); + } else { + data.files = $.makeArray(data.files); + this._onAdd(null, data); + } + }, + + // This method is exposed to the widget API and allows sending files + // using the fileupload API. The data parameter accepts an object which + // must have a files or fileInput property and can contain additional options: + // .fileupload('send', {files: filesList}); + // The method returns a Promise object for the file upload call. + send: function (data) { + if (data && !this.options.disabled) { + if (data.fileInput && !data.files) { + var that = this, + dfd = $.Deferred(), + promise = dfd.promise(), + jqXHR, + aborted; + promise.abort = function () { + aborted = true; + if (jqXHR) { + return jqXHR.abort(); + } + dfd.reject(null, 'abort', 'abort'); + return promise; + }; + this._getFileInputFiles(data.fileInput).always(function (files) { + if (aborted) { + return; + } + if (!files.length) { + dfd.reject(); + return; + } + data.files = files; + jqXHR = that._onSend(null, data); + jqXHR.then( + function (result, textStatus, jqXHR) { + dfd.resolve(result, textStatus, jqXHR); + }, + function (jqXHR, textStatus, errorThrown) { + dfd.reject(jqXHR, textStatus, errorThrown); + } + ); + }); + return this._enhancePromise(promise); + } + data.files = $.makeArray(data.files); + if (data.files.length) { + return this._onSend(null, data); + } + } + return this._getXHRPromise(false, data && data.context); + } + }); +}); diff --git a/lib/web/jquery/fileUploader/jquery.fileuploader.js b/lib/web/jquery/fileUploader/jquery.fileuploader.js new file mode 100644 index 0000000000000..4ec869ab4f470 --- /dev/null +++ b/lib/web/jquery/fileUploader/jquery.fileuploader.js @@ -0,0 +1,33 @@ +/** + * Custom Uploader + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +/* global define, require */ + +(function (factory) { + 'use strict'; + if (typeof define === 'function' && define.amd) { + // Register as an anonymous AMD module: + define([ + 'jquery', + 'jquery/fileUploader/jquery.fileupload-image', + 'jquery/fileUploader/jquery.fileupload-audio', + 'jquery/fileUploader/jquery.fileupload-video', + 'jquery/fileUploader/jquery.iframe-transport', + ], factory); + } else if (typeof exports === 'object') { + // Node/CommonJS: + factory( + require('jquery'), + require('jquery/fileUploader/jquery.fileupload-image'), + require('jquery/fileUploader/jquery.fileupload-audio'), + require('jquery/fileUploader/jquery.fileupload-video'), + require('jquery/fileUploader/jquery.iframe-transport') + ); + } else { + // Browser globals: + factory(window.jQuery); + } +})(); diff --git a/lib/web/jquery/fileUploader/jquery.iframe-transport.js b/lib/web/jquery/fileUploader/jquery.iframe-transport.js index 4749f46993652..3e3b9a93b05df 100644 --- a/lib/web/jquery/fileUploader/jquery.iframe-transport.js +++ b/lib/web/jquery/fileUploader/jquery.iframe-transport.js @@ -1,172 +1,227 @@ /* - * jQuery Iframe Transport Plugin 1.5 + * jQuery Iframe Transport Plugin * https://github.com/blueimp/jQuery-File-Upload * * Copyright 2011, Sebastian Tschan * https://blueimp.net * * Licensed under the MIT license: - * http://www.opensource.org/licenses/MIT + * https://opensource.org/licenses/MIT */ -/*jslint unparam: true, nomen: true */ -/*global define, window, document */ +/* global define, require */ (function (factory) { - 'use strict'; - if (typeof define === 'function' && define.amd) { - // Register as an anonymous AMD module: - define(['jquery'], factory); - } else { - // Browser globals: - factory(window.jQuery); - } -}(function ($) { - 'use strict'; + 'use strict'; + if (typeof define === 'function' && define.amd) { + // Register as an anonymous AMD module: + define(['jquery'], factory); + } else if (typeof exports === 'object') { + // Node/CommonJS: + factory(require('jquery')); + } else { + // Browser globals: + factory(window.jQuery); + } +})(function ($) { + 'use strict'; - // Helper variable to create unique names for the transport iframes: - var counter = 0; + // Helper variable to create unique names for the transport iframes: + var counter = 0, + jsonAPI = $, + jsonParse = 'parseJSON'; - // The iframe transport accepts three additional options: - // options.fileInput: a jQuery collection of file input fields - // options.paramName: the parameter name for the file form data, - // overrides the name property of the file input field(s), - // can be a string or an array of strings. - // options.formData: an array of objects with name and value properties, - // equivalent to the return data of .serializeArray(), e.g.: - // [{name: 'a', value: 1}, {name: 'b', value: 2}] - $.ajaxTransport('iframe', function (options) { - if (options.async && (options.type === 'POST' || options.type === 'GET')) { - var form, - iframe; - return { - send: function (_, completeCallback) { - form = $('
'); - form.attr('accept-charset', options.formAcceptCharset); - // javascript:false as initial iframe src - // prevents warning popups on HTTPS in IE6. - // IE versions below IE8 cannot set the name property of - // elements that have already been added to the DOM, - // so we set the name along with the iframe HTML markup: - iframe = $( - '' - ).bind('load', function () { - var fileInputClones, - paramNames = $.isArray(options.paramName) ? - options.paramName : [options.paramName]; - iframe - .unbind('load') - .bind('load', function () { - var response; - // Wrap in a try/catch block to catch exceptions thrown - // when trying to access cross-domain iframe contents: - try { - response = iframe.contents(); - // Google Chrome and Firefox do not throw an - // exception when calling iframe.contents() on - // cross-domain requests, so we unify the response: - if (!response.length || !response[0].firstChild) { - throw new Error(); - } - } catch (e) { - response = undefined; - } - // The complete callback returns the - // iframe content document as response object: - completeCallback( - 200, - 'success', - {'iframe': response} - ); - // Fix for IE endless progress bar activity bug - // (happens on form submits to iframe targets): - $('') - .appendTo(form); - form.remove(); - }); - form - .prop('target', iframe.prop('name')) - .prop('action', options.url) - .prop('method', options.type); - if (options.formData) { - $.each(options.formData, function (index, field) { - $('') - .prop('name', field.name) - .val(field.value) - .appendTo(form); - }); - } - if (options.fileInput && options.fileInput.length && - options.type === 'POST') { - fileInputClones = options.fileInput.clone(); - // Insert a clone for each file input field: - options.fileInput.after(function (index) { - return fileInputClones[index]; - }); - if (options.paramName) { - options.fileInput.each(function (index) { - $(this).prop( - 'name', - paramNames[index] || options.paramName - ); - }); - } - // Appending the file input fields to the hidden form - // removes them from their original location: - form - .append(options.fileInput) - .prop('enctype', 'multipart/form-data') - // enctype must be set as encoding for IE: - .prop('encoding', 'multipart/form-data'); - } - form.submit(); - // Insert the file input fields at their original location - // by replacing the clones with the originals: - if (fileInputClones && fileInputClones.length) { - options.fileInput.each(function (index, input) { - var clone = $(fileInputClones[index]); - $(input).prop('name', clone.prop('name')); - clone.replaceWith(input); - }); - } - }); - form.append(iframe).appendTo(document.body); - }, - abort: function () { - if (iframe) { - // javascript:false as iframe src aborts the request - // and prevents warning popups on HTTPS in IE6. - // concat is used to avoid the "Script URL" JSLint error: - iframe - .unbind('load') - .prop('src', 'javascript'.concat(':false;')); - } - if (form) { - form.remove(); - } - } - }; - } - }); + if ('JSON' in window && 'parse' in JSON) { + jsonAPI = JSON; + jsonParse = 'parse'; + } - // The iframe transport returns the iframe content document as response. - // The following adds converters from iframe to text, json, html, and script: - $.ajaxSetup({ - converters: { - 'iframe text': function (iframe) { - return $(iframe[0].body).text(); - }, - 'iframe json': function (iframe) { - return $.parseJSON($(iframe[0].body).text()); - }, - 'iframe html': function (iframe) { - return $(iframe[0].body).html(); - }, - 'iframe script': function (iframe) { - return $.globalEval($(iframe[0].body).text()); + // The iframe transport accepts four additional options: + // options.fileInput: a jQuery collection of file input fields + // options.paramName: the parameter name for the file form data, + // overrides the name property of the file input field(s), + // can be a string or an array of strings. + // options.formData: an array of objects with name and value properties, + // equivalent to the return data of .serializeArray(), e.g.: + // [{name: 'a', value: 1}, {name: 'b', value: 2}] + // options.initialIframeSrc: the URL of the initial iframe src, + // by default set to "javascript:false;" + $.ajaxTransport('iframe', function (options) { + if (options.async) { + // javascript:false as initial iframe src + // prevents warning popups on HTTPS in IE6: + // eslint-disable-next-line no-script-url + var initialIframeSrc = options.initialIframeSrc || 'javascript:false;', + form, + iframe, + addParamChar; + return { + send: function (_, completeCallback) { + form = $('
'); + form.attr('accept-charset', options.formAcceptCharset); + addParamChar = /\?/.test(options.url) ? '&' : '?'; + // XDomainRequest only supports GET and POST: + if (options.type === 'DELETE') { + options.url = options.url + addParamChar + '_method=DELETE'; + options.type = 'POST'; + } else if (options.type === 'PUT') { + options.url = options.url + addParamChar + '_method=PUT'; + options.type = 'POST'; + } else if (options.type === 'PATCH') { + options.url = options.url + addParamChar + '_method=PATCH'; + options.type = 'POST'; + } + // IE versions below IE8 cannot set the name property of + // elements that have already been added to the DOM, + // so we set the name along with the iframe HTML markup: + counter += 1; + iframe = $( + '' + ).on('load', function () { + var fileInputClones, + paramNames = $.isArray(options.paramName) + ? options.paramName + : [options.paramName]; + iframe.off('load').on('load', function () { + var response; + // Wrap in a try/catch block to catch exceptions thrown + // when trying to access cross-domain iframe contents: + try { + response = iframe.contents(); + // Google Chrome and Firefox do not throw an + // exception when calling iframe.contents() on + // cross-domain requests, so we unify the response: + if (!response.length || !response[0].firstChild) { + throw new Error(); + } + } catch (e) { + response = undefined; + } + // The complete callback returns the + // iframe content document as response object: + completeCallback(200, 'success', { iframe: response }); + // Fix for IE endless progress bar activity bug + // (happens on form submits to iframe targets): + $('').appendTo( + form + ); + window.setTimeout(function () { + // Removing the form in a setTimeout call + // allows Chrome's developer tools to display + // the response result + form.remove(); + }, 0); + }); + form + .prop('target', iframe.prop('name')) + .prop('action', options.url) + .prop('method', options.type); + if (options.formData) { + $.each(options.formData, function (index, field) { + $('') + .prop('name', field.name) + .val(field.value) + .appendTo(form); + }); } + if ( + options.fileInput && + options.fileInput.length && + options.type === 'POST' + ) { + fileInputClones = options.fileInput.clone(); + // Insert a clone for each file input field: + options.fileInput.after(function (index) { + return fileInputClones[index]; + }); + if (options.paramName) { + options.fileInput.each(function (index) { + $(this).prop('name', paramNames[index] || options.paramName); + }); + } + // Appending the file input fields to the hidden form + // removes them from their original location: + form + .append(options.fileInput) + .prop('enctype', 'multipart/form-data') + // enctype must be set as encoding for IE: + .prop('encoding', 'multipart/form-data'); + // Remove the HTML5 form attribute from the input(s): + options.fileInput.removeAttr('form'); + } + window.setTimeout(function () { + // Submitting the form in a setTimeout call fixes an issue with + // Safari 13 not triggering the iframe load event after resetting + // the load event handler, see also: + // https://github.com/blueimp/jQuery-File-Upload/issues/3633 + form.submit(); + // Insert the file input fields at their original location + // by replacing the clones with the originals: + if (fileInputClones && fileInputClones.length) { + options.fileInput.each(function (index, input) { + var clone = $(fileInputClones[index]); + // Restore the original name and form properties: + $(input) + .prop('name', clone.prop('name')) + .attr('form', clone.attr('form')); + clone.replaceWith(input); + }); + } + }, 0); + }); + form.append(iframe).appendTo(document.body); + }, + abort: function () { + if (iframe) { + // javascript:false as iframe src aborts the request + // and prevents warning popups on HTTPS in IE6. + iframe.off('load').prop('src', initialIframeSrc); + } + if (form) { + form.remove(); + } } - }); + }; + } + }); -})); + // The iframe transport returns the iframe content document as response. + // The following adds converters from iframe to text, json, html, xml + // and script. + // Please note that the Content-Type for JSON responses has to be text/plain + // or text/html, if the browser doesn't include application/json in the + // Accept header, else IE will show a download dialog. + // The Content-Type for XML responses on the other hand has to be always + // application/xml or text/xml, so IE properly parses the XML response. + // See also + // https://github.com/blueimp/jQuery-File-Upload/wiki/Setup#content-type-negotiation + $.ajaxSetup({ + converters: { + 'iframe text': function (iframe) { + return iframe && $(iframe[0].body).text(); + }, + 'iframe json': function (iframe) { + return iframe && jsonAPI[jsonParse]($(iframe[0].body).text()); + }, + 'iframe html': function (iframe) { + return iframe && $(iframe[0].body).html(); + }, + 'iframe xml': function (iframe) { + var xmlDoc = iframe && iframe[0]; + return xmlDoc && $.isXMLDoc(xmlDoc) + ? xmlDoc + : $.parseXML( + (xmlDoc.XMLDocument && xmlDoc.XMLDocument.xml) || + $(xmlDoc.body).html() + ); + }, + 'iframe script': function (iframe) { + return iframe && $.globalEval($(iframe[0].body).text()); + } + } + }); +}); diff --git a/lib/web/jquery/fileUploader/load-image.js b/lib/web/jquery/fileUploader/load-image.js deleted file mode 100644 index f6817db48c045..0000000000000 --- a/lib/web/jquery/fileUploader/load-image.js +++ /dev/null @@ -1 +0,0 @@ -(function(a){"use strict";var b=function(a,c,d){var e=document.createElement("img"),f,g;return e.onerror=c,e.onload=function(){g&&(!d||!d.noRevoke)&&b.revokeObjectURL(g),c(b.scale(e,d))},window.Blob&&a instanceof Blob||window.File&&a instanceof File?f=g=b.createObjectURL(a):f=a,f?(e.src=f,e):b.readFile(a,function(a){e.src=a})},c=window.createObjectURL&&window||window.URL&&URL.revokeObjectURL&&URL||window.webkitURL&&webkitURL;b.scale=function(a,b){b=b||{};var c=document.createElement("canvas"),d=a.width,e=a.height,f=Math.max((b.minWidth||d)/d,(b.minHeight||e)/e);return f>1&&(d=parseInt(d*f,10),e=parseInt(e*f,10)),f=Math.min((b.maxWidth||d)/d,(b.maxHeight||e)/e),f<1&&(d=parseInt(d*f,10),e=parseInt(e*f,10)),a.getContext||b.canvas&&c.getContext?(c.width=d,c.height=e,c.getContext("2d").drawImage(a,0,0,d,e),c):(a.width=d,a.height=e,a)},b.createObjectURL=function(a){return c?c.createObjectURL(a):!1},b.revokeObjectURL=function(a){return c?c.revokeObjectURL(a):!1},b.readFile=function(a,b){if(window.FileReader&&FileReader.prototype.readAsDataURL){var c=new FileReader;return c.onload=function(a){b(a.target.result)},c.readAsDataURL(a),c}return!1},typeof define=="function"&&define.amd?define(function(){return b}):a.loadImage=b})(this); \ No newline at end of file diff --git a/lib/web/jquery/fileUploader/locale.js b/lib/web/jquery/fileUploader/locale.js deleted file mode 100644 index ea64b0a870ff7..0000000000000 --- a/lib/web/jquery/fileUploader/locale.js +++ /dev/null @@ -1,29 +0,0 @@ -/* - * jQuery File Upload Plugin Localization Example 6.5.1 - * https://github.com/blueimp/jQuery-File-Upload - * - * Copyright 2012, Sebastian Tschan - * https://blueimp.net - * - * Licensed under the MIT license: - * http://www.opensource.org/licenses/MIT - */ - -/*global window */ - -window.locale = { - "fileupload": { - "errors": { - "maxFileSize": "File is too big", - "minFileSize": "File is too small", - "acceptFileTypes": "Filetype not allowed", - "maxNumberOfFiles": "Max number of files exceeded", - "uploadedBytes": "Uploaded bytes exceed file size", - "emptyResult": "Empty file upload result" - }, - "error": "Error", - "start": "Start", - "cancel": "Cancel", - "destroy": "Delete" - } -}; diff --git a/lib/web/jquery/fileUploader/main.js b/lib/web/jquery/fileUploader/main.js deleted file mode 100644 index 7231899276c4b..0000000000000 --- a/lib/web/jquery/fileUploader/main.js +++ /dev/null @@ -1,78 +0,0 @@ -/* - * jQuery File Upload Plugin JS Example 6.7 - * https://github.com/blueimp/jQuery-File-Upload - * - * Copyright 2010, Sebastian Tschan - * https://blueimp.net - * - * Licensed under the MIT license: - * http://www.opensource.org/licenses/MIT - */ - -/*jslint nomen: true, unparam: true, regexp: true */ -/*global $, window, document */ - -$(function () { - 'use strict'; - - // Initialize the jQuery File Upload widget: - $('#fileupload').fileupload(); - - // Enable iframe cross-domain access via redirect option: - $('#fileupload').fileupload( - 'option', - 'redirect', - window.location.href.replace( - /\/[^\/]*$/, - '/cors/result.html?%s' - ) - ); - - if (window.location.hostname === 'blueimp.github.com') { - // Demo settings: - $('#fileupload').fileupload('option', { - url: '//jquery-file-upload.appspot.com/', - maxFileSize: 5000000, - acceptFileTypes: /(\.|\/)(gif|jpe?g|png)$/i, - process: [ - { - action: 'load', - fileTypes: /^image\/(gif|jpeg|png)$/, - maxFileSize: 20000000 // 20MB - }, - { - action: 'resize', - maxWidth: 1440, - maxHeight: 900 - }, - { - action: 'save' - } - ] - }); - // Upload server status check for browsers with CORS support: - if ($.support.cors) { - $.ajax({ - url: '//jquery-file-upload.appspot.com/', - type: 'HEAD' - }).fail(function () { - $('') - .text($.mage.__('Upload server currently unavailable - ') + - new Date()) - .appendTo('#fileupload'); - }); - } - } else { - // Load existing files: - $('#fileupload').each(function () { - var that = this; - $.getJSON(this.action, function (result) { - if (result && result.length) { - $(that).fileupload('option', 'done') - .call(that, null, {result: result}); - } - }); - }); - } - -}); diff --git a/lib/web/jquery/fileUploader/vendor/blueimp-canvas-to-blob/LICENSE.txt b/lib/web/jquery/fileUploader/vendor/blueimp-canvas-to-blob/LICENSE.txt new file mode 100644 index 0000000000000..e1ad73662d4a5 --- /dev/null +++ b/lib/web/jquery/fileUploader/vendor/blueimp-canvas-to-blob/LICENSE.txt @@ -0,0 +1,20 @@ +MIT License + +Copyright © 2012 Sebastian Tschan, https://blueimp.net + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/lib/web/jquery/fileUploader/vendor/blueimp-canvas-to-blob/README.md b/lib/web/jquery/fileUploader/vendor/blueimp-canvas-to-blob/README.md new file mode 100644 index 0000000000000..92e16c63ce7cd --- /dev/null +++ b/lib/web/jquery/fileUploader/vendor/blueimp-canvas-to-blob/README.md @@ -0,0 +1,135 @@ +# JavaScript Canvas to Blob + +## Contents + +- [Description](#description) +- [Setup](#setup) +- [Usage](#usage) +- [Requirements](#requirements) +- [Browsers](#browsers) +- [API](#api) +- [Test](#test) +- [License](#license) + +## Description + +Canvas to Blob is a +[polyfill](https://developer.mozilla.org/en-US/docs/Glossary/Polyfill) for +Browsers that don't support the standard JavaScript +[HTMLCanvasElement.toBlob](https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob) +method. + +It can be used to create +[Blob](https://developer.mozilla.org/en-US/docs/Web/API/Blob) objects from an +HTML [canvas](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/canvas) +element. + +## Setup + +Install via [NPM](https://www.npmjs.com/package/blueimp-canvas-to-blob): + +```sh +npm install blueimp-canvas-to-blob +``` + +This will install the JavaScript files inside +`./node_modules/blueimp-canvas-to-blob/js/` relative to your current directory, +from where you can copy them into a folder that is served by your web server. + +Next include the minified JavaScript Canvas to Blob script in your HTML markup: + +```html + +``` + +Or alternatively, include the non-minified version: + +```html + +``` + +## Usage + +You can use the `canvas.toBlob()` method in the same way as the native +implementation: + +```js +var canvas = document.createElement('canvas') +// Edit the canvas ... +if (canvas.toBlob) { + canvas.toBlob(function (blob) { + // Do something with the blob object, + // e.g. create multipart form data for file uploads: + var formData = new FormData() + formData.append('file', blob, 'image.jpg') + // ... + }, 'image/jpeg') +} +``` + +## Requirements + +The JavaScript Canvas to Blob function has zero dependencies. + +However, it is a very suitable complement to the +[JavaScript Load Image](https://github.com/blueimp/JavaScript-Load-Image) +function. + +## Browsers + +The following browsers have native support for +[HTMLCanvasElement.toBlob](https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob): + +- Chrome 50+ +- Firefox 19+ +- Safari 11+ +- Mobile Chrome 50+ (Android) +- Mobile Firefox 4+ (Android) +- Mobile Safari 11+ (iOS) +- Edge 79+ + +Browsers which implement the following APIs support `canvas.toBlob()` via +polyfill: + +- [HTMLCanvasElement](https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement) +- [HTMLCanvasElement.toDataURL](https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toDataURL) +- [Blob() constructor](https://developer.mozilla.org/en-US/docs/Web/API/Blob/Blob) +- [atob](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/atob) +- [ArrayBuffer](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer) +- [Uint8Array](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array) + +This includes the following browsers: + +- Chrome 20+ +- Firefox 13+ +- Safari 8+ +- Mobile Chrome 25+ (Android) +- Mobile Firefox 14+ (Android) +- Mobile Safari 8+ (iOS) +- Edge 74+ +- Edge Legacy 12+ +- Internet Explorer 10+ + +## API + +In addition to the `canvas.toBlob()` polyfill, the JavaScript Canvas to Blob +script exposes its helper function `dataURLtoBlob(url)`: + +```js +// Uncomment the following line when using a module loader like webpack: +// var dataURLtoBlob = require('blueimp-canvas-to-blob') + +// black+white 3x2 GIF, base64 data: +var b64 = 'R0lGODdhAwACAPEAAAAAAP///yZFySZFySH5BAEAAAIALAAAAAADAAIAAAIDRAJZADs=' +var url = 'data:image/gif;base64,' + b64 +var blob = dataURLtoBlob(url) +``` + +## Test + +[Unit tests](https://blueimp.github.io/JavaScript-Canvas-to-Blob/test/) + +## License + +The JavaScript Canvas to Blob script is released under the +[MIT license](https://opensource.org/licenses/MIT). diff --git a/lib/web/jquery/fileUploader/vendor/blueimp-canvas-to-blob/js/canvas-to-blob.js b/lib/web/jquery/fileUploader/vendor/blueimp-canvas-to-blob/js/canvas-to-blob.js new file mode 100644 index 0000000000000..8cd717bc1205f --- /dev/null +++ b/lib/web/jquery/fileUploader/vendor/blueimp-canvas-to-blob/js/canvas-to-blob.js @@ -0,0 +1,143 @@ +/* + * JavaScript Canvas to Blob + * https://github.com/blueimp/JavaScript-Canvas-to-Blob + * + * Copyright 2012, Sebastian Tschan + * https://blueimp.net + * + * Licensed under the MIT license: + * https://opensource.org/licenses/MIT + * + * Based on stackoverflow user Stoive's code snippet: + * http://stackoverflow.com/q/4998908 + */ + +/* global define, Uint8Array, ArrayBuffer, module */ + +;(function (window) { + 'use strict' + + var CanvasPrototype = + window.HTMLCanvasElement && window.HTMLCanvasElement.prototype + var hasBlobConstructor = + window.Blob && + (function () { + try { + return Boolean(new Blob()) + } catch (e) { + return false + } + })() + var hasArrayBufferViewSupport = + hasBlobConstructor && + window.Uint8Array && + (function () { + try { + return new Blob([new Uint8Array(100)]).size === 100 + } catch (e) { + return false + } + })() + var BlobBuilder = + window.BlobBuilder || + window.WebKitBlobBuilder || + window.MozBlobBuilder || + window.MSBlobBuilder + var dataURIPattern = /^data:((.*?)(;charset=.*?)?)(;base64)?,/ + var dataURLtoBlob = + (hasBlobConstructor || BlobBuilder) && + window.atob && + window.ArrayBuffer && + window.Uint8Array && + function (dataURI) { + var matches, + mediaType, + isBase64, + dataString, + byteString, + arrayBuffer, + intArray, + i, + bb + // Parse the dataURI components as per RFC 2397 + matches = dataURI.match(dataURIPattern) + if (!matches) { + throw new Error('invalid data URI') + } + // Default to text/plain;charset=US-ASCII + mediaType = matches[2] + ? matches[1] + : 'text/plain' + (matches[3] || ';charset=US-ASCII') + isBase64 = !!matches[4] + dataString = dataURI.slice(matches[0].length) + if (isBase64) { + // Convert base64 to raw binary data held in a string: + byteString = atob(dataString) + } else { + // Convert base64/URLEncoded data component to raw binary: + byteString = decodeURIComponent(dataString) + } + // Write the bytes of the string to an ArrayBuffer: + arrayBuffer = new ArrayBuffer(byteString.length) + intArray = new Uint8Array(arrayBuffer) + for (i = 0; i < byteString.length; i += 1) { + intArray[i] = byteString.charCodeAt(i) + } + // Write the ArrayBuffer (or ArrayBufferView) to a blob: + if (hasBlobConstructor) { + return new Blob([hasArrayBufferViewSupport ? intArray : arrayBuffer], { + type: mediaType + }) + } + bb = new BlobBuilder() + bb.append(arrayBuffer) + return bb.getBlob(mediaType) + } + if (window.HTMLCanvasElement && !CanvasPrototype.toBlob) { + if (CanvasPrototype.mozGetAsFile) { + CanvasPrototype.toBlob = function (callback, type, quality) { + var self = this + setTimeout(function () { + if (quality && CanvasPrototype.toDataURL && dataURLtoBlob) { + callback(dataURLtoBlob(self.toDataURL(type, quality))) + } else { + callback(self.mozGetAsFile('blob', type)) + } + }) + } + } else if (CanvasPrototype.toDataURL && dataURLtoBlob) { + if (CanvasPrototype.msToBlob) { + CanvasPrototype.toBlob = function (callback, type, quality) { + var self = this + setTimeout(function () { + if ( + ((type && type !== 'image/png') || quality) && + CanvasPrototype.toDataURL && + dataURLtoBlob + ) { + callback(dataURLtoBlob(self.toDataURL(type, quality))) + } else { + callback(self.msToBlob(type)) + } + }) + } + } else { + CanvasPrototype.toBlob = function (callback, type, quality) { + var self = this + setTimeout(function () { + callback(dataURLtoBlob(self.toDataURL(type, quality))) + }) + } + } + } + } + if (typeof define === 'function' && define.amd) { + define(function () { + return dataURLtoBlob + }) + } else if (typeof module === 'object' && module.exports) { + module.exports = dataURLtoBlob + } else { + window.dataURLtoBlob = dataURLtoBlob + } +})(window) diff --git a/lib/web/jquery/fileUploader/vendor/blueimp-load-image/LICENSE.txt b/lib/web/jquery/fileUploader/vendor/blueimp-load-image/LICENSE.txt new file mode 100644 index 0000000000000..d6a9d74758be3 --- /dev/null +++ b/lib/web/jquery/fileUploader/vendor/blueimp-load-image/LICENSE.txt @@ -0,0 +1,20 @@ +MIT License + +Copyright © 2011 Sebastian Tschan, https://blueimp.net + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/lib/web/jquery/fileUploader/vendor/blueimp-load-image/README.md b/lib/web/jquery/fileUploader/vendor/blueimp-load-image/README.md new file mode 100644 index 0000000000000..5759a126aa172 --- /dev/null +++ b/lib/web/jquery/fileUploader/vendor/blueimp-load-image/README.md @@ -0,0 +1,1070 @@ +# JavaScript Load Image + +> A JavaScript library to load and transform image files. + +## Contents + +- [Demo](https://blueimp.github.io/JavaScript-Load-Image/) +- [Description](#description) +- [Setup](#setup) +- [Usage](#usage) + - [Image loading](#image-loading) + - [Image scaling](#image-scaling) +- [Requirements](#requirements) +- [Browser support](#browser-support) +- [API](#api) + - [Callback](#callback) + - [Function signature](#function-signature) + - [Cancel image loading](#cancel-image-loading) + - [Callback arguments](#callback-arguments) + - [Error handling](#error-handling) + - [Promise](#promise) +- [Options](#options) + - [maxWidth](#maxwidth) + - [maxHeight](#maxheight) + - [minWidth](#minwidth) + - [minHeight](#minheight) + - [sourceWidth](#sourcewidth) + - [sourceHeight](#sourceheight) + - [top](#top) + - [right](#right) + - [bottom](#bottom) + - [left](#left) + - [contain](#contain) + - [cover](#cover) + - [aspectRatio](#aspectratio) + - [pixelRatio](#pixelratio) + - [downsamplingRatio](#downsamplingratio) + - [imageSmoothingEnabled](#imagesmoothingenabled) + - [imageSmoothingQuality](#imagesmoothingquality) + - [crop](#crop) + - [orientation](#orientation) + - [meta](#meta) + - [canvas](#canvas) + - [crossOrigin](#crossorigin) + - [noRevoke](#norevoke) +- [Metadata parsing](#metadata-parsing) + - [Image head](#image-head) + - [Exif parser](#exif-parser) + - [Exif Thumbnail](#exif-thumbnail) + - [Exif IFD](#exif-ifd) + - [GPSInfo IFD](#gpsinfo-ifd) + - [Interoperability IFD](#interoperability-ifd) + - [Exif parser options](#exif-parser-options) + - [Exif writer](#exif-writer) + - [IPTC parser](#iptc-parser) + - [IPTC parser options](#iptc-parser-options) +- [License](#license) +- [Credits](#credits) + +## Description + +JavaScript Load Image is a library to load images provided as `File` or `Blob` +objects or via `URL`. It returns an optionally **scaled**, **cropped** or +**rotated** HTML `img` or `canvas` element. + +It also provides methods to parse image metadata to extract +[IPTC](https://iptc.org/standards/photo-metadata/) and +[Exif](https://en.wikipedia.org/wiki/Exif) tags as well as embedded thumbnail +images, to overwrite the Exif Orientation value and to restore the complete +image header after resizing. + +## Setup + +Install via [NPM](https://www.npmjs.com/package/blueimp-load-image): + +```sh +npm install blueimp-load-image +``` + +This will install the JavaScript files inside +`./node_modules/blueimp-load-image/js/` relative to your current directory, from +where you can copy them into a folder that is served by your web server. + +Next include the combined and minified JavaScript Load Image script in your HTML +markup: + +```html + +``` + +Or alternatively, choose which components you want to include: + +```html + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +## Usage + +### Image loading + +In your application code, use the `loadImage()` function with +[callback](#callback) style: + +```js +document.getElementById('file-input').onchange = function () { + loadImage( + this.files[0], + function (img) { + document.body.appendChild(img) + }, + { maxWidth: 600 } // Options + ) +} +``` + +Or use the [Promise](#promise) based API like this ([requires](#requirements) a +polyfill for older browsers): + +```js +document.getElementById('file-input').onchange = function () { + loadImage(this.files[0], { maxWidth: 600 }).then(function (data) { + document.body.appendChild(data.image) + }) +} +``` + +With +[async/await](https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Asynchronous/Async_await) +(requires a modern browser or a code transpiler like +[Babel](https://babeljs.io/) or [TypeScript](https://www.typescriptlang.org/)): + +```js +document.getElementById('file-input').onchange = async function () { + let data = await loadImage(this.files[0], { maxWidth: 600 }) + document.body.appendChild(data.image) +} +``` + +### Image scaling + +It is also possible to use the image scaling functionality directly with an +existing image: + +```js +var scaledImage = loadImage.scale( + img, // img or canvas element + { maxWidth: 600 } +) +``` + +## Requirements + +The JavaScript Load Image library has zero dependencies, but benefits from the +following two +[polyfills](https://developer.mozilla.org/en-US/docs/Glossary/Polyfill): + +- [blueimp-canvas-to-blob](https://github.com/blueimp/JavaScript-Canvas-to-Blob) + for browsers without native + [HTMLCanvasElement.toBlob](https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob) + support, to create `Blob` objects out of `canvas` elements. +- [promise-polyfill](https://github.com/taylorhakes/promise-polyfill) to be able + to use the + [Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) + based `loadImage` API in Browsers without native `Promise` support. + +## Browser support + +Browsers which implement the following APIs support all options: + +- Loading images from File and Blob objects: + - [URL.createObjectURL](https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL) + or + [FileReader.readAsDataURL](https://developer.mozilla.org/en-US/docs/Web/API/FileReader/readAsDataURL) +- Parsing meta data: + - [FileReader.readAsArrayBuffer](https://developer.mozilla.org/en-US/docs/Web/API/FileReader/readAsArrayBuffer) + - [Blob.slice](https://developer.mozilla.org/en-US/docs/Web/API/Blob/slice) + - [DataView](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DataView) + (no [BigInt](https://developer.mozilla.org/en-US/docs/Glossary/BigInt) + support required) +- Parsing meta data from images loaded via URL: + - [fetch Response.blob](https://developer.mozilla.org/en-US/docs/Web/API/Body/blob) + or + [XMLHttpRequest.responseType blob](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/responseType#blob) +- Promise based API: + - [Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) + +This includes (but is not limited to) the following browsers: + +- Chrome 32+ +- Firefox 29+ +- Safari 8+ +- Mobile Chrome 42+ (Android) +- Mobile Firefox 50+ (Android) +- Mobile Safari 8+ (iOS) +- Edge 74+ +- Edge Legacy 12+ +- Internet Explorer 10+ `*` + +`*` Internet Explorer [requires](#requirements) a polyfill for the `Promise` +based API. + +Loading an image from a URL and applying transformations (scaling, cropping and +rotating - except `orientation:true`, which requires reading meta data) is +supported by all browsers which implement the +[HTMLCanvasElement](https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement) +interface. + +Loading an image from a URL and scaling it in size is supported by all browsers +which implement the +[img](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img) element and +has been tested successfully with browser engines as old as Internet Explorer 5 +(via +[IE11's emulation mode]()). + +The `loadImage()` function applies options using +[progressive enhancement](https://en.wikipedia.org/wiki/Progressive_enhancement) +and falls back to a configuration that is supported by the browser, e.g. if the +`canvas` element is not supported, an equivalent `img` element is returned. + +## API + +### Callback + +#### Function signature + +The `loadImage()` function accepts a +[File](https://developer.mozilla.org/en-US/docs/Web/API/File) or +[Blob](https://developer.mozilla.org/en-US/docs/Web/API/Blob) object or an image +URL as first argument. + +If a [File](https://developer.mozilla.org/en-US/docs/Web/API/File) or +[Blob](https://developer.mozilla.org/en-US/docs/Web/API/Blob) is passed as +parameter, it returns an HTML `img` element if the browser supports the +[URL](https://developer.mozilla.org/en-US/docs/Web/API/URL) API, alternatively a +[FileReader](https://developer.mozilla.org/en-US/docs/Web/API/FileReader) object +if the `FileReader` API is supported, or `false`. + +It always returns an HTML +[img](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Img) element +when passing an image URL: + +```js +var loadingImage = loadImage( + 'https://example.org/image.png', + function (img) { + document.body.appendChild(img) + }, + { maxWidth: 600 } +) +``` + +#### Cancel image loading + +Some browsers (e.g. Chrome) will cancel the image loading process if the `src` +property of an `img` element is changed. +To avoid unnecessary requests, we can use the +[data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs) +of a 1x1 pixel transparent GIF image as `src` target to cancel the original +image download. + +To disable callback handling, we can also unset the image event handlers and for +maximum browser compatibility, cancel the file reading process if the returned +object is a +[FileReader](https://developer.mozilla.org/en-US/docs/Web/API/FileReader) +instance: + +```js +var loadingImage = loadImage( + 'https://example.org/image.png', + function (img) { + document.body.appendChild(img) + }, + { maxWidth: 600 } +) + +if (loadingImage) { + // Unset event handling for the loading image: + loadingImage.onload = loadingImage.onerror = null + + // Cancel image loading process: + if (loadingImage.abort) { + // FileReader instance, stop the file reading process: + loadingImage.abort() + } else { + // HTMLImageElement element, cancel the original image request by changing + // the target source to the data URL of a 1x1 pixel transparent image GIF: + loadingImage.src = + 'data:image/gif;base64,' + + 'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' + } +} +``` + +**Please note:** +The `img` element (or `FileReader` instance) for the loading image is only +returned when using the callback style API and not available with the +[Promise](#promise) based API. + +#### Callback arguments + +For the callback style API, the second argument to `loadImage()` must be a +`callback` function, which is called when the image has been loaded or an error +occurred while loading the image. + +The callback function is passed two arguments: + +1. An HTML [img](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img) + element or + [canvas](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API) + element, or an + [Event](https://developer.mozilla.org/en-US/docs/Web/API/Event) object of + type `error`. +2. An object with the original image dimensions as properties and potentially + additional [metadata](#metadata-parsing). + +```js +loadImage( + fileOrBlobOrUrl, + function (img, data) { + document.body.appendChild(img) + console.log('Original image width: ', data.originalWidth) + console.log('Original image height: ', data.originalHeight) + }, + { maxWidth: 600, meta: true } +) +``` + +**Please note:** +The original image dimensions reflect the natural width and height of the loaded +image before applying any transformation. +For consistent values across browsers, [metadata](#metadata-parsing) parsing has +to be enabled via `meta:true`, so `loadImage` can detect automatic image +orientation and normalize the dimensions. + +#### Error handling + +Example code implementing error handling: + +```js +loadImage( + fileOrBlobOrUrl, + function (img, data) { + if (img.type === 'error') { + console.error('Error loading image file') + } else { + document.body.appendChild(img) + } + }, + { maxWidth: 600 } +) +``` + +### Promise + +If the `loadImage()` function is called without a `callback` function as second +argument and the +[Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) +API is available, it returns a `Promise` object: + +```js +loadImage(fileOrBlobOrUrl, { maxWidth: 600, meta: true }) + .then(function (data) { + document.body.appendChild(data.image) + console.log('Original image width: ', data.originalWidth) + console.log('Original image height: ', data.originalHeight) + }) + .catch(function (err) { + // Handling image loading errors + console.log(err) + }) +``` + +The `Promise` resolves with an object with the following properties: + +- `image`: An HTML + [img](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img) or + [canvas](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API) element. +- `originalWidth`: The original width of the image. +- `originalHeight`: The original height of the image. + +Please also read the note about original image dimensions normalization in the +[callback arguments](#callback-arguments) section. + +If [metadata](#metadata-parsing) has been parsed, additional properties might be +present on the object. + +If image loading fails, the `Promise` rejects with an +[Event](https://developer.mozilla.org/en-US/docs/Web/API/Event) object of type +`error`. + +## Options + +The optional options argument to `loadImage()` allows to configure the image +loading. + +It can be used the following way with the callback style: + +```js +loadImage( + fileOrBlobOrUrl, + function (img) { + document.body.appendChild(img) + }, + { + maxWidth: 600, + maxHeight: 300, + minWidth: 100, + minHeight: 50, + canvas: true + } +) +``` + +Or the following way with the `Promise` based API: + +```js +loadImage(fileOrBlobOrUrl, { + maxWidth: 600, + maxHeight: 300, + minWidth: 100, + minHeight: 50, + canvas: true +}).then(function (data) { + document.body.appendChild(data.image) +}) +``` + +All settings are optional. By default, the image is returned as HTML `img` +element without any image size restrictions. + +### maxWidth + +Defines the maximum width of the `img`/`canvas` element. + +### maxHeight + +Defines the maximum height of the `img`/`canvas` element. + +### minWidth + +Defines the minimum width of the `img`/`canvas` element. + +### minHeight + +Defines the minimum height of the `img`/`canvas` element. + +### sourceWidth + +The width of the sub-rectangle of the source image to draw into the destination +canvas. +Defaults to the source image width and requires `canvas: true`. + +### sourceHeight + +The height of the sub-rectangle of the source image to draw into the destination +canvas. +Defaults to the source image height and requires `canvas: true`. + +### top + +The top margin of the sub-rectangle of the source image. +Defaults to `0` and requires `canvas: true`. + +### right + +The right margin of the sub-rectangle of the source image. +Defaults to `0` and requires `canvas: true`. + +### bottom + +The bottom margin of the sub-rectangle of the source image. +Defaults to `0` and requires `canvas: true`. + +### left + +The left margin of the sub-rectangle of the source image. +Defaults to `0` and requires `canvas: true`. + +### contain + +Scales the image up/down to contain it in the max dimensions if set to `true`. +This emulates the CSS feature +[background-image: contain](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Backgrounds_and_Borders/Resizing_background_images#contain). + +### cover + +Scales the image up/down to cover the max dimensions with the image dimensions +if set to `true`. +This emulates the CSS feature +[background-image: cover](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Backgrounds_and_Borders/Resizing_background_images#cover). + +### aspectRatio + +Crops the image to the given aspect ratio (e.g. `16/9`). +Setting the `aspectRatio` also enables the `crop` option. + +### pixelRatio + +Defines the ratio of the canvas pixels to the physical image pixels on the +screen. +Should be set to +[window.devicePixelRatio](https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio) +unless the scaled image is not rendered on screen. +Defaults to `1` and requires `canvas: true`. + +### downsamplingRatio + +Defines the ratio in which the image is downsampled (scaled down in steps). +By default, images are downsampled in one step. +With a ratio of `0.5`, each step scales the image to half the size, before +reaching the target dimensions. +Requires `canvas: true`. + +### imageSmoothingEnabled + +If set to `false`, +[disables image smoothing](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/imageSmoothingEnabled). +Defaults to `true` and requires `canvas: true`. + +### imageSmoothingQuality + +Sets the +[quality of image smoothing](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/imageSmoothingQuality). +Possible values: `'low'`, `'medium'`, `'high'` +Defaults to `'low'` and requires `canvas: true`. + +### crop + +Crops the image to the `maxWidth`/`maxHeight` constraints if set to `true`. +Enabling the `crop` option also enables the `canvas` option. + +### orientation + +Transform the canvas according to the specified Exif orientation, which can be +an `integer` in the range of `1` to `8` or the boolean value `true`. + +When set to `true`, it will set the orientation value based on the Exif data of +the image, which will be parsed automatically if the Exif extension is +available. + +Exif orientation values to correctly display the letter F: + +``` + 1 2 + ██████ ██████ + ██ ██ + ████ ████ + ██ ██ + ██ ██ + + 3 4 + ██ ██ + ██ ██ + ████ ████ + ██ ██ + ██████ ██████ + + 5 6 +██████████ ██ +██ ██ ██ ██ +██ ██████████ + + 7 8 + ██ ██████████ + ██ ██ ██ ██ +██████████ ██ +``` + +Setting `orientation` to `true` enables the `canvas` and `meta` options, unless +the browser supports automatic image orientation (see +[browser support for image-orientation](https://caniuse.com/#feat=css-image-orientation)). + +Setting `orientation` to `1` enables the `canvas` and `meta` options if the +browser does support automatic image orientation (to allow reset of the +orientation). + +Setting `orientation` to an integer in the range of `2` to `8` always enables +the `canvas` option and also enables the `meta` option if the browser supports +automatic image orientation (again to allow reset). + +### meta + +Automatically parses the image metadata if set to `true`. + +If metadata has been found, the data object passed as second argument to the +callback function has additional properties (see +[metadata parsing](#metadata-parsing)). + +If the file is given as URL and the browser supports the +[fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) or the +XHR +[responseType](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/responseType) +`blob`, fetches the file as `Blob` to be able to parse the metadata. + +### canvas + +Returns the image as +[canvas](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API) element if +set to `true`. + +### crossOrigin + +Sets the `crossOrigin` property on the `img` element for loading +[CORS enabled images](https://developer.mozilla.org/en-US/docs/Web/HTML/CORS_enabled_image). + +### noRevoke + +By default, the +[created object URL](https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL) +is revoked after the image has been loaded, except when this option is set to +`true`. + +## Metadata parsing + +If the Load Image Meta extension is included, it is possible to parse image meta +data automatically with the `meta` option: + +```js +loadImage( + fileOrBlobOrUrl, + function (img, data) { + console.log('Original image head: ', data.imageHead) + console.log('Exif data: ', data.exif) // requires exif extension + console.log('IPTC data: ', data.iptc) // requires iptc extension + }, + { meta: true } +) +``` + +Or alternatively via `loadImage.parseMetaData`, which can be used with an +available `File` or `Blob` object as first argument: + +```js +loadImage.parseMetaData( + fileOrBlob, + function (data) { + console.log('Original image head: ', data.imageHead) + console.log('Exif data: ', data.exif) // requires exif extension + console.log('IPTC data: ', data.iptc) // requires iptc extension + }, + { + maxMetaDataSize: 262144 + } +) +``` + +Or using the +[Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) +based API: + +```js +loadImage + .parseMetaData(fileOrBlob, { + maxMetaDataSize: 262144 + }) + .then(function (data) { + console.log('Original image head: ', data.imageHead) + console.log('Exif data: ', data.exif) // requires exif extension + console.log('IPTC data: ', data.iptc) // requires iptc extension + }) +``` + +The Metadata extension adds additional options used for the `parseMetaData` +method: + +- `maxMetaDataSize`: Maximum number of bytes of metadata to parse. +- `disableImageHead`: Disable parsing the original image head. +- `disableMetaDataParsers`: Disable parsing metadata (image head only) + +### Image head + +Resized JPEG images can be combined with their original image head via +`loadImage.replaceHead`, which requires the resized image as `Blob` object as +first argument and an `ArrayBuffer` image head as second argument. + +With callback style, the third argument must be a `callback` function, which is +called with the new `Blob` object: + +```js +loadImage( + fileOrBlobOrUrl, + function (img, data) { + if (data.imageHead) { + img.toBlob(function (blob) { + loadImage.replaceHead(blob, data.imageHead, function (newBlob) { + // do something with the new Blob object + }) + }, 'image/jpeg') + } + }, + { meta: true, canvas: true, maxWidth: 800 } +) +``` + +Or using the +[Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) +based API like this: + +```js +loadImage(fileOrBlobOrUrl, { meta: true, canvas: true, maxWidth: 800 }) + .then(function (data) { + if (!data.imageHead) throw new Error('Could not parse image metadata') + return new Promise(function (resolve) { + data.image.toBlob(function (blob) { + data.blob = blob + resolve(data) + }, 'image/jpeg') + }) + }) + .then(function (data) { + return loadImage.replaceHead(data.blob, data.imageHead) + }) + .then(function (blob) { + // do something with the new Blob object + }) + .catch(function (err) { + console.error(err) + }) +``` + +**Please note:** +`Blob` objects of resized images can be created via +[HTMLCanvasElement.toBlob](https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob). +[blueimp-canvas-to-blob](https://github.com/blueimp/JavaScript-Canvas-to-Blob) +provides a polyfill for browsers without native `canvas.toBlob()` support. + +### Exif parser + +If you include the Load Image Exif Parser extension, the argument passed to the +callback for `parseMetaData` will contain the following additional properties if +Exif data could be found in the given image: + +- `exif`: The parsed Exif tags +- `exifOffsets`: The parsed Exif tag offsets +- `exifTiffOffset`: TIFF header offset (used for offset pointers) +- `exifLittleEndian`: little endian order if true, big endian if false + +The `exif` object stores the parsed Exif tags: + +```js +var orientation = data.exif[0x0112] // Orientation +``` + +The `exif` and `exifOffsets` objects also provide a `get()` method to retrieve +the tag value/offset via the tag's mapped name: + +```js +var orientation = data.exif.get('Orientation') +var orientationOffset = data.exifOffsets.get('Orientation') +``` + +By default, only the following names are mapped: + +- `Orientation` +- `Thumbnail` (see [Exif Thumbnail](#exif-thumbnail)) +- `Exif` (see [Exif IFD](#exif-ifd)) +- `GPSInfo` (see [GPSInfo IFD](#gpsinfo-ifd)) +- `Interoperability` (see [Interoperability IFD](#interoperability-ifd)) + +If you also include the Load Image Exif Map library, additional tag mappings +become available, as well as three additional methods: + +- `exif.getText()` +- `exif.getName()` +- `exif.getAll()` + +```js +var orientationText = data.exif.getText('Orientation') // e.g. "Rotate 90° CW" + +var name = data.exif.getName(0x0112) // "Orientation" + +// A map of all parsed tags with their mapped names/text as keys/values: +var allTags = data.exif.getAll() +``` + +#### Exif Thumbnail + +Example code displaying a thumbnail image embedded into the Exif metadata: + +```js +loadImage( + fileOrBlobOrUrl, + function (img, data) { + var exif = data.exif + var thumbnail = exif && exif.get('Thumbnail') + var blob = thumbnail && thumbnail.get('Blob') + if (blob) { + loadImage( + blob, + function (thumbImage) { + document.body.appendChild(thumbImage) + }, + { orientation: exif.get('Orientation') } + ) + } + }, + { meta: true } +) +``` + +#### Exif IFD + +Example code displaying data from the Exif IFD (Image File Directory) that +contains Exif specified TIFF tags: + +```js +loadImage( + fileOrBlobOrUrl, + function (img, data) { + var exifIFD = data.exif && data.exif.get('Exif') + if (exifIFD) { + // Map of all Exif IFD tags with their mapped names/text as keys/values: + console.log(exifIFD.getAll()) + // A specific Exif IFD tag value: + console.log(exifIFD.get('UserComment')) + } + }, + { meta: true } +) +``` + +#### GPSInfo IFD + +Example code displaying data from the Exif IFD (Image File Directory) that +contains [GPS](https://en.wikipedia.org/wiki/Global_Positioning_System) info: + +```js +loadImage( + fileOrBlobOrUrl, + function (img, data) { + var gpsInfo = data.exif && data.exif.get('GPSInfo') + if (gpsInfo) { + // Map of all GPSInfo tags with their mapped names/text as keys/values: + console.log(gpsInfo.getAll()) + // A specific GPSInfo tag value: + console.log(gpsInfo.get('GPSLatitude')) + } + }, + { meta: true } +) +``` + +#### Interoperability IFD + +Example code displaying data from the Exif IFD (Image File Directory) that +contains Interoperability data: + +```js +loadImage( + fileOrBlobOrUrl, + function (img, data) { + var interoperabilityData = data.exif && data.exif.get('Interoperability') + if (interoperabilityData) { + // The InteroperabilityIndex tag value: + console.log(interoperabilityData.get('InteroperabilityIndex')) + } + }, + { meta: true } +) +``` + +#### Exif parser options + +The Exif parser adds additional options: + +- `disableExif`: Disables Exif parsing when `true`. +- `disableExifOffsets`: Disables storing Exif tag offsets when `true`. +- `includeExifTags`: A map of Exif tags to include for parsing (includes all but + the excluded tags by default). +- `excludeExifTags`: A map of Exif tags to exclude from parsing (defaults to + exclude `Exif` `MakerNote`). + +An example parsing only Orientation, Thumbnail and ExifVersion tags: + +```js +loadImage.parseMetaData( + fileOrBlob, + function (data) { + console.log('Exif data: ', data.exif) + }, + { + includeExifTags: { + 0x0112: true, // Orientation + ifd1: { + 0x0201: true, // JPEGInterchangeFormat (Thumbnail data offset) + 0x0202: true // JPEGInterchangeFormatLength (Thumbnail data length) + }, + 0x8769: { + // ExifIFDPointer + 0x9000: true // ExifVersion + } + } + } +) +``` + +An example excluding `Exif` `MakerNote` and `GPSInfo`: + +```js +loadImage.parseMetaData( + fileOrBlob, + function (data) { + console.log('Exif data: ', data.exif) + }, + { + excludeExifTags: { + 0x8769: { + // ExifIFDPointer + 0x927c: true // MakerNote + }, + 0x8825: true // GPSInfoIFDPointer + } + } +) +``` + +### Exif writer + +The Exif parser extension also includes a minimal writer that allows to override +the Exif `Orientation` value in the parsed `imageHead` `ArrayBuffer`: + +```js +loadImage( + fileOrBlobOrUrl, + function (img, data) { + if (data.imageHead && data.exif) { + // Reset Exif Orientation data: + loadImage.writeExifData(data.imageHead, data, 'Orientation', 1) + img.toBlob(function (blob) { + loadImage.replaceHead(blob, data.imageHead, function (newBlob) { + // do something with newBlob + }) + }, 'image/jpeg') + } + }, + { meta: true, orientation: true, canvas: true, maxWidth: 800 } +) +``` + +**Please note:** +The Exif writer relies on the Exif tag offsets being available as +`data.exifOffsets` property, which requires that Exif data has been parsed from +the image. +The Exif writer can only change existing values, not add new tags, e.g. it +cannot add an Exif `Orientation` tag for an image that does not have one. + +### IPTC parser + +If you include the Load Image IPTC Parser extension, the argument passed to the +callback for `parseMetaData` will contain the following additional properties if +IPTC data could be found in the given image: + +- `iptc`: The parsed IPTC tags +- `iptcOffsets`: The parsed IPTC tag offsets + +The `iptc` object stores the parsed IPTC tags: + +```js +var objectname = data.iptc[5] +``` + +The `iptc` and `iptcOffsets` objects also provide a `get()` method to retrieve +the tag value/offset via the tag's mapped name: + +```js +var objectname = data.iptc.get('ObjectName') +``` + +By default, only the following names are mapped: + +- `ObjectName` + +If you also include the Load Image IPTC Map library, additional tag mappings +become available, as well as three additional methods: + +- `iptc.getText()` +- `iptc.getName()` +- `iptc.getAll()` + +```js +var keywords = data.iptc.getText('Keywords') // e.g.: ['Weather','Sky'] + +var name = data.iptc.getName(5) // ObjectName + +// A map of all parsed tags with their mapped names/text as keys/values: +var allTags = data.iptc.getAll() +``` + +#### IPTC parser options + +The IPTC parser adds additional options: + +- `disableIptc`: Disables IPTC parsing when true. +- `disableIptcOffsets`: Disables storing IPTC tag offsets when `true`. +- `includeIptcTags`: A map of IPTC tags to include for parsing (includes all but + the excluded tags by default). +- `excludeIptcTags`: A map of IPTC tags to exclude from parsing (defaults to + exclude `ObjectPreviewData`). + +An example parsing only the `ObjectName` tag: + +```js +loadImage.parseMetaData( + fileOrBlob, + function (data) { + console.log('IPTC data: ', data.iptc) + }, + { + includeIptcTags: { + 5: true // ObjectName + } + } +) +``` + +An example excluding `ApplicationRecordVersion` and `ObjectPreviewData`: + +```js +loadImage.parseMetaData( + fileOrBlob, + function (data) { + console.log('IPTC data: ', data.iptc) + }, + { + excludeIptcTags: { + 0: true, // ApplicationRecordVersion + 202: true // ObjectPreviewData + } + } +) +``` + +## License + +The JavaScript Load Image library is released under the +[MIT license](https://opensource.org/licenses/MIT). + +## Credits + +- Original image metadata handling implemented with the help and contribution of + Achim Stöhr. +- Original Exif tags mapping based on Jacob Seidelin's + [exif-js](https://github.com/exif-js/exif-js) library. +- Original IPTC parser implementation by + [Dave Bevan](https://github.com/bevand10). diff --git a/lib/web/jquery/fileUploader/vendor/blueimp-load-image/js/index.js b/lib/web/jquery/fileUploader/vendor/blueimp-load-image/js/index.js new file mode 100644 index 0000000000000..20875a2d08535 --- /dev/null +++ b/lib/web/jquery/fileUploader/vendor/blueimp-load-image/js/index.js @@ -0,0 +1,12 @@ +/* global module, require */ + +module.exports = require('jquery/fileUploader/vendor/blueimp-load-image/js/load-image') + +require('jquery/fileUploader/vendor/blueimp-load-image/js/load-image-scale') +require('jquery/fileUploader/vendor/blueimp-load-image/js/load-image-meta') +require('jquery/fileUploader/vendor/blueimp-load-image/js/load-image-fetch') +require('jquery/fileUploader/vendor/blueimp-load-image/js/load-image-exif') +require('jquery/fileUploader/vendor/blueimp-load-image/js/load-image-exif-map') +require('jquery/fileUploader/vendor/blueimp-load-image/js/load-image-iptc') +require('jquery/fileUploader/vendor/blueimp-load-image/js/load-image-iptc-map') +require('jquery/fileUploader/vendor/blueimp-load-image/js/load-image-orientation') diff --git a/lib/web/jquery/fileUploader/vendor/blueimp-load-image/js/load-image-exif-map.js b/lib/web/jquery/fileUploader/vendor/blueimp-load-image/js/load-image-exif-map.js new file mode 100644 index 0000000000000..29f11aff226fc --- /dev/null +++ b/lib/web/jquery/fileUploader/vendor/blueimp-load-image/js/load-image-exif-map.js @@ -0,0 +1,420 @@ +/* + * JavaScript Load Image Exif Map + * https://github.com/blueimp/JavaScript-Load-Image + * + * Copyright 2013, Sebastian Tschan + * https://blueimp.net + * + * Exif tags mapping based on + * https://github.com/jseidelin/exif-js + * + * Licensed under the MIT license: + * https://opensource.org/licenses/MIT + */ + +/* global define, module, require */ + +;(function (factory) { + 'use strict' + if (typeof define === 'function' && define.amd) { + // Register as an anonymous AMD module: + define(['jquery/fileUploader/vendor/blueimp-load-image/js/load-image', 'jquery/fileUploader/vendor/blueimp-load-image/js/load-image-exif'], factory) + } else if (typeof module === 'object' && module.exports) { + factory(require('jquery/fileUploader/vendor/blueimp-load-image/js/load-image'), require('jquery/fileUploader/vendor/blueimp-load-image/js/load-image-exif')) + } else { + // Browser globals: + factory(window.loadImage) + } +})(function (loadImage) { + 'use strict' + + var ExifMapProto = loadImage.ExifMap.prototype + + ExifMapProto.tags = { + // ================= + // TIFF tags (IFD0): + // ================= + 0x0100: 'ImageWidth', + 0x0101: 'ImageHeight', + 0x0102: 'BitsPerSample', + 0x0103: 'Compression', + 0x0106: 'PhotometricInterpretation', + 0x0112: 'Orientation', + 0x0115: 'SamplesPerPixel', + 0x011c: 'PlanarConfiguration', + 0x0212: 'YCbCrSubSampling', + 0x0213: 'YCbCrPositioning', + 0x011a: 'XResolution', + 0x011b: 'YResolution', + 0x0128: 'ResolutionUnit', + 0x0111: 'StripOffsets', + 0x0116: 'RowsPerStrip', + 0x0117: 'StripByteCounts', + 0x0201: 'JPEGInterchangeFormat', + 0x0202: 'JPEGInterchangeFormatLength', + 0x012d: 'TransferFunction', + 0x013e: 'WhitePoint', + 0x013f: 'PrimaryChromaticities', + 0x0211: 'YCbCrCoefficients', + 0x0214: 'ReferenceBlackWhite', + 0x0132: 'DateTime', + 0x010e: 'ImageDescription', + 0x010f: 'Make', + 0x0110: 'Model', + 0x0131: 'Software', + 0x013b: 'Artist', + 0x8298: 'Copyright', + 0x8769: { + // ExifIFDPointer + 0x9000: 'ExifVersion', // EXIF version + 0xa000: 'FlashpixVersion', // Flashpix format version + 0xa001: 'ColorSpace', // Color space information tag + 0xa002: 'PixelXDimension', // Valid width of meaningful image + 0xa003: 'PixelYDimension', // Valid height of meaningful image + 0xa500: 'Gamma', + 0x9101: 'ComponentsConfiguration', // Information about channels + 0x9102: 'CompressedBitsPerPixel', // Compressed bits per pixel + 0x927c: 'MakerNote', // Any desired information written by the manufacturer + 0x9286: 'UserComment', // Comments by user + 0xa004: 'RelatedSoundFile', // Name of related sound file + 0x9003: 'DateTimeOriginal', // Date and time when the original image was generated + 0x9004: 'DateTimeDigitized', // Date and time when the image was stored digitally + 0x9010: 'OffsetTime', // Time zone when the image file was last changed + 0x9011: 'OffsetTimeOriginal', // Time zone when the image was stored digitally + 0x9012: 'OffsetTimeDigitized', // Time zone when the image was stored digitally + 0x9290: 'SubSecTime', // Fractions of seconds for DateTime + 0x9291: 'SubSecTimeOriginal', // Fractions of seconds for DateTimeOriginal + 0x9292: 'SubSecTimeDigitized', // Fractions of seconds for DateTimeDigitized + 0x829a: 'ExposureTime', // Exposure time (in seconds) + 0x829d: 'FNumber', + 0x8822: 'ExposureProgram', // Exposure program + 0x8824: 'SpectralSensitivity', // Spectral sensitivity + 0x8827: 'PhotographicSensitivity', // EXIF 2.3, ISOSpeedRatings in EXIF 2.2 + 0x8828: 'OECF', // Optoelectric conversion factor + 0x8830: 'SensitivityType', + 0x8831: 'StandardOutputSensitivity', + 0x8832: 'RecommendedExposureIndex', + 0x8833: 'ISOSpeed', + 0x8834: 'ISOSpeedLatitudeyyy', + 0x8835: 'ISOSpeedLatitudezzz', + 0x9201: 'ShutterSpeedValue', // Shutter speed + 0x9202: 'ApertureValue', // Lens aperture + 0x9203: 'BrightnessValue', // Value of brightness + 0x9204: 'ExposureBias', // Exposure bias + 0x9205: 'MaxApertureValue', // Smallest F number of lens + 0x9206: 'SubjectDistance', // Distance to subject in meters + 0x9207: 'MeteringMode', // Metering mode + 0x9208: 'LightSource', // Kind of light source + 0x9209: 'Flash', // Flash status + 0x9214: 'SubjectArea', // Location and area of main subject + 0x920a: 'FocalLength', // Focal length of the lens in mm + 0xa20b: 'FlashEnergy', // Strobe energy in BCPS + 0xa20c: 'SpatialFrequencyResponse', + 0xa20e: 'FocalPlaneXResolution', // Number of pixels in width direction per FPRUnit + 0xa20f: 'FocalPlaneYResolution', // Number of pixels in height direction per FPRUnit + 0xa210: 'FocalPlaneResolutionUnit', // Unit for measuring the focal plane resolution + 0xa214: 'SubjectLocation', // Location of subject in image + 0xa215: 'ExposureIndex', // Exposure index selected on camera + 0xa217: 'SensingMethod', // Image sensor type + 0xa300: 'FileSource', // Image source (3 == DSC) + 0xa301: 'SceneType', // Scene type (1 == directly photographed) + 0xa302: 'CFAPattern', // Color filter array geometric pattern + 0xa401: 'CustomRendered', // Special processing + 0xa402: 'ExposureMode', // Exposure mode + 0xa403: 'WhiteBalance', // 1 = auto white balance, 2 = manual + 0xa404: 'DigitalZoomRatio', // Digital zoom ratio + 0xa405: 'FocalLengthIn35mmFilm', + 0xa406: 'SceneCaptureType', // Type of scene + 0xa407: 'GainControl', // Degree of overall image gain adjustment + 0xa408: 'Contrast', // Direction of contrast processing applied by camera + 0xa409: 'Saturation', // Direction of saturation processing applied by camera + 0xa40a: 'Sharpness', // Direction of sharpness processing applied by camera + 0xa40b: 'DeviceSettingDescription', + 0xa40c: 'SubjectDistanceRange', // Distance to subject + 0xa420: 'ImageUniqueID', // Identifier assigned uniquely to each image + 0xa430: 'CameraOwnerName', + 0xa431: 'BodySerialNumber', + 0xa432: 'LensSpecification', + 0xa433: 'LensMake', + 0xa434: 'LensModel', + 0xa435: 'LensSerialNumber' + }, + 0x8825: { + // GPSInfoIFDPointer + 0x0000: 'GPSVersionID', + 0x0001: 'GPSLatitudeRef', + 0x0002: 'GPSLatitude', + 0x0003: 'GPSLongitudeRef', + 0x0004: 'GPSLongitude', + 0x0005: 'GPSAltitudeRef', + 0x0006: 'GPSAltitude', + 0x0007: 'GPSTimeStamp', + 0x0008: 'GPSSatellites', + 0x0009: 'GPSStatus', + 0x000a: 'GPSMeasureMode', + 0x000b: 'GPSDOP', + 0x000c: 'GPSSpeedRef', + 0x000d: 'GPSSpeed', + 0x000e: 'GPSTrackRef', + 0x000f: 'GPSTrack', + 0x0010: 'GPSImgDirectionRef', + 0x0011: 'GPSImgDirection', + 0x0012: 'GPSMapDatum', + 0x0013: 'GPSDestLatitudeRef', + 0x0014: 'GPSDestLatitude', + 0x0015: 'GPSDestLongitudeRef', + 0x0016: 'GPSDestLongitude', + 0x0017: 'GPSDestBearingRef', + 0x0018: 'GPSDestBearing', + 0x0019: 'GPSDestDistanceRef', + 0x001a: 'GPSDestDistance', + 0x001b: 'GPSProcessingMethod', + 0x001c: 'GPSAreaInformation', + 0x001d: 'GPSDateStamp', + 0x001e: 'GPSDifferential', + 0x001f: 'GPSHPositioningError' + }, + 0xa005: { + // InteroperabilityIFDPointer + 0x0001: 'InteroperabilityIndex' + } + } + + // IFD1 directory can contain any IFD0 tags: + ExifMapProto.tags.ifd1 = ExifMapProto.tags + + ExifMapProto.stringValues = { + ExposureProgram: { + 0: 'Undefined', + 1: 'Manual', + 2: 'Normal program', + 3: 'Aperture priority', + 4: 'Shutter priority', + 5: 'Creative program', + 6: 'Action program', + 7: 'Portrait mode', + 8: 'Landscape mode' + }, + MeteringMode: { + 0: 'Unknown', + 1: 'Average', + 2: 'CenterWeightedAverage', + 3: 'Spot', + 4: 'MultiSpot', + 5: 'Pattern', + 6: 'Partial', + 255: 'Other' + }, + LightSource: { + 0: 'Unknown', + 1: 'Daylight', + 2: 'Fluorescent', + 3: 'Tungsten (incandescent light)', + 4: 'Flash', + 9: 'Fine weather', + 10: 'Cloudy weather', + 11: 'Shade', + 12: 'Daylight fluorescent (D 5700 - 7100K)', + 13: 'Day white fluorescent (N 4600 - 5400K)', + 14: 'Cool white fluorescent (W 3900 - 4500K)', + 15: 'White fluorescent (WW 3200 - 3700K)', + 17: 'Standard light A', + 18: 'Standard light B', + 19: 'Standard light C', + 20: 'D55', + 21: 'D65', + 22: 'D75', + 23: 'D50', + 24: 'ISO studio tungsten', + 255: 'Other' + }, + Flash: { + 0x0000: 'Flash did not fire', + 0x0001: 'Flash fired', + 0x0005: 'Strobe return light not detected', + 0x0007: 'Strobe return light detected', + 0x0009: 'Flash fired, compulsory flash mode', + 0x000d: 'Flash fired, compulsory flash mode, return light not detected', + 0x000f: 'Flash fired, compulsory flash mode, return light detected', + 0x0010: 'Flash did not fire, compulsory flash mode', + 0x0018: 'Flash did not fire, auto mode', + 0x0019: 'Flash fired, auto mode', + 0x001d: 'Flash fired, auto mode, return light not detected', + 0x001f: 'Flash fired, auto mode, return light detected', + 0x0020: 'No flash function', + 0x0041: 'Flash fired, red-eye reduction mode', + 0x0045: 'Flash fired, red-eye reduction mode, return light not detected', + 0x0047: 'Flash fired, red-eye reduction mode, return light detected', + 0x0049: 'Flash fired, compulsory flash mode, red-eye reduction mode', + 0x004d: 'Flash fired, compulsory flash mode, red-eye reduction mode, return light not detected', + 0x004f: 'Flash fired, compulsory flash mode, red-eye reduction mode, return light detected', + 0x0059: 'Flash fired, auto mode, red-eye reduction mode', + 0x005d: 'Flash fired, auto mode, return light not detected, red-eye reduction mode', + 0x005f: 'Flash fired, auto mode, return light detected, red-eye reduction mode' + }, + SensingMethod: { + 1: 'Undefined', + 2: 'One-chip color area sensor', + 3: 'Two-chip color area sensor', + 4: 'Three-chip color area sensor', + 5: 'Color sequential area sensor', + 7: 'Trilinear sensor', + 8: 'Color sequential linear sensor' + }, + SceneCaptureType: { + 0: 'Standard', + 1: 'Landscape', + 2: 'Portrait', + 3: 'Night scene' + }, + SceneType: { + 1: 'Directly photographed' + }, + CustomRendered: { + 0: 'Normal process', + 1: 'Custom process' + }, + WhiteBalance: { + 0: 'Auto white balance', + 1: 'Manual white balance' + }, + GainControl: { + 0: 'None', + 1: 'Low gain up', + 2: 'High gain up', + 3: 'Low gain down', + 4: 'High gain down' + }, + Contrast: { + 0: 'Normal', + 1: 'Soft', + 2: 'Hard' + }, + Saturation: { + 0: 'Normal', + 1: 'Low saturation', + 2: 'High saturation' + }, + Sharpness: { + 0: 'Normal', + 1: 'Soft', + 2: 'Hard' + }, + SubjectDistanceRange: { + 0: 'Unknown', + 1: 'Macro', + 2: 'Close view', + 3: 'Distant view' + }, + FileSource: { + 3: 'DSC' + }, + ComponentsConfiguration: { + 0: '', + 1: 'Y', + 2: 'Cb', + 3: 'Cr', + 4: 'R', + 5: 'G', + 6: 'B' + }, + Orientation: { + 1: 'Original', + 2: 'Horizontal flip', + 3: 'Rotate 180° CCW', + 4: 'Vertical flip', + 5: 'Vertical flip + Rotate 90° CW', + 6: 'Rotate 90° CW', + 7: 'Horizontal flip + Rotate 90° CW', + 8: 'Rotate 90° CCW' + } + } + + ExifMapProto.getText = function (name) { + var value = this.get(name) + switch (name) { + case 'LightSource': + case 'Flash': + case 'MeteringMode': + case 'ExposureProgram': + case 'SensingMethod': + case 'SceneCaptureType': + case 'SceneType': + case 'CustomRendered': + case 'WhiteBalance': + case 'GainControl': + case 'Contrast': + case 'Saturation': + case 'Sharpness': + case 'SubjectDistanceRange': + case 'FileSource': + case 'Orientation': + return this.stringValues[name][value] + case 'ExifVersion': + case 'FlashpixVersion': + if (!value) return + return String.fromCharCode(value[0], value[1], value[2], value[3]) + case 'ComponentsConfiguration': + if (!value) return + return ( + this.stringValues[name][value[0]] + + this.stringValues[name][value[1]] + + this.stringValues[name][value[2]] + + this.stringValues[name][value[3]] + ) + case 'GPSVersionID': + if (!value) return + return value[0] + '.' + value[1] + '.' + value[2] + '.' + value[3] + } + return String(value) + } + + ExifMapProto.getAll = function () { + var map = {} + var prop + var obj + var name + for (prop in this) { + if (Object.prototype.hasOwnProperty.call(this, prop)) { + obj = this[prop] + if (obj && obj.getAll) { + map[this.ifds[prop].name] = obj.getAll() + } else { + name = this.tags[prop] + if (name) map[name] = this.getText(name) + } + } + } + return map + } + + ExifMapProto.getName = function (tagCode) { + var name = this.tags[tagCode] + if (typeof name === 'object') return this.ifds[tagCode].name + return name + } + + // Extend the map of tag names to tag codes: + ;(function () { + var tags = ExifMapProto.tags + var prop + var ifd + var subTags + // Map the tag names to tags: + for (prop in tags) { + if (Object.prototype.hasOwnProperty.call(tags, prop)) { + ifd = ExifMapProto.ifds[prop] + if (ifd) { + subTags = tags[prop] + for (prop in subTags) { + if (Object.prototype.hasOwnProperty.call(subTags, prop)) { + ifd.map[subTags[prop]] = Number(prop) + } + } + } else { + ExifMapProto.map[tags[prop]] = Number(prop) + } + } + } + })() +}) diff --git a/lib/web/jquery/fileUploader/vendor/blueimp-load-image/js/load-image-exif.js b/lib/web/jquery/fileUploader/vendor/blueimp-load-image/js/load-image-exif.js new file mode 100644 index 0000000000000..3c0937b8b590a --- /dev/null +++ b/lib/web/jquery/fileUploader/vendor/blueimp-load-image/js/load-image-exif.js @@ -0,0 +1,460 @@ +/* + * JavaScript Load Image Exif Parser + * https://github.com/blueimp/JavaScript-Load-Image + * + * Copyright 2013, Sebastian Tschan + * https://blueimp.net + * + * Licensed under the MIT license: + * https://opensource.org/licenses/MIT + */ + +/* global define, module, require, DataView */ + +/* eslint-disable no-console */ + +;(function (factory) { + 'use strict' + if (typeof define === 'function' && define.amd) { + // Register as an anonymous AMD module: + define(['jquery/fileUploader/vendor/blueimp-load-image/js/load-image', 'jquery/fileUploader/vendor/blueimp-load-image/js/load-image-meta'], factory) + } else if (typeof module === 'object' && module.exports) { + factory(require('jquery/fileUploader/vendor/blueimp-load-image/js/load-image'), require('jquery/fileUploader/vendor/blueimp-load-image/js/load-image-meta')) + } else { + // Browser globals: + factory(window.loadImage) + } +})(function (loadImage) { + 'use strict' + + /** + * Exif tag map + * + * @name ExifMap + * @class + * @param {number|string} tagCode IFD tag code + */ + function ExifMap(tagCode) { + if (tagCode) { + Object.defineProperty(this, 'map', { + value: this.ifds[tagCode].map + }) + Object.defineProperty(this, 'tags', { + value: (this.tags && this.tags[tagCode]) || {} + }) + } + } + + ExifMap.prototype.map = { + Orientation: 0x0112, + Thumbnail: 'ifd1', + Blob: 0x0201, // Alias for JPEGInterchangeFormat + Exif: 0x8769, + GPSInfo: 0x8825, + Interoperability: 0xa005 + } + + ExifMap.prototype.ifds = { + ifd1: { name: 'Thumbnail', map: ExifMap.prototype.map }, + 0x8769: { name: 'Exif', map: {} }, + 0x8825: { name: 'GPSInfo', map: {} }, + 0xa005: { name: 'Interoperability', map: {} } + } + + /** + * Retrieves exif tag value + * + * @param {number|string} id Exif tag code or name + * @returns {object} Exif tag value + */ + ExifMap.prototype.get = function (id) { + return this[id] || this[this.map[id]] + } + + /** + * Returns the Exif Thumbnail data as Blob. + * + * @param {DataView} dataView Data view interface + * @param {number} offset Thumbnail data offset + * @param {number} length Thumbnail data length + * @returns {undefined|Blob} Returns the Thumbnail Blob or undefined + */ + function getExifThumbnail(dataView, offset, length) { + if (!length) return + if (offset + length > dataView.byteLength) { + console.log('Invalid Exif data: Invalid thumbnail data.') + return + } + return new Blob( + [loadImage.bufferSlice.call(dataView.buffer, offset, offset + length)], + { + type: 'image/jpeg' + } + ) + } + + var ExifTagTypes = { + // byte, 8-bit unsigned int: + 1: { + getValue: function (dataView, dataOffset) { + return dataView.getUint8(dataOffset) + }, + size: 1 + }, + // ascii, 8-bit byte: + 2: { + getValue: function (dataView, dataOffset) { + return String.fromCharCode(dataView.getUint8(dataOffset)) + }, + size: 1, + ascii: true + }, + // short, 16 bit int: + 3: { + getValue: function (dataView, dataOffset, littleEndian) { + return dataView.getUint16(dataOffset, littleEndian) + }, + size: 2 + }, + // long, 32 bit int: + 4: { + getValue: function (dataView, dataOffset, littleEndian) { + return dataView.getUint32(dataOffset, littleEndian) + }, + size: 4 + }, + // rational = two long values, first is numerator, second is denominator: + 5: { + getValue: function (dataView, dataOffset, littleEndian) { + return ( + dataView.getUint32(dataOffset, littleEndian) / + dataView.getUint32(dataOffset + 4, littleEndian) + ) + }, + size: 8 + }, + // slong, 32 bit signed int: + 9: { + getValue: function (dataView, dataOffset, littleEndian) { + return dataView.getInt32(dataOffset, littleEndian) + }, + size: 4 + }, + // srational, two slongs, first is numerator, second is denominator: + 10: { + getValue: function (dataView, dataOffset, littleEndian) { + return ( + dataView.getInt32(dataOffset, littleEndian) / + dataView.getInt32(dataOffset + 4, littleEndian) + ) + }, + size: 8 + } + } + // undefined, 8-bit byte, value depending on field: + ExifTagTypes[7] = ExifTagTypes[1] + + /** + * Returns Exif tag value. + * + * @param {DataView} dataView Data view interface + * @param {number} tiffOffset TIFF offset + * @param {number} offset Tag offset + * @param {number} type Tag type + * @param {number} length Tag length + * @param {boolean} littleEndian Little endian encoding + * @returns {object} Tag value + */ + function getExifValue( + dataView, + tiffOffset, + offset, + type, + length, + littleEndian + ) { + var tagType = ExifTagTypes[type] + var tagSize + var dataOffset + var values + var i + var str + var c + if (!tagType) { + console.log('Invalid Exif data: Invalid tag type.') + return + } + tagSize = tagType.size * length + // Determine if the value is contained in the dataOffset bytes, + // or if the value at the dataOffset is a pointer to the actual data: + dataOffset = + tagSize > 4 + ? tiffOffset + dataView.getUint32(offset + 8, littleEndian) + : offset + 8 + if (dataOffset + tagSize > dataView.byteLength) { + console.log('Invalid Exif data: Invalid data offset.') + return + } + if (length === 1) { + return tagType.getValue(dataView, dataOffset, littleEndian) + } + values = [] + for (i = 0; i < length; i += 1) { + values[i] = tagType.getValue( + dataView, + dataOffset + i * tagType.size, + littleEndian + ) + } + if (tagType.ascii) { + str = '' + // Concatenate the chars: + for (i = 0; i < values.length; i += 1) { + c = values[i] + // Ignore the terminating NULL byte(s): + if (c === '\u0000') { + break + } + str += c + } + return str + } + return values + } + + /** + * Determines if the given tag should be included. + * + * @param {object} includeTags Map of tags to include + * @param {object} excludeTags Map of tags to exclude + * @param {number|string} tagCode Tag code to check + * @returns {boolean} True if the tag should be included + */ + function shouldIncludeTag(includeTags, excludeTags, tagCode) { + return ( + (!includeTags || includeTags[tagCode]) && + (!excludeTags || excludeTags[tagCode] !== true) + ) + } + + /** + * Parses Exif tags. + * + * @param {DataView} dataView Data view interface + * @param {number} tiffOffset TIFF offset + * @param {number} dirOffset Directory offset + * @param {boolean} littleEndian Little endian encoding + * @param {ExifMap} tags Map to store parsed exif tags + * @param {ExifMap} tagOffsets Map to store parsed exif tag offsets + * @param {object} includeTags Map of tags to include + * @param {object} excludeTags Map of tags to exclude + * @returns {number} Next directory offset + */ + function parseExifTags( + dataView, + tiffOffset, + dirOffset, + littleEndian, + tags, + tagOffsets, + includeTags, + excludeTags + ) { + var tagsNumber, dirEndOffset, i, tagOffset, tagNumber, tagValue + if (dirOffset + 6 > dataView.byteLength) { + console.log('Invalid Exif data: Invalid directory offset.') + return + } + tagsNumber = dataView.getUint16(dirOffset, littleEndian) + dirEndOffset = dirOffset + 2 + 12 * tagsNumber + if (dirEndOffset + 4 > dataView.byteLength) { + console.log('Invalid Exif data: Invalid directory size.') + return + } + for (i = 0; i < tagsNumber; i += 1) { + tagOffset = dirOffset + 2 + 12 * i + tagNumber = dataView.getUint16(tagOffset, littleEndian) + if (!shouldIncludeTag(includeTags, excludeTags, tagNumber)) continue + tagValue = getExifValue( + dataView, + tiffOffset, + tagOffset, + dataView.getUint16(tagOffset + 2, littleEndian), // tag type + dataView.getUint32(tagOffset + 4, littleEndian), // tag length + littleEndian + ) + tags[tagNumber] = tagValue + if (tagOffsets) { + tagOffsets[tagNumber] = tagOffset + } + } + // Return the offset to the next directory: + return dataView.getUint32(dirEndOffset, littleEndian) + } + + /** + * Parses tags in a given IFD (Image File Directory). + * + * @param {object} data Data object to store exif tags and offsets + * @param {number|string} tagCode IFD tag code + * @param {DataView} dataView Data view interface + * @param {number} tiffOffset TIFF offset + * @param {boolean} littleEndian Little endian encoding + * @param {object} includeTags Map of tags to include + * @param {object} excludeTags Map of tags to exclude + */ + function parseExifIFD( + data, + tagCode, + dataView, + tiffOffset, + littleEndian, + includeTags, + excludeTags + ) { + var dirOffset = data.exif[tagCode] + if (dirOffset) { + data.exif[tagCode] = new ExifMap(tagCode) + if (data.exifOffsets) { + data.exifOffsets[tagCode] = new ExifMap(tagCode) + } + parseExifTags( + dataView, + tiffOffset, + tiffOffset + dirOffset, + littleEndian, + data.exif[tagCode], + data.exifOffsets && data.exifOffsets[tagCode], + includeTags && includeTags[tagCode], + excludeTags && excludeTags[tagCode] + ) + } + } + + loadImage.parseExifData = function (dataView, offset, length, data, options) { + if (options.disableExif) { + return + } + var includeTags = options.includeExifTags + var excludeTags = options.excludeExifTags || { + 0x8769: { + // ExifIFDPointer + 0x927c: true // MakerNote + } + } + var tiffOffset = offset + 10 + var littleEndian + var dirOffset + var thumbnailIFD + // Check for the ASCII code for "Exif" (0x45786966): + if (dataView.getUint32(offset + 4) !== 0x45786966) { + // No Exif data, might be XMP data instead + return + } + if (tiffOffset + 8 > dataView.byteLength) { + console.log('Invalid Exif data: Invalid segment size.') + return + } + // Check for the two null bytes: + if (dataView.getUint16(offset + 8) !== 0x0000) { + console.log('Invalid Exif data: Missing byte alignment offset.') + return + } + // Check the byte alignment: + switch (dataView.getUint16(tiffOffset)) { + case 0x4949: + littleEndian = true + break + case 0x4d4d: + littleEndian = false + break + default: + console.log('Invalid Exif data: Invalid byte alignment marker.') + return + } + // Check for the TIFF tag marker (0x002A): + if (dataView.getUint16(tiffOffset + 2, littleEndian) !== 0x002a) { + console.log('Invalid Exif data: Missing TIFF marker.') + return + } + // Retrieve the directory offset bytes, usually 0x00000008 or 8 decimal: + dirOffset = dataView.getUint32(tiffOffset + 4, littleEndian) + // Create the exif object to store the tags: + data.exif = new ExifMap() + if (!options.disableExifOffsets) { + data.exifOffsets = new ExifMap() + data.exifTiffOffset = tiffOffset + data.exifLittleEndian = littleEndian + } + // Parse the tags of the main image directory (IFD0) and retrieve the + // offset to the next directory (IFD1), usually the thumbnail directory: + dirOffset = parseExifTags( + dataView, + tiffOffset, + tiffOffset + dirOffset, + littleEndian, + data.exif, + data.exifOffsets, + includeTags, + excludeTags + ) + if (dirOffset && shouldIncludeTag(includeTags, excludeTags, 'ifd1')) { + data.exif.ifd1 = dirOffset + if (data.exifOffsets) { + data.exifOffsets.ifd1 = tiffOffset + dirOffset + } + } + Object.keys(data.exif.ifds).forEach(function (tagCode) { + parseExifIFD( + data, + tagCode, + dataView, + tiffOffset, + littleEndian, + includeTags, + excludeTags + ) + }) + thumbnailIFD = data.exif.ifd1 + // Check for JPEG Thumbnail offset and data length: + if (thumbnailIFD && thumbnailIFD[0x0201]) { + thumbnailIFD[0x0201] = getExifThumbnail( + dataView, + tiffOffset + thumbnailIFD[0x0201], + thumbnailIFD[0x0202] // Thumbnail data length + ) + } + } + + // Registers the Exif parser for the APP1 JPEG metadata segment: + loadImage.metaDataParsers.jpeg[0xffe1].push(loadImage.parseExifData) + + loadImage.exifWriters = { + // Orientation writer: + 0x0112: function (buffer, data, value) { + var orientationOffset = data.exifOffsets[0x0112] + if (!orientationOffset) return buffer + var view = new DataView(buffer, orientationOffset + 8, 2) + view.setUint16(0, value, data.exifLittleEndian) + return buffer + } + } + + loadImage.writeExifData = function (buffer, data, id, value) { + loadImage.exifWriters[data.exif.map[id]](buffer, data, value) + } + + loadImage.ExifMap = ExifMap + + // Adds the following properties to the parseMetaData callback data: + // - exif: The parsed Exif tags + // - exifOffsets: The parsed Exif tag offsets + // - exifTiffOffset: TIFF header offset (used for offset pointers) + // - exifLittleEndian: little endian order if true, big endian if false + + // Adds the following options to the parseMetaData method: + // - disableExif: Disables Exif parsing when true. + // - disableExifOffsets: Disables storing Exif tag offsets when true. + // - includeExifTags: A map of Exif tags to include for parsing. + // - excludeExifTags: A map of Exif tags to exclude from parsing. +}) diff --git a/lib/web/jquery/fileUploader/vendor/blueimp-load-image/js/load-image-fetch.js b/lib/web/jquery/fileUploader/vendor/blueimp-load-image/js/load-image-fetch.js new file mode 100644 index 0000000000000..28a28fb83e6cd --- /dev/null +++ b/lib/web/jquery/fileUploader/vendor/blueimp-load-image/js/load-image-fetch.js @@ -0,0 +1,103 @@ +/* + * JavaScript Load Image Fetch + * https://github.com/blueimp/JavaScript-Load-Image + * + * Copyright 2017, Sebastian Tschan + * https://blueimp.net + * + * Licensed under the MIT license: + * https://opensource.org/licenses/MIT + */ + +/* global define, module, require, Promise */ + +;(function (factory) { + 'use strict' + if (typeof define === 'function' && define.amd) { + // Register as an anonymous AMD module: + define(['jquery/fileUploader/vendor/blueimp-load-image/js/load-image'], factory) + } else if (typeof module === 'object' && module.exports) { + factory(require('jquery/fileUploader/vendor/blueimp-load-image/js/load-image')) + } else { + // Browser globals: + factory(window.loadImage) + } +})(function (loadImage) { + 'use strict' + + var global = loadImage.global + + if ( + global.fetch && + global.Request && + global.Response && + global.Response.prototype.blob + ) { + loadImage.fetchBlob = function (url, callback, options) { + /** + * Fetch response handler. + * + * @param {Response} response Fetch response + * @returns {Blob} Fetched Blob. + */ + function responseHandler(response) { + return response.blob() + } + if (global.Promise && typeof callback !== 'function') { + return fetch(new Request(url, callback)).then(responseHandler) + } + fetch(new Request(url, options)) + .then(responseHandler) + .then(callback) + [ + // Avoid parsing error in IE<9, where catch is a reserved word. + // eslint-disable-next-line dot-notation + 'catch' + ](function (err) { + callback(null, err) + }) + } + } else if ( + global.XMLHttpRequest && + // https://xhr.spec.whatwg.org/#the-responsetype-attribute + new XMLHttpRequest().responseType === '' + ) { + loadImage.fetchBlob = function (url, callback, options) { + /** + * Promise executor + * + * @param {Function} resolve Resolution function + * @param {Function} reject Rejection function + */ + function executor(resolve, reject) { + options = options || {} // eslint-disable-line no-param-reassign + var req = new XMLHttpRequest() + req.open(options.method || 'GET', url) + if (options.headers) { + Object.keys(options.headers).forEach(function (key) { + req.setRequestHeader(key, options.headers[key]) + }) + } + req.withCredentials = options.credentials === 'include' + req.responseType = 'blob' + req.onload = function () { + resolve(req.response) + } + req.onerror = req.onabort = req.ontimeout = function (err) { + if (resolve === reject) { + // Not using Promises + reject(null, err) + } else { + reject(err) + } + } + req.send(options.body) + } + if (global.Promise && typeof callback !== 'function') { + options = callback // eslint-disable-line no-param-reassign + return new Promise(executor) + } + return executor(callback, callback) + } + } +}) diff --git a/lib/web/jquery/fileUploader/vendor/blueimp-load-image/js/load-image-iptc-map.js b/lib/web/jquery/fileUploader/vendor/blueimp-load-image/js/load-image-iptc-map.js new file mode 100644 index 0000000000000..cd959a24b3541 --- /dev/null +++ b/lib/web/jquery/fileUploader/vendor/blueimp-load-image/js/load-image-iptc-map.js @@ -0,0 +1,169 @@ +/* + * JavaScript Load Image IPTC Map + * https://github.com/blueimp/JavaScript-Load-Image + * + * Copyright 2013, Sebastian Tschan + * Copyright 2018, Dave Bevan + * + * IPTC tags mapping based on + * https://iptc.org/standards/photo-metadata + * https://exiftool.org/TagNames/IPTC.html + * + * Licensed under the MIT license: + * https://opensource.org/licenses/MIT + */ + +/* global define, module, require */ + +;(function (factory) { + 'use strict' + if (typeof define === 'function' && define.amd) { + // Register as an anonymous AMD module: + define(['jquery/fileUploader/vendor/blueimp-load-image/js/load-image', 'jquery/fileUploader/vendor/blueimp-load-image/js/load-image-iptc'], factory) + } else if (typeof module === 'object' && module.exports) { + factory(require('jquery/fileUploader/vendor/blueimp-load-image/js/load-image'), require('jquery/fileUploader/vendor/blueimp-load-image/js/load-image-iptc')) + } else { + // Browser globals: + factory(window.loadImage) + } +})(function (loadImage) { + 'use strict' + + var IptcMapProto = loadImage.IptcMap.prototype + + IptcMapProto.tags = { + 0: 'ApplicationRecordVersion', + 3: 'ObjectTypeReference', + 4: 'ObjectAttributeReference', + 5: 'ObjectName', + 7: 'EditStatus', + 8: 'EditorialUpdate', + 10: 'Urgency', + 12: 'SubjectReference', + 15: 'Category', + 20: 'SupplementalCategories', + 22: 'FixtureIdentifier', + 25: 'Keywords', + 26: 'ContentLocationCode', + 27: 'ContentLocationName', + 30: 'ReleaseDate', + 35: 'ReleaseTime', + 37: 'ExpirationDate', + 38: 'ExpirationTime', + 40: 'SpecialInstructions', + 42: 'ActionAdvised', + 45: 'ReferenceService', + 47: 'ReferenceDate', + 50: 'ReferenceNumber', + 55: 'DateCreated', + 60: 'TimeCreated', + 62: 'DigitalCreationDate', + 63: 'DigitalCreationTime', + 65: 'OriginatingProgram', + 70: 'ProgramVersion', + 75: 'ObjectCycle', + 80: 'Byline', + 85: 'BylineTitle', + 90: 'City', + 92: 'Sublocation', + 95: 'State', + 100: 'CountryCode', + 101: 'Country', + 103: 'OriginalTransmissionReference', + 105: 'Headline', + 110: 'Credit', + 115: 'Source', + 116: 'CopyrightNotice', + 118: 'Contact', + 120: 'Caption', + 121: 'LocalCaption', + 122: 'Writer', + 125: 'RasterizedCaption', + 130: 'ImageType', + 131: 'ImageOrientation', + 135: 'LanguageIdentifier', + 150: 'AudioType', + 151: 'AudioSamplingRate', + 152: 'AudioSamplingResolution', + 153: 'AudioDuration', + 154: 'AudioOutcue', + 184: 'JobID', + 185: 'MasterDocumentID', + 186: 'ShortDocumentID', + 187: 'UniqueDocumentID', + 188: 'OwnerID', + 200: 'ObjectPreviewFileFormat', + 201: 'ObjectPreviewFileVersion', + 202: 'ObjectPreviewData', + 221: 'Prefs', + 225: 'ClassifyState', + 228: 'SimilarityIndex', + 230: 'DocumentNotes', + 231: 'DocumentHistory', + 232: 'ExifCameraInfo', + 255: 'CatalogSets' + } + + IptcMapProto.stringValues = { + 10: { + 0: '0 (reserved)', + 1: '1 (most urgent)', + 2: '2', + 3: '3', + 4: '4', + 5: '5 (normal urgency)', + 6: '6', + 7: '7', + 8: '8 (least urgent)', + 9: '9 (user-defined priority)' + }, + 75: { + a: 'Morning', + b: 'Both Morning and Evening', + p: 'Evening' + }, + 131: { + L: 'Landscape', + P: 'Portrait', + S: 'Square' + } + } + + IptcMapProto.getText = function (id) { + var value = this.get(id) + var tagCode = this.map[id] + var stringValue = this.stringValues[tagCode] + if (stringValue) return stringValue[value] + return String(value) + } + + IptcMapProto.getAll = function () { + var map = {} + var prop + var name + for (prop in this) { + if (Object.prototype.hasOwnProperty.call(this, prop)) { + name = this.tags[prop] + if (name) map[name] = this.getText(name) + } + } + return map + } + + IptcMapProto.getName = function (tagCode) { + return this.tags[tagCode] + } + + // Extend the map of tag names to tag codes: + ;(function () { + var tags = IptcMapProto.tags + var map = IptcMapProto.map || {} + var prop + // Map the tag names to tags: + for (prop in tags) { + if (Object.prototype.hasOwnProperty.call(tags, prop)) { + map[tags[prop]] = Number(prop) + } + } + })() +}) diff --git a/lib/web/jquery/fileUploader/vendor/blueimp-load-image/js/load-image-iptc.js b/lib/web/jquery/fileUploader/vendor/blueimp-load-image/js/load-image-iptc.js new file mode 100644 index 0000000000000..f6b4594f9e130 --- /dev/null +++ b/lib/web/jquery/fileUploader/vendor/blueimp-load-image/js/load-image-iptc.js @@ -0,0 +1,239 @@ +/* + * JavaScript Load Image IPTC Parser + * https://github.com/blueimp/JavaScript-Load-Image + * + * Copyright 2013, Sebastian Tschan + * Copyright 2018, Dave Bevan + * https://blueimp.net + * + * Licensed under the MIT license: + * https://opensource.org/licenses/MIT + */ + +/* global define, module, require, DataView */ + +;(function (factory) { + 'use strict' + if (typeof define === 'function' && define.amd) { + // Register as an anonymous AMD module: + define(['jquery/fileUploader/vendor/blueimp-load-image/js/load-image', 'jquery/fileUploader/vendor/blueimp-load-image/js/load-image-meta'], factory) + } else if (typeof module === 'object' && module.exports) { + factory(require('jquery/fileUploader/vendor/blueimp-load-image/js/load-image'), require('jquery/fileUploader/vendor/blueimp-load-image/js/load-image-meta')) + } else { + // Browser globals: + factory(window.loadImage) + } +})(function (loadImage) { + 'use strict' + + /** + * IPTC tag map + * + * @name IptcMap + * @class + */ + function IptcMap() {} + + IptcMap.prototype.map = { + ObjectName: 5 + } + + IptcMap.prototype.types = { + 0: 'Uint16', // ApplicationRecordVersion + 200: 'Uint16', // ObjectPreviewFileFormat + 201: 'Uint16', // ObjectPreviewFileVersion + 202: 'binary' // ObjectPreviewData + } + + /** + * Retrieves IPTC tag value + * + * @param {number|string} id IPTC tag code or name + * @returns {object} IPTC tag value + */ + IptcMap.prototype.get = function (id) { + return this[id] || this[this.map[id]] + } + + /** + * Retrieves string for the given DataView and range + * + * @param {DataView} dataView Data view interface + * @param {number} offset Offset start + * @param {number} length Offset length + * @returns {string} String value + */ + function getStringValue(dataView, offset, length) { + var outstr = '' + var end = offset + length + for (var n = offset; n < end; n += 1) { + outstr += String.fromCharCode(dataView.getUint8(n)) + } + return outstr + } + + /** + * Retrieves tag value for the given DataView and range + * + * @param {number} tagCode tag code + * @param {IptcMap} map IPTC tag map + * @param {DataView} dataView Data view interface + * @param {number} offset Range start + * @param {number} length Range length + * @returns {object} Tag value + */ + function getTagValue(tagCode, map, dataView, offset, length) { + if (map.types[tagCode] === 'binary') { + return new Blob([dataView.buffer.slice(offset, offset + length)]) + } + if (map.types[tagCode] === 'Uint16') { + return dataView.getUint16(offset) + } + return getStringValue(dataView, offset, length) + } + + /** + * Combines IPTC value with existing ones. + * + * @param {object} value Existing IPTC field value + * @param {object} newValue New IPTC field value + * @returns {object} Resulting IPTC field value + */ + function combineTagValues(value, newValue) { + if (value === undefined) return newValue + if (value instanceof Array) { + value.push(newValue) + return value + } + return [value, newValue] + } + + /** + * Parses IPTC tags. + * + * @param {DataView} dataView Data view interface + * @param {number} segmentOffset Segment offset + * @param {number} segmentLength Segment length + * @param {object} data Data export object + * @param {object} includeTags Map of tags to include + * @param {object} excludeTags Map of tags to exclude + */ + function parseIptcTags( + dataView, + segmentOffset, + segmentLength, + data, + includeTags, + excludeTags + ) { + var value, tagSize, tagCode + var segmentEnd = segmentOffset + segmentLength + var offset = segmentOffset + while (offset < segmentEnd) { + if ( + dataView.getUint8(offset) === 0x1c && // tag marker + dataView.getUint8(offset + 1) === 0x02 // record number, only handles v2 + ) { + tagCode = dataView.getUint8(offset + 2) + if ( + (!includeTags || includeTags[tagCode]) && + (!excludeTags || !excludeTags[tagCode]) + ) { + tagSize = dataView.getInt16(offset + 3) + value = getTagValue(tagCode, data.iptc, dataView, offset + 5, tagSize) + data.iptc[tagCode] = combineTagValues(data.iptc[tagCode], value) + if (data.iptcOffsets) { + data.iptcOffsets[tagCode] = offset + } + } + } + offset += 1 + } + } + + /** + * Tests if field segment starts at offset. + * + * @param {DataView} dataView Data view interface + * @param {number} offset Segment offset + * @returns {boolean} True if '8BIM' exists at offset + */ + function isSegmentStart(dataView, offset) { + return ( + dataView.getUint32(offset) === 0x3842494d && // Photoshop segment start + dataView.getUint16(offset + 4) === 0x0404 // IPTC segment start + ) + } + + /** + * Returns header length. + * + * @param {DataView} dataView Data view interface + * @param {number} offset Segment offset + * @returns {number} Header length + */ + function getHeaderLength(dataView, offset) { + var length = dataView.getUint8(offset + 7) + if (length % 2 !== 0) length += 1 + // Check for pre photoshop 6 format + if (length === 0) { + // Always 4 + length = 4 + } + return length + } + + loadImage.parseIptcData = function (dataView, offset, length, data, options) { + if (options.disableIptc) { + return + } + var markerLength = offset + length + while (offset + 8 < markerLength) { + if (isSegmentStart(dataView, offset)) { + var headerLength = getHeaderLength(dataView, offset) + var segmentOffset = offset + 8 + headerLength + if (segmentOffset > markerLength) { + // eslint-disable-next-line no-console + console.log('Invalid IPTC data: Invalid segment offset.') + break + } + var segmentLength = dataView.getUint16(offset + 6 + headerLength) + if (offset + segmentLength > markerLength) { + // eslint-disable-next-line no-console + console.log('Invalid IPTC data: Invalid segment size.') + break + } + // Create the iptc object to store the tags: + data.iptc = new IptcMap() + if (!options.disableIptcOffsets) { + data.iptcOffsets = new IptcMap() + } + parseIptcTags( + dataView, + segmentOffset, + segmentLength, + data, + options.includeIptcTags, + options.excludeIptcTags || { 202: true } // ObjectPreviewData + ) + return + } + // eslint-disable-next-line no-param-reassign + offset += 1 + } + } + + // Registers this IPTC parser for the APP13 JPEG metadata segment: + loadImage.metaDataParsers.jpeg[0xffed].push(loadImage.parseIptcData) + + loadImage.IptcMap = IptcMap + + // Adds the following properties to the parseMetaData callback data: + // - iptc: The iptc tags, parsed by the parseIptcData method + + // Adds the following options to the parseMetaData method: + // - disableIptc: Disables IPTC parsing when true. + // - disableIptcOffsets: Disables storing IPTC tag offsets when true. + // - includeIptcTags: A map of IPTC tags to include for parsing. + // - excludeIptcTags: A map of IPTC tags to exclude from parsing. +}) diff --git a/lib/web/jquery/fileUploader/vendor/blueimp-load-image/js/load-image-meta.js b/lib/web/jquery/fileUploader/vendor/blueimp-load-image/js/load-image-meta.js new file mode 100644 index 0000000000000..20a06184c640d --- /dev/null +++ b/lib/web/jquery/fileUploader/vendor/blueimp-load-image/js/load-image-meta.js @@ -0,0 +1,259 @@ +/* + * JavaScript Load Image Meta + * https://github.com/blueimp/JavaScript-Load-Image + * + * Copyright 2013, Sebastian Tschan + * https://blueimp.net + * + * Image metadata handling implementation + * based on the help and contribution of + * Achim Stöhr. + * + * Licensed under the MIT license: + * https://opensource.org/licenses/MIT + */ + +/* global define, module, require, Promise, DataView, Uint8Array, ArrayBuffer */ + +;(function (factory) { + 'use strict' + if (typeof define === 'function' && define.amd) { + // Register as an anonymous AMD module: + define(['jquery/fileUploader/vendor/blueimp-load-image/js/load-image'], factory) + } else if (typeof module === 'object' && module.exports) { + factory(require('jquery/fileUploader/vendor/blueimp-load-image/js/load-image')) + } else { + // Browser globals: + factory(window.loadImage) + } +})(function (loadImage) { + 'use strict' + + var global = loadImage.global + var originalTransform = loadImage.transform + + var blobSlice = + global.Blob && + (Blob.prototype.slice || + Blob.prototype.webkitSlice || + Blob.prototype.mozSlice) + + var bufferSlice = + (global.ArrayBuffer && ArrayBuffer.prototype.slice) || + function (begin, end) { + // Polyfill for IE10, which does not support ArrayBuffer.slice + // eslint-disable-next-line no-param-reassign + end = end || this.byteLength - begin + var arr1 = new Uint8Array(this, begin, end) + var arr2 = new Uint8Array(end) + arr2.set(arr1) + return arr2.buffer + } + + var metaDataParsers = { + jpeg: { + 0xffe1: [], // APP1 marker + 0xffed: [] // APP13 marker + } + } + + /** + * Parses image metadata and calls the callback with an object argument + * with the following property: + * - imageHead: The complete image head as ArrayBuffer + * The options argument accepts an object and supports the following + * properties: + * - maxMetaDataSize: Defines the maximum number of bytes to parse. + * - disableImageHead: Disables creating the imageHead property. + * + * @param {Blob} file Blob object + * @param {Function} [callback] Callback function + * @param {object} [options] Parsing options + * @param {object} [data] Result data object + * @returns {Promise|undefined} Returns Promise if no callback given. + */ + function parseMetaData(file, callback, options, data) { + var that = this + /** + * Promise executor + * + * @param {Function} resolve Resolution function + * @param {Function} reject Rejection function + * @returns {undefined} Undefined + */ + function executor(resolve, reject) { + if ( + !( + global.DataView && + blobSlice && + file && + file.size >= 12 && + file.type === 'image/jpeg' + ) + ) { + // Nothing to parse + return resolve(data) + } + // 256 KiB should contain all EXIF/ICC/IPTC segments: + var maxMetaDataSize = options.maxMetaDataSize || 262144 + if ( + !loadImage.readFile( + blobSlice.call(file, 0, maxMetaDataSize), + function (buffer) { + // Note on endianness: + // Since the marker and length bytes in JPEG files are always + // stored in big endian order, we can leave the endian parameter + // of the DataView methods undefined, defaulting to big endian. + var dataView = new DataView(buffer) + // Check for the JPEG marker (0xffd8): + if (dataView.getUint16(0) !== 0xffd8) { + return reject( + new Error('Invalid JPEG file: Missing JPEG marker.') + ) + } + var offset = 2 + var maxOffset = dataView.byteLength - 4 + var headLength = offset + var markerBytes + var markerLength + var parsers + var i + while (offset < maxOffset) { + markerBytes = dataView.getUint16(offset) + // Search for APPn (0xffeN) and COM (0xfffe) markers, + // which contain application-specific metadata like + // Exif, ICC and IPTC data and text comments: + if ( + (markerBytes >= 0xffe0 && markerBytes <= 0xffef) || + markerBytes === 0xfffe + ) { + // The marker bytes (2) are always followed by + // the length bytes (2), indicating the length of the + // marker segment, which includes the length bytes, + // but not the marker bytes, so we add 2: + markerLength = dataView.getUint16(offset + 2) + 2 + if (offset + markerLength > dataView.byteLength) { + // eslint-disable-next-line no-console + console.log('Invalid JPEG metadata: Invalid segment size.') + break + } + parsers = metaDataParsers.jpeg[markerBytes] + if (parsers && !options.disableMetaDataParsers) { + for (i = 0; i < parsers.length; i += 1) { + parsers[i].call( + that, + dataView, + offset, + markerLength, + data, + options + ) + } + } + offset += markerLength + headLength = offset + } else { + // Not an APPn or COM marker, probably safe to + // assume that this is the end of the metadata + break + } + } + // Meta length must be longer than JPEG marker (2) + // plus APPn marker (2), followed by length bytes (2): + if (!options.disableImageHead && headLength > 6) { + data.imageHead = bufferSlice.call(buffer, 0, headLength) + } + resolve(data) + }, + reject, + 'readAsArrayBuffer' + ) + ) { + // No support for the FileReader interface, nothing to parse + resolve(data) + } + } + options = options || {} // eslint-disable-line no-param-reassign + if (global.Promise && typeof callback !== 'function') { + options = callback || {} // eslint-disable-line no-param-reassign + data = options // eslint-disable-line no-param-reassign + return new Promise(executor) + } + data = data || {} // eslint-disable-line no-param-reassign + return executor(callback, callback) + } + + /** + * Replaces the head of a JPEG Blob + * + * @param {Blob} blob Blob object + * @param {ArrayBuffer} oldHead Old JPEG head + * @param {ArrayBuffer} newHead New JPEG head + * @returns {Blob} Combined Blob + */ + function replaceJPEGHead(blob, oldHead, newHead) { + if (!blob || !oldHead || !newHead) return null + return new Blob([newHead, blobSlice.call(blob, oldHead.byteLength)], { + type: 'image/jpeg' + }) + } + + /** + * Replaces the image head of a JPEG blob with the given one. + * Returns a Promise or calls the callback with the new Blob. + * + * @param {Blob} blob Blob object + * @param {ArrayBuffer} head New JPEG head + * @param {Function} [callback] Callback function + * @returns {Promise|undefined} Combined Blob + */ + function replaceHead(blob, head, callback) { + var options = { maxMetaDataSize: 256, disableMetaDataParsers: true } + if (!callback && global.Promise) { + return parseMetaData(blob, options).then(function (data) { + return replaceJPEGHead(blob, data.imageHead, head) + }) + } + parseMetaData( + blob, + function (data) { + callback(replaceJPEGHead(blob, data.imageHead, head)) + }, + options + ) + } + + loadImage.transform = function (img, options, callback, file, data) { + if (loadImage.requiresMetaData(options)) { + data = data || {} // eslint-disable-line no-param-reassign + parseMetaData( + file, + function (result) { + if (result !== data) { + // eslint-disable-next-line no-console + if (global.console) console.log(result) + result = data // eslint-disable-line no-param-reassign + } + originalTransform.call( + loadImage, + img, + options, + callback, + file, + result + ) + }, + options, + data + ) + } else { + originalTransform.apply(loadImage, arguments) + } + } + + loadImage.blobSlice = blobSlice + loadImage.bufferSlice = bufferSlice + loadImage.replaceHead = replaceHead + loadImage.parseMetaData = parseMetaData + loadImage.metaDataParsers = metaDataParsers +}) diff --git a/lib/web/jquery/fileUploader/vendor/blueimp-load-image/js/load-image-orientation.js b/lib/web/jquery/fileUploader/vendor/blueimp-load-image/js/load-image-orientation.js new file mode 100644 index 0000000000000..2b32a368e5f54 --- /dev/null +++ b/lib/web/jquery/fileUploader/vendor/blueimp-load-image/js/load-image-orientation.js @@ -0,0 +1,481 @@ +/* + * JavaScript Load Image Orientation + * https://github.com/blueimp/JavaScript-Load-Image + * + * Copyright 2013, Sebastian Tschan + * https://blueimp.net + * + * Licensed under the MIT license: + * https://opensource.org/licenses/MIT + */ + +/* +Exif orientation values to correctly display the letter F: + + 1 2 + ██████ ██████ + ██ ██ + ████ ████ + ██ ██ + ██ ██ + + 3 4 + ██ ██ + ██ ██ + ████ ████ + ██ ██ + ██████ ██████ + + 5 6 +██████████ ██ +██ ██ ██ ██ +██ ██████████ + + 7 8 + ██ ██████████ + ██ ██ ██ ██ +██████████ ██ + +*/ + +/* global define, module, require */ + +;(function (factory) { + 'use strict' + if (typeof define === 'function' && define.amd) { + // Register as an anonymous AMD module: + define(['jquery/fileUploader/vendor/blueimp-load-image/js/load-image', 'jquery/fileUploader/vendor/blueimp-load-image/js/load-image-scale', 'jquery/fileUploader/vendor/blueimp-load-image/js/load-image-meta'], factory) + } else if (typeof module === 'object' && module.exports) { + factory( + require('jquery/fileUploader/vendor/blueimp-load-image/js/load-image'), + require('jquery/fileUploader/vendor/blueimp-load-image/js/load-image-scale'), + require('jquery/fileUploader/vendor/blueimp-load-image/js/load-image-meta') + ) + } else { + // Browser globals: + factory(window.loadImage) + } +})(function (loadImage) { + 'use strict' + + var originalTransform = loadImage.transform + var originalRequiresCanvas = loadImage.requiresCanvas + var originalRequiresMetaData = loadImage.requiresMetaData + var originalTransformCoordinates = loadImage.transformCoordinates + var originalGetTransformedOptions = loadImage.getTransformedOptions + + ;(function ($) { + // Guard for non-browser environments (e.g. server-side rendering): + if (!$.global.document) return + // black+white 3x2 JPEG, with the following meta information set: + // - EXIF Orientation: 6 (Rotated 90° CCW) + // Image data layout (B=black, F=white): + // BFF + // BBB + var testImageURL = + 'data:image/jpeg;base64,/9j/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAYAAAA' + + 'AAAD/2wCEAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBA' + + 'QEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE' + + 'BAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAf/AABEIAAIAAwMBEQACEQEDEQH/x' + + 'ABRAAEAAAAAAAAAAAAAAAAAAAAKEAEBAQADAQEAAAAAAAAAAAAGBQQDCAkCBwEBAAAAAAA' + + 'AAAAAAAAAAAAAABEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AG8T9NfSMEVMhQ' + + 'voP3fFiRZ+MTHDifa/95OFSZU5OzRzxkyejv8ciEfhSceSXGjS8eSdLnZc2HDm4M3BxcXw' + + 'H/9k=' + var img = document.createElement('img') + img.onload = function () { + // Check if the browser supports automatic image orientation: + $.orientation = img.width === 2 && img.height === 3 + if ($.orientation) { + var canvas = $.createCanvas(1, 1, true) + var ctx = canvas.getContext('2d') + ctx.drawImage(img, 1, 1, 1, 1, 0, 0, 1, 1) + // Check if the source image coordinates (sX, sY, sWidth, sHeight) are + // correctly applied to the auto-orientated image, which should result + // in a white opaque pixel (e.g. in Safari). + // Browsers that show a transparent pixel (e.g. Chromium) fail to crop + // auto-oriented images correctly and require a workaround, e.g. + // drawing the complete source image to an intermediate canvas first. + // See https://bugs.chromium.org/p/chromium/issues/detail?id=1074354 + $.orientationCropBug = + ctx.getImageData(0, 0, 1, 1).data.toString() !== '255,255,255,255' + } + } + img.src = testImageURL + })(loadImage) + + /** + * Determines if the orientation requires a canvas element. + * + * @param {object} [options] Options object + * @param {boolean} [withMetaData] Is metadata required for orientation + * @returns {boolean} Returns true if orientation requires canvas/meta + */ + function requiresCanvasOrientation(options, withMetaData) { + var orientation = options && options.orientation + return ( + // Exif orientation for browsers without automatic image orientation: + (orientation === true && !loadImage.orientation) || + // Orientation reset for browsers with automatic image orientation: + (orientation === 1 && loadImage.orientation) || + // Orientation to defined value, requires meta for orientation reset only: + ((!withMetaData || loadImage.orientation) && + orientation > 1 && + orientation < 9) + ) + } + + /** + * Determines if the image requires an orientation change. + * + * @param {number} [orientation] Defined orientation value + * @param {number} [autoOrientation] Auto-orientation based on Exif data + * @returns {boolean} Returns true if an orientation change is required + */ + function requiresOrientationChange(orientation, autoOrientation) { + return ( + orientation !== autoOrientation && + ((orientation === 1 && autoOrientation > 1 && autoOrientation < 9) || + (orientation > 1 && orientation < 9)) + ) + } + + /** + * Determines orientation combinations that require a rotation by 180°. + * + * The following is a list of combinations that return true: + * + * 2 (flip) => 5 (rot90,flip), 7 (rot90,flip), 6 (rot90), 8 (rot90) + * 4 (flip) => 5 (rot90,flip), 7 (rot90,flip), 6 (rot90), 8 (rot90) + * + * 5 (rot90,flip) => 2 (flip), 4 (flip), 6 (rot90), 8 (rot90) + * 7 (rot90,flip) => 2 (flip), 4 (flip), 6 (rot90), 8 (rot90) + * + * 6 (rot90) => 2 (flip), 4 (flip), 5 (rot90,flip), 7 (rot90,flip) + * 8 (rot90) => 2 (flip), 4 (flip), 5 (rot90,flip), 7 (rot90,flip) + * + * @param {number} [orientation] Defined orientation value + * @param {number} [autoOrientation] Auto-orientation based on Exif data + * @returns {boolean} Returns true if rotation by 180° is required + */ + function requiresRot180(orientation, autoOrientation) { + if (autoOrientation > 1 && autoOrientation < 9) { + switch (orientation) { + case 2: + case 4: + return autoOrientation > 4 + case 5: + case 7: + return autoOrientation % 2 === 0 + case 6: + case 8: + return ( + autoOrientation === 2 || + autoOrientation === 4 || + autoOrientation === 5 || + autoOrientation === 7 + ) + } + } + return false + } + + // Determines if the target image should be a canvas element: + loadImage.requiresCanvas = function (options) { + return ( + requiresCanvasOrientation(options) || + originalRequiresCanvas.call(loadImage, options) + ) + } + + // Determines if metadata should be loaded automatically: + loadImage.requiresMetaData = function (options) { + return ( + requiresCanvasOrientation(options, true) || + originalRequiresMetaData.call(loadImage, options) + ) + } + + loadImage.transform = function (img, options, callback, file, data) { + originalTransform.call( + loadImage, + img, + options, + function (img, data) { + if (data) { + var autoOrientation = + loadImage.orientation && data.exif && data.exif.get('Orientation') + if (autoOrientation > 4 && autoOrientation < 9) { + // Automatic image orientation switched image dimensions + var originalWidth = data.originalWidth + var originalHeight = data.originalHeight + data.originalWidth = originalHeight + data.originalHeight = originalWidth + } + } + callback(img, data) + }, + file, + data + ) + } + + // Transforms coordinate and dimension options + // based on the given orientation option: + loadImage.getTransformedOptions = function (img, opts, data) { + var options = originalGetTransformedOptions.call(loadImage, img, opts) + var exifOrientation = data.exif && data.exif.get('Orientation') + var orientation = options.orientation + var autoOrientation = loadImage.orientation && exifOrientation + if (orientation === true) orientation = exifOrientation + if (!requiresOrientationChange(orientation, autoOrientation)) { + return options + } + var top = options.top + var right = options.right + var bottom = options.bottom + var left = options.left + var newOptions = {} + for (var i in options) { + if (Object.prototype.hasOwnProperty.call(options, i)) { + newOptions[i] = options[i] + } + } + newOptions.orientation = orientation + if ( + (orientation > 4 && !(autoOrientation > 4)) || + (orientation < 5 && autoOrientation > 4) + ) { + // Image dimensions and target dimensions are switched + newOptions.maxWidth = options.maxHeight + newOptions.maxHeight = options.maxWidth + newOptions.minWidth = options.minHeight + newOptions.minHeight = options.minWidth + newOptions.sourceWidth = options.sourceHeight + newOptions.sourceHeight = options.sourceWidth + } + if (autoOrientation > 1) { + // Browsers which correctly apply source image coordinates to + // auto-oriented images + switch (autoOrientation) { + case 2: + // Horizontal flip + right = options.left + left = options.right + break + case 3: + // 180° Rotate CCW + top = options.bottom + right = options.left + bottom = options.top + left = options.right + break + case 4: + // Vertical flip + top = options.bottom + bottom = options.top + break + case 5: + // Horizontal flip + 90° Rotate CCW + top = options.left + right = options.bottom + bottom = options.right + left = options.top + break + case 6: + // 90° Rotate CCW + top = options.left + right = options.top + bottom = options.right + left = options.bottom + break + case 7: + // Vertical flip + 90° Rotate CCW + top = options.right + right = options.top + bottom = options.left + left = options.bottom + break + case 8: + // 90° Rotate CW + top = options.right + right = options.bottom + bottom = options.left + left = options.top + break + } + // Some orientation combinations require additional rotation by 180°: + if (requiresRot180(orientation, autoOrientation)) { + var tmpTop = top + var tmpRight = right + top = bottom + right = left + bottom = tmpTop + left = tmpRight + } + } + newOptions.top = top + newOptions.right = right + newOptions.bottom = bottom + newOptions.left = left + // Account for defined browser orientation: + switch (orientation) { + case 2: + // Horizontal flip + newOptions.right = left + newOptions.left = right + break + case 3: + // 180° Rotate CCW + newOptions.top = bottom + newOptions.right = left + newOptions.bottom = top + newOptions.left = right + break + case 4: + // Vertical flip + newOptions.top = bottom + newOptions.bottom = top + break + case 5: + // Vertical flip + 90° Rotate CW + newOptions.top = left + newOptions.right = bottom + newOptions.bottom = right + newOptions.left = top + break + case 6: + // 90° Rotate CW + newOptions.top = right + newOptions.right = bottom + newOptions.bottom = left + newOptions.left = top + break + case 7: + // Horizontal flip + 90° Rotate CW + newOptions.top = right + newOptions.right = top + newOptions.bottom = left + newOptions.left = bottom + break + case 8: + // 90° Rotate CCW + newOptions.top = left + newOptions.right = top + newOptions.bottom = right + newOptions.left = bottom + break + } + return newOptions + } + + // Transform image orientation based on the given EXIF orientation option: + loadImage.transformCoordinates = function (canvas, options, data) { + originalTransformCoordinates.call(loadImage, canvas, options, data) + var orientation = options.orientation + var autoOrientation = + loadImage.orientation && data.exif && data.exif.get('Orientation') + if (!requiresOrientationChange(orientation, autoOrientation)) { + return + } + var ctx = canvas.getContext('2d') + var width = canvas.width + var height = canvas.height + var sourceWidth = width + var sourceHeight = height + if ( + (orientation > 4 && !(autoOrientation > 4)) || + (orientation < 5 && autoOrientation > 4) + ) { + // Image dimensions and target dimensions are switched + canvas.width = height + canvas.height = width + } + if (orientation > 4) { + // Destination and source dimensions are switched + sourceWidth = height + sourceHeight = width + } + // Reset automatic browser orientation: + switch (autoOrientation) { + case 2: + // Horizontal flip + ctx.translate(sourceWidth, 0) + ctx.scale(-1, 1) + break + case 3: + // 180° Rotate CCW + ctx.translate(sourceWidth, sourceHeight) + ctx.rotate(Math.PI) + break + case 4: + // Vertical flip + ctx.translate(0, sourceHeight) + ctx.scale(1, -1) + break + case 5: + // Horizontal flip + 90° Rotate CCW + ctx.rotate(-0.5 * Math.PI) + ctx.scale(-1, 1) + break + case 6: + // 90° Rotate CCW + ctx.rotate(-0.5 * Math.PI) + ctx.translate(-sourceWidth, 0) + break + case 7: + // Vertical flip + 90° Rotate CCW + ctx.rotate(-0.5 * Math.PI) + ctx.translate(-sourceWidth, sourceHeight) + ctx.scale(1, -1) + break + case 8: + // 90° Rotate CW + ctx.rotate(0.5 * Math.PI) + ctx.translate(0, -sourceHeight) + break + } + // Some orientation combinations require additional rotation by 180°: + if (requiresRot180(orientation, autoOrientation)) { + ctx.translate(sourceWidth, sourceHeight) + ctx.rotate(Math.PI) + } + switch (orientation) { + case 2: + // Horizontal flip + ctx.translate(width, 0) + ctx.scale(-1, 1) + break + case 3: + // 180° Rotate CCW + ctx.translate(width, height) + ctx.rotate(Math.PI) + break + case 4: + // Vertical flip + ctx.translate(0, height) + ctx.scale(1, -1) + break + case 5: + // Vertical flip + 90° Rotate CW + ctx.rotate(0.5 * Math.PI) + ctx.scale(1, -1) + break + case 6: + // 90° Rotate CW + ctx.rotate(0.5 * Math.PI) + ctx.translate(0, -height) + break + case 7: + // Horizontal flip + 90° Rotate CW + ctx.rotate(0.5 * Math.PI) + ctx.translate(width, -height) + ctx.scale(-1, 1) + break + case 8: + // 90° Rotate CCW + ctx.rotate(-0.5 * Math.PI) + ctx.translate(-width, 0) + break + } + } +}) diff --git a/lib/web/jquery/fileUploader/vendor/blueimp-load-image/js/load-image-scale.js b/lib/web/jquery/fileUploader/vendor/blueimp-load-image/js/load-image-scale.js new file mode 100644 index 0000000000000..80cc5e544fecb --- /dev/null +++ b/lib/web/jquery/fileUploader/vendor/blueimp-load-image/js/load-image-scale.js @@ -0,0 +1,327 @@ +/* + * JavaScript Load Image Scaling + * https://github.com/blueimp/JavaScript-Load-Image + * + * Copyright 2011, Sebastian Tschan + * https://blueimp.net + * + * Licensed under the MIT license: + * https://opensource.org/licenses/MIT + */ + +/* global define, module, require */ + +;(function (factory) { + 'use strict' + if (typeof define === 'function' && define.amd) { + // Register as an anonymous AMD module: + define(['jquery/fileUploader/vendor/blueimp-load-image/js/load-image'], factory) + } else if (typeof module === 'object' && module.exports) { + factory(require('jquery/fileUploader/vendor/blueimp-load-image/js/load-image')) + } else { + // Browser globals: + factory(window.loadImage) + } +})(function (loadImage) { + 'use strict' + + var originalTransform = loadImage.transform + + loadImage.createCanvas = function (width, height, offscreen) { + if (offscreen && loadImage.global.OffscreenCanvas) { + return new OffscreenCanvas(width, height) + } + var canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + return canvas + } + + loadImage.transform = function (img, options, callback, file, data) { + originalTransform.call( + loadImage, + loadImage.scale(img, options, data), + options, + callback, + file, + data + ) + } + + // Transform image coordinates, allows to override e.g. + // the canvas orientation based on the orientation option, + // gets canvas, options and data passed as arguments: + loadImage.transformCoordinates = function () {} + + // Returns transformed options, allows to override e.g. + // maxWidth, maxHeight and crop options based on the aspectRatio. + // gets img, options, data passed as arguments: + loadImage.getTransformedOptions = function (img, options) { + var aspectRatio = options.aspectRatio + var newOptions + var i + var width + var height + if (!aspectRatio) { + return options + } + newOptions = {} + for (i in options) { + if (Object.prototype.hasOwnProperty.call(options, i)) { + newOptions[i] = options[i] + } + } + newOptions.crop = true + width = img.naturalWidth || img.width + height = img.naturalHeight || img.height + if (width / height > aspectRatio) { + newOptions.maxWidth = height * aspectRatio + newOptions.maxHeight = height + } else { + newOptions.maxWidth = width + newOptions.maxHeight = width / aspectRatio + } + return newOptions + } + + // Canvas render method, allows to implement a different rendering algorithm: + loadImage.drawImage = function ( + img, + canvas, + sourceX, + sourceY, + sourceWidth, + sourceHeight, + destWidth, + destHeight, + options + ) { + var ctx = canvas.getContext('2d') + if (options.imageSmoothingEnabled === false) { + ctx.msImageSmoothingEnabled = false + ctx.imageSmoothingEnabled = false + } else if (options.imageSmoothingQuality) { + ctx.imageSmoothingQuality = options.imageSmoothingQuality + } + ctx.drawImage( + img, + sourceX, + sourceY, + sourceWidth, + sourceHeight, + 0, + 0, + destWidth, + destHeight + ) + return ctx + } + + // Determines if the target image should be a canvas element: + loadImage.requiresCanvas = function (options) { + return options.canvas || options.crop || !!options.aspectRatio + } + + // Scales and/or crops the given image (img or canvas HTML element) + // using the given options: + loadImage.scale = function (img, options, data) { + // eslint-disable-next-line no-param-reassign + options = options || {} + // eslint-disable-next-line no-param-reassign + data = data || {} + var useCanvas = + img.getContext || + (loadImage.requiresCanvas(options) && + !!loadImage.global.HTMLCanvasElement) + var width = img.naturalWidth || img.width + var height = img.naturalHeight || img.height + var destWidth = width + var destHeight = height + var maxWidth + var maxHeight + var minWidth + var minHeight + var sourceWidth + var sourceHeight + var sourceX + var sourceY + var pixelRatio + var downsamplingRatio + var tmp + var canvas + /** + * Scales up image dimensions + */ + function scaleUp() { + var scale = Math.max( + (minWidth || destWidth) / destWidth, + (minHeight || destHeight) / destHeight + ) + if (scale > 1) { + destWidth *= scale + destHeight *= scale + } + } + /** + * Scales down image dimensions + */ + function scaleDown() { + var scale = Math.min( + (maxWidth || destWidth) / destWidth, + (maxHeight || destHeight) / destHeight + ) + if (scale < 1) { + destWidth *= scale + destHeight *= scale + } + } + if (useCanvas) { + // eslint-disable-next-line no-param-reassign + options = loadImage.getTransformedOptions(img, options, data) + sourceX = options.left || 0 + sourceY = options.top || 0 + if (options.sourceWidth) { + sourceWidth = options.sourceWidth + if (options.right !== undefined && options.left === undefined) { + sourceX = width - sourceWidth - options.right + } + } else { + sourceWidth = width - sourceX - (options.right || 0) + } + if (options.sourceHeight) { + sourceHeight = options.sourceHeight + if (options.bottom !== undefined && options.top === undefined) { + sourceY = height - sourceHeight - options.bottom + } + } else { + sourceHeight = height - sourceY - (options.bottom || 0) + } + destWidth = sourceWidth + destHeight = sourceHeight + } + maxWidth = options.maxWidth + maxHeight = options.maxHeight + minWidth = options.minWidth + minHeight = options.minHeight + if (useCanvas && maxWidth && maxHeight && options.crop) { + destWidth = maxWidth + destHeight = maxHeight + tmp = sourceWidth / sourceHeight - maxWidth / maxHeight + if (tmp < 0) { + sourceHeight = (maxHeight * sourceWidth) / maxWidth + if (options.top === undefined && options.bottom === undefined) { + sourceY = (height - sourceHeight) / 2 + } + } else if (tmp > 0) { + sourceWidth = (maxWidth * sourceHeight) / maxHeight + if (options.left === undefined && options.right === undefined) { + sourceX = (width - sourceWidth) / 2 + } + } + } else { + if (options.contain || options.cover) { + minWidth = maxWidth = maxWidth || minWidth + minHeight = maxHeight = maxHeight || minHeight + } + if (options.cover) { + scaleDown() + scaleUp() + } else { + scaleUp() + scaleDown() + } + } + if (useCanvas) { + pixelRatio = options.pixelRatio + if ( + pixelRatio > 1 && + // Check if the image has not yet had the device pixel ratio applied: + !( + img.style.width && + Math.floor(parseFloat(img.style.width, 10)) === + Math.floor(width / pixelRatio) + ) + ) { + destWidth *= pixelRatio + destHeight *= pixelRatio + } + // Check if workaround for Chromium orientation crop bug is required: + // https://bugs.chromium.org/p/chromium/issues/detail?id=1074354 + if ( + loadImage.orientationCropBug && + !img.getContext && + (sourceX || sourceY || sourceWidth !== width || sourceHeight !== height) + ) { + // Write the complete source image to an intermediate canvas first: + tmp = img + // eslint-disable-next-line no-param-reassign + img = loadImage.createCanvas(width, height, true) + loadImage.drawImage( + tmp, + img, + 0, + 0, + width, + height, + width, + height, + options + ) + } + downsamplingRatio = options.downsamplingRatio + if ( + downsamplingRatio > 0 && + downsamplingRatio < 1 && + destWidth < sourceWidth && + destHeight < sourceHeight + ) { + while (sourceWidth * downsamplingRatio > destWidth) { + canvas = loadImage.createCanvas( + sourceWidth * downsamplingRatio, + sourceHeight * downsamplingRatio, + true + ) + loadImage.drawImage( + img, + canvas, + sourceX, + sourceY, + sourceWidth, + sourceHeight, + canvas.width, + canvas.height, + options + ) + sourceX = 0 + sourceY = 0 + sourceWidth = canvas.width + sourceHeight = canvas.height + // eslint-disable-next-line no-param-reassign + img = canvas + } + } + canvas = loadImage.createCanvas(destWidth, destHeight) + loadImage.transformCoordinates(canvas, options, data) + if (pixelRatio > 1) { + canvas.style.width = canvas.width / pixelRatio + 'px' + } + loadImage + .drawImage( + img, + canvas, + sourceX, + sourceY, + sourceWidth, + sourceHeight, + destWidth, + destHeight, + options + ) + .setTransform(1, 0, 0, 1, 0, 0) // reset to the identity matrix + return canvas + } + img.width = destWidth + img.height = destHeight + return img + } +}) diff --git a/lib/web/jquery/fileUploader/vendor/blueimp-load-image/js/load-image.js b/lib/web/jquery/fileUploader/vendor/blueimp-load-image/js/load-image.js new file mode 100644 index 0000000000000..27387fbd13d76 --- /dev/null +++ b/lib/web/jquery/fileUploader/vendor/blueimp-load-image/js/load-image.js @@ -0,0 +1,229 @@ +/* + * JavaScript Load Image + * https://github.com/blueimp/JavaScript-Load-Image + * + * Copyright 2011, Sebastian Tschan + * https://blueimp.net + * + * Licensed under the MIT license: + * https://opensource.org/licenses/MIT + */ + +/* global define, module, Promise */ + +;(function ($) { + 'use strict' + + var urlAPI = $.URL || $.webkitURL + + /** + * Creates an object URL for a given File object. + * + * @param {Blob} blob Blob object + * @returns {string|boolean} Returns object URL if API exists, else false. + */ + function createObjectURL(blob) { + return urlAPI ? urlAPI.createObjectURL(blob) : false + } + + /** + * Revokes a given object URL. + * + * @param {string} url Blob object URL + * @returns {undefined|boolean} Returns undefined if API exists, else false. + */ + function revokeObjectURL(url) { + return urlAPI ? urlAPI.revokeObjectURL(url) : false + } + + /** + * Helper function to revoke an object URL + * + * @param {string} url Blob Object URL + * @param {object} [options] Options object + */ + function revokeHelper(url, options) { + if (url && url.slice(0, 5) === 'blob:' && !(options && options.noRevoke)) { + revokeObjectURL(url) + } + } + + /** + * Loads a given File object via FileReader interface. + * + * @param {Blob} file Blob object + * @param {Function} onload Load event callback + * @param {Function} [onerror] Error/Abort event callback + * @param {string} [method=readAsDataURL] FileReader method + * @returns {FileReader|boolean} Returns FileReader if API exists, else false. + */ + function readFile(file, onload, onerror, method) { + if (!$.FileReader) return false + var reader = new FileReader() + reader.onload = function () { + onload.call(reader, this.result) + } + if (onerror) { + reader.onabort = reader.onerror = function () { + onerror.call(reader, this.error) + } + } + var readerMethod = reader[method || 'readAsDataURL'] + if (readerMethod) { + readerMethod.call(reader, file) + return reader + } + } + + /** + * Cross-frame instanceof check. + * + * @param {string} type Instance type + * @param {object} obj Object instance + * @returns {boolean} Returns true if the object is of the given instance. + */ + function isInstanceOf(type, obj) { + // Cross-frame instanceof check + return Object.prototype.toString.call(obj) === '[object ' + type + ']' + } + + /** + * @typedef { HTMLImageElement|HTMLCanvasElement } Result + */ + + /** + * Loads an image for a given File object. + * + * @param {Blob|string} file Blob object or image URL + * @param {Function|object} [callback] Image load event callback or options + * @param {object} [options] Options object + * @returns {HTMLImageElement|FileReader|Promise} Object + */ + function loadImage(file, callback, options) { + /** + * Promise executor + * + * @param {Function} resolve Resolution function + * @param {Function} reject Rejection function + * @returns {HTMLImageElement|FileReader} Object + */ + function executor(resolve, reject) { + var img = document.createElement('img') + var url + /** + * Callback for the fetchBlob call. + * + * @param {HTMLImageElement|HTMLCanvasElement} img Error object + * @param {object} data Data object + * @returns {undefined} Undefined + */ + function resolveWrapper(img, data) { + if (resolve === reject) { + // Not using Promises + if (resolve) resolve(img, data) + return + } else if (img instanceof Error) { + reject(img) + return + } + data = data || {} // eslint-disable-line no-param-reassign + data.image = img + resolve(data) + } + /** + * Callback for the fetchBlob call. + * + * @param {Blob} blob Blob object + * @param {Error} err Error object + */ + function fetchBlobCallback(blob, err) { + if (err && $.console) console.log(err) // eslint-disable-line no-console + if (blob && isInstanceOf('Blob', blob)) { + file = blob // eslint-disable-line no-param-reassign + url = createObjectURL(file) + } else { + url = file + if (options && options.crossOrigin) { + img.crossOrigin = options.crossOrigin + } + } + img.src = url + } + img.onerror = function (event) { + revokeHelper(url, options) + if (reject) reject.call(img, event) + } + img.onload = function () { + revokeHelper(url, options) + var data = { + originalWidth: img.naturalWidth || img.width, + originalHeight: img.naturalHeight || img.height + } + try { + loadImage.transform(img, options, resolveWrapper, file, data) + } catch (error) { + if (reject) reject(error) + } + } + if (typeof file === 'string') { + if (loadImage.requiresMetaData(options)) { + loadImage.fetchBlob(file, fetchBlobCallback, options) + } else { + fetchBlobCallback() + } + return img + } else if (isInstanceOf('Blob', file) || isInstanceOf('File', file)) { + url = createObjectURL(file) + if (url) { + img.src = url + return img + } + return readFile( + file, + function (url) { + img.src = url + }, + reject + ) + } + } + if ($.Promise && typeof callback !== 'function') { + options = callback // eslint-disable-line no-param-reassign + return new Promise(executor) + } + return executor(callback, callback) + } + + // Determines if metadata should be loaded automatically. + // Requires the load image meta extension to load metadata. + loadImage.requiresMetaData = function (options) { + return options && options.meta + } + + // If the callback given to this function returns a blob, it is used as image + // source instead of the original url and overrides the file argument used in + // the onload and onerror event callbacks: + loadImage.fetchBlob = function (url, callback) { + callback() + } + + loadImage.transform = function (img, options, callback, file, data) { + callback(img, data) + } + + loadImage.global = $ + loadImage.readFile = readFile + loadImage.isInstanceOf = isInstanceOf + loadImage.createObjectURL = createObjectURL + loadImage.revokeObjectURL = revokeObjectURL + + if (typeof define === 'function' && define.amd) { + define(function () { + return loadImage + }) + } else if (typeof module === 'object' && module.exports) { + module.exports = loadImage + } else { + $.loadImage = loadImage + } +})((typeof window !== 'undefined' && window) || this) diff --git a/lib/web/jquery/fileUploader/vendor/blueimp-tmpl/LICENSE.txt b/lib/web/jquery/fileUploader/vendor/blueimp-tmpl/LICENSE.txt new file mode 100644 index 0000000000000..d6a9d74758be3 --- /dev/null +++ b/lib/web/jquery/fileUploader/vendor/blueimp-tmpl/LICENSE.txt @@ -0,0 +1,20 @@ +MIT License + +Copyright © 2011 Sebastian Tschan, https://blueimp.net + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/lib/web/jquery/fileUploader/vendor/blueimp-tmpl/README.md b/lib/web/jquery/fileUploader/vendor/blueimp-tmpl/README.md new file mode 100644 index 0000000000000..d8281b237ca1f --- /dev/null +++ b/lib/web/jquery/fileUploader/vendor/blueimp-tmpl/README.md @@ -0,0 +1,436 @@ +# JavaScript Templates + +## Contents + +- [Demo](https://blueimp.github.io/JavaScript-Templates/) +- [Description](#description) +- [Usage](#usage) + - [Client-side](#client-side) + - [Server-side](#server-side) +- [Requirements](#requirements) +- [API](#api) + - [tmpl() function](#tmpl-function) + - [Templates cache](#templates-cache) + - [Output encoding](#output-encoding) + - [Local helper variables](#local-helper-variables) + - [Template function argument](#template-function-argument) + - [Template parsing](#template-parsing) +- [Templates syntax](#templates-syntax) + - [Interpolation](#interpolation) + - [Evaluation](#evaluation) +- [Compiled templates](#compiled-templates) +- [Tests](#tests) +- [License](#license) + +## Description + +1KB lightweight, fast & powerful JavaScript templating engine with zero +dependencies. +Compatible with server-side environments like [Node.js](https://nodejs.org/), +module loaders like [RequireJS](https://requirejs.org/) or +[webpack](https://webpack.js.org/) and all web browsers. + +## Usage + +### Client-side + +Install the **blueimp-tmpl** package with [NPM](https://www.npmjs.org/): + +```sh +npm install blueimp-tmpl +``` + +Include the (minified) JavaScript Templates script in your HTML markup: + +```html + +``` + +Add a script section with type **"text/x-tmpl"**, a unique **id** property and +your template definition as content: + +```html + +``` + +**"o"** (the lowercase letter) is a reference to the data parameter of the +template function (see the API section on how to modify this identifier). + +In your application code, create a JavaScript object to use as data for the +template: + +```js +var data = { + title: 'JavaScript Templates', + license: { + name: 'MIT license', + url: 'https://opensource.org/licenses/MIT' + }, + features: ['lightweight & fast', 'powerful', 'zero dependencies'] +} +``` + +In a real application, this data could be the result of retrieving a +[JSON](https://json.org/) resource. + +Render the result by calling the **tmpl()** method with the id of the template +and the data object as arguments: + +```js +document.getElementById('result').innerHTML = tmpl('tmpl-demo', data) +``` + +### Server-side + +The following is an example how to use the JavaScript Templates engine on the +server-side with [Node.js](https://nodejs.org/). + +Install the **blueimp-tmpl** package with [NPM](https://www.npmjs.org/): + +```sh +npm install blueimp-tmpl +``` + +Add a file **template.html** with the following content: + +```html + +{%=o.title%} +

{%=o.title%}

+

Features

+
    +{% for (var i=0; i{%=o.features[i]%} +{% } %} +
+``` + +Add a file **server.js** with the following content: + +```js +require('http') + .createServer(function (req, res) { + var fs = require('fs'), + // The tmpl module exports the tmpl() function: + tmpl = require('./tmpl'), + // Use the following version if you installed the package with npm: + // tmpl = require("blueimp-tmpl"), + // Sample data: + data = { + title: 'JavaScript Templates', + url: 'https://github.com/blueimp/JavaScript-Templates', + features: ['lightweight & fast', 'powerful', 'zero dependencies'] + } + // Override the template loading method: + tmpl.load = function (id) { + var filename = id + '.html' + console.log('Loading ' + filename) + return fs.readFileSync(filename, 'utf8') + } + res.writeHead(200, { 'Content-Type': 'text/x-tmpl' }) + // Render the content: + res.end(tmpl('template', data)) + }) + .listen(8080, 'localhost') +console.log('Server running at http://localhost:8080/') +``` + +Run the application with the following command: + +```sh +node server.js +``` + +## Requirements + +The JavaScript Templates script has zero dependencies. + +## API + +### tmpl() function + +The **tmpl()** function is added to the global **window** object and can be +called as global function: + +```js +var result = tmpl('tmpl-demo', data) +``` + +The **tmpl()** function can be called with the id of a template, or with a +template string: + +```js +var result = tmpl('

{%=o.title%}

', data) +``` + +If called without second argument, **tmpl()** returns a reusable template +function: + +```js +var func = tmpl('

{%=o.title%}

') +document.getElementById('result').innerHTML = func(data) +``` + +### Templates cache + +Templates loaded by id are cached in the map **tmpl.cache**: + +```js +var func = tmpl('tmpl-demo'), // Loads and parses the template + cached = typeof tmpl.cache['tmpl-demo'] === 'function', // true + result = tmpl('tmpl-demo', data) // Uses cached template function + +tmpl.cache['tmpl-demo'] = null +result = tmpl('tmpl-demo', data) // Loads and parses the template again +``` + +### Output encoding + +The method **tmpl.encode** is used to escape HTML special characters in the +template output: + +```js +var output = tmpl.encode('<>&"\'\x00') // Renders "<>&"'" +``` + +**tmpl.encode** makes use of the regular expression **tmpl.encReg** and the +encoding map **tmpl.encMap** to match and replace special characters, which can +be modified to change the behavior of the output encoding. +Strings matched by the regular expression, but not found in the encoding map are +removed from the output. This allows for example to automatically trim input +values (removing whitespace from the start and end of the string): + +```js +tmpl.encReg = /(^\s+)|(\s+$)|[<>&"'\x00]/g +var output = tmpl.encode(' Banana! ') // Renders "Banana" (without whitespace) +``` + +### Local helper variables + +The local variables available inside the templates are the following: + +- **o**: The data object given as parameter to the template function (see the + next section on how to modify the parameter name). +- **tmpl**: A reference to the **tmpl** function object. +- **\_s**: The string for the rendered result content. +- **\_e**: A reference to the **tmpl.encode** method. +- **print**: Helper function to add content to the rendered result string. +- **include**: Helper function to include the return value of a different + template in the result. + +To introduce additional local helper variables, the string **tmpl.helper** can +be extended. The following adds a convenience function for _console.log_ and a +streaming function, that streams the template rendering result back to the +callback argument (note the comma at the beginning of each variable +declaration): + +```js +tmpl.helper += + ',log=function(){console.log.apply(console, arguments)}' + + ",st='',stream=function(cb){var l=st.length;st=_s;cb( _s.slice(l));}" +``` + +Those new helper functions could be used to stream the template contents to the +console output: + +```html + +``` + +### Template function argument + +The generated template functions accept one argument, which is the data object +given to the **tmpl(id, data)** function. This argument is available inside the +template definitions as parameter **o** (the lowercase letter). + +The argument name can be modified by overriding **tmpl.arg**: + +```js +tmpl.arg = 'p' + +// Renders "

JavaScript Templates

": +var result = tmpl('

{%=p.title%}

', { title: 'JavaScript Templates' }) +``` + +### Template parsing + +The template contents are matched and replaced using the regular expression +**tmpl.regexp** and the replacement function **tmpl.func**. The replacement +function operates based on the +[parenthesized submatch strings](https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/String/replace#Specifying_a_function_as_a_parameter). + +To use different tags for the template syntax, override **tmpl.regexp** with a +modified regular expression, by exchanging all occurrences of "{%" and "%}", +e.g. with "[%" and "%]": + +```js +tmpl.regexp = /([\s'\\])(?!(?:[^[]|\[(?!%))*%\])|(?:\[%(=|#)([\s\S]+?)%\])|(\[%)|(%\])/g +``` + +By default, the plugin preserves whitespace (newlines, carriage returns, tabs +and spaces). To strip unnecessary whitespace, you can override the **tmpl.func** +function, e.g. with the following code: + +```js +var originalFunc = tmpl.func +tmpl.func = function (s, p1, p2, p3, p4, p5, offset, str) { + if (p1 && /\s/.test(p1)) { + if ( + !offset || + /\s/.test(str.charAt(offset - 1)) || + /^\s+$/g.test(str.slice(offset)) + ) { + return '' + } + return ' ' + } + return originalFunc.apply(tmpl, arguments) +} +``` + +## Templates syntax + +### Interpolation + +Print variable with HTML special characters escaped: + +```html +

{%=o.title%}

+``` + +Print variable without escaping: + +```html +

{%#o.user_id%}

+``` + +Print output of function calls: + +```html +Website +``` + +Use dot notation to print nested properties: + +```html +{%=o.author.name%} +``` + +### Evaluation + +Use **print(str)** to add escaped content to the output: + +```html +Year: {% var d=new Date(); print(d.getFullYear()); %} +``` + +Use **print(str, true)** to add unescaped content to the output: + +```html +{% print("Fast & powerful", true); %} +``` + +Use **include(str, obj)** to include content from a different template: + +```html +
+ {% include('tmpl-link', {name: "Website", url: "https://example.org"}); %} +
+``` + +**If else condition**: + +```html +{% if (o.author.url) { %} +{%=o.author.name%} +{% } else { %} +No author url. +{% } %} +``` + +**For loop**: + +```html +
    +{% for (var i=0; i{%=o.features[i]%} +{% } %} +
+``` + +## Compiled templates + +The JavaScript Templates project comes with a compilation script, that allows +you to compile your templates into JavaScript code and combine them with a +minimal Templates runtime into one combined JavaScript file. + +The compilation script is built for [Node.js](https://nodejs.org/). +To use it, first install the JavaScript Templates project via +[NPM](https://www.npmjs.org/): + +```sh +npm install blueimp-tmpl +``` + +This will put the executable **tmpl.js** into the folder **node_modules/.bin**. +It will also make it available on your PATH if you install the package globally +(by adding the **-g** flag to the install command). + +The **tmpl.js** executable accepts the paths to one or multiple template files +as command line arguments and prints the generated JavaScript code to the +console output. The following command line shows you how to store the generated +code in a new JavaScript file that can be included in your project: + +```sh +tmpl.js index.html > tmpl.js +``` + +The files given as command line arguments to **tmpl.js** can either be pure +template files or HTML documents with embedded template script sections. For the +pure template files, the file names (without extension) serve as template ids. +The generated file can be included in your project as a replacement for the +original **tmpl.js** runtime. It provides you with the same API and provides a +**tmpl(id, data)** function that accepts the id of one of your templates as +first and a data object as optional second parameter. + +## Tests + +The JavaScript Templates project comes with +[Unit Tests](https://en.wikipedia.org/wiki/Unit_testing). +There are two different ways to run the tests: + +- Open test/index.html in your browser or +- run `npm test` in the Terminal in the root path of the repository package. + +The first one tests the browser integration, the second one the +[Node.js](https://nodejs.org/) integration. + +## License + +The JavaScript Templates script is released under the +[MIT license](https://opensource.org/licenses/MIT). diff --git a/lib/web/jquery/fileUploader/vendor/blueimp-tmpl/js/compile.js b/lib/web/jquery/fileUploader/vendor/blueimp-tmpl/js/compile.js new file mode 100755 index 0000000000000..122d034eaa8ea --- /dev/null +++ b/lib/web/jquery/fileUploader/vendor/blueimp-tmpl/js/compile.js @@ -0,0 +1,91 @@ +#!/usr/bin/env node +/* + * JavaScript Templates Compiler + * https://github.com/blueimp/JavaScript-Templates + * + * Copyright 2011, Sebastian Tschan + * https://blueimp.net + * + * Licensed under the MIT license: + * https://opensource.org/licenses/MIT + */ + +/* eslint-disable strict */ +/* eslint-disable no-console */ + +;(function () { + 'use strict' + var path = require('path') + var tmpl = require(path.join(__dirname, 'tmpl.js')) + var fs = require('fs') + // Retrieve the content of the minimal runtime: + var runtime = fs.readFileSync(path.join(__dirname, 'runtime.js'), 'utf8') + // A regular expression to parse templates from script tags in a HTML page: + var regexp = /([\s\S]+?)<\/script>/gi + // A regular expression to match the helper function names: + var helperRegexp = new RegExp( + tmpl.helper.match(/\w+(?=\s*=\s*function\s*\()/g).join('\\s*\\(|') + + '\\s*\\(' + ) + // A list to store the function bodies: + var list = [] + var code + // Extend the Templating engine with a print method for the generated functions: + tmpl.print = function (str) { + // Only add helper functions if they are used inside of the template: + var helper = helperRegexp.test(str) ? tmpl.helper : '' + var body = str.replace(tmpl.regexp, tmpl.func) + if (helper || /_e\s*\(/.test(body)) { + helper = '_e=tmpl.encode' + helper + ',' + } + return ( + 'function(' + + tmpl.arg + + ',tmpl){' + + ('var ' + helper + "_s='" + body + "';return _s;") + .split("_s+='';") + .join('') + + '}' + ) + } + // Loop through the command line arguments: + process.argv.forEach(function (file, index) { + var listLength = list.length + var stats + var content + var result + var id + // Skip the first two arguments, which are "node" and the script: + if (index > 1) { + stats = fs.statSync(file) + if (!stats.isFile()) { + console.error(file + ' is not a file.') + return + } + content = fs.readFileSync(file, 'utf8') + // eslint-disable-next-line no-constant-condition + while (true) { + // Find templates in script tags: + result = regexp.exec(content) + if (!result) { + break + } + id = result[2] || result[4] + list.push("'" + id + "':" + tmpl.print(result[5])) + } + if (listLength === list.length) { + // No template script tags found, use the complete content: + id = path.basename(file, path.extname(file)) + list.push("'" + id + "':" + tmpl.print(content)) + } + } + }) + if (!list.length) { + console.error('Missing input file.') + return + } + // Combine the generated functions as cache of the minimal runtime: + code = runtime.replace('{}', '{' + list.join(',') + '}') + // Print the resulting code to the console output: + console.log(code) +})() diff --git a/lib/web/jquery/fileUploader/vendor/blueimp-tmpl/js/runtime.js b/lib/web/jquery/fileUploader/vendor/blueimp-tmpl/js/runtime.js new file mode 100644 index 0000000000000..1a3a716c51bc0 --- /dev/null +++ b/lib/web/jquery/fileUploader/vendor/blueimp-tmpl/js/runtime.js @@ -0,0 +1,50 @@ +/* + * JavaScript Templates Runtime + * https://github.com/blueimp/JavaScript-Templates + * + * Copyright 2011, Sebastian Tschan + * https://blueimp.net + * + * Licensed under the MIT license: + * https://opensource.org/licenses/MIT + */ + +/* global define */ + +/* eslint-disable strict */ + +;(function ($) { + 'use strict' + var tmpl = function (id, data) { + var f = tmpl.cache[id] + return data + ? f(data, tmpl) + : function (data) { + return f(data, tmpl) + } + } + tmpl.cache = {} + tmpl.encReg = /[<>&"'\x00]/g // eslint-disable-line no-control-regex + tmpl.encMap = { + '<': '<', + '>': '>', + '&': '&', + '"': '"', + "'": ''' + } + tmpl.encode = function (s) { + // eslint-disable-next-line eqeqeq + return (s == null ? '' : '' + s).replace(tmpl.encReg, function (c) { + return tmpl.encMap[c] || '' + }) + } + if (typeof define === 'function' && define.amd) { + define(function () { + return tmpl + }) + } else if (typeof module === 'object' && module.exports) { + module.exports = tmpl + } else { + $.tmpl = tmpl + } +})(this) diff --git a/lib/web/jquery/fileUploader/vendor/blueimp-tmpl/js/tmpl.js b/lib/web/jquery/fileUploader/vendor/blueimp-tmpl/js/tmpl.js new file mode 100644 index 0000000000000..63eb927cb0d4d --- /dev/null +++ b/lib/web/jquery/fileUploader/vendor/blueimp-tmpl/js/tmpl.js @@ -0,0 +1,98 @@ +/* + * JavaScript Templates + * https://github.com/blueimp/JavaScript-Templates + * + * Copyright 2011, Sebastian Tschan + * https://blueimp.net + * + * Licensed under the MIT license: + * https://opensource.org/licenses/MIT + * + * Inspired by John Resig's JavaScript Micro-Templating: + * http://ejohn.org/blog/javascript-micro-templating/ + */ + +/* global define */ + +/* eslint-disable strict */ + +;(function ($) { + 'use strict' + var tmpl = function (str, data) { + var f = !/[^\w\-.:]/.test(str) + ? (tmpl.cache[str] = tmpl.cache[str] || tmpl(tmpl.load(str))) + : new Function( // eslint-disable-line no-new-func + tmpl.arg + ',tmpl', + 'var _e=tmpl.encode' + + tmpl.helper + + ",_s='" + + str.replace(tmpl.regexp, tmpl.func) + + "';return _s;" + ) + return data + ? f(data, tmpl) + : function (data) { + return f(data, tmpl) + } + } + tmpl.cache = {} + tmpl.load = function (id) { + return document.getElementById(id).innerHTML + } + tmpl.regexp = /([\s'\\])(?!(?:[^{]|\{(?!%))*%\})|(?:\{%(=|#)([\s\S]+?)%\})|(\{%)|(%\})/g + tmpl.func = function (s, p1, p2, p3, p4, p5) { + if (p1) { + // whitespace, quote and backspace in HTML context + return ( + { + '\n': '\\n', + '\r': '\\r', + '\t': '\\t', + ' ': ' ' + }[p1] || '\\' + p1 + ) + } + if (p2) { + // interpolation: {%=prop%}, or unescaped: {%#prop%} + if (p2 === '=') { + return "'+_e(" + p3 + ")+'" + } + return "'+(" + p3 + "==null?'':" + p3 + ")+'" + } + if (p4) { + // evaluation start tag: {% + return "';" + } + if (p5) { + // evaluation end tag: %} + return "_s+='" + } + } + tmpl.encReg = /[<>&"'\x00]/g // eslint-disable-line no-control-regex + tmpl.encMap = { + '<': '<', + '>': '>', + '&': '&', + '"': '"', + "'": ''' + } + tmpl.encode = function (s) { + // eslint-disable-next-line eqeqeq + return (s == null ? '' : '' + s).replace(tmpl.encReg, function (c) { + return tmpl.encMap[c] || '' + }) + } + tmpl.arg = 'o' + tmpl.helper = + ",print=function(s,e){_s+=e?(s==null?'':s):_e(s);}" + + ',include=function(s,d){_s+=tmpl(s,d);}' + if (typeof define === 'function' && define.amd) { + define(function () { + return tmpl + }) + } else if (typeof module === 'object' && module.exports) { + module.exports = tmpl + } else { + $.tmpl = tmpl + } +})(this) diff --git a/lib/web/jquery/fileUploader/vendor/jquery.ui.widget.js b/lib/web/jquery/fileUploader/vendor/jquery.ui.widget.js index 39287bd41ab09..69096aaa35ef4 100644 --- a/lib/web/jquery/fileUploader/vendor/jquery.ui.widget.js +++ b/lib/web/jquery/fileUploader/vendor/jquery.ui.widget.js @@ -1,282 +1,808 @@ -/* - * jQuery UI Widget 1.8.23+amd - * https://github.com/blueimp/jQuery-File-Upload - * - * Copyright 2012, AUTHORS.txt (http://jqueryui.com/about) - * Dual licensed under the MIT or GPL Version 2 licenses. - * http://jquery.org/license - * - * http://docs.jquery.com/UI/Widget - */ +/*! jQuery UI - v1.12.1+0b7246b6eeadfa9e2696e22f3230f6452f8129dc - 2020-02-20 + * http://jqueryui.com + * Includes: widget.js + * Copyright jQuery Foundation and other contributors; Licensed MIT */ + +/* global define, require */ +/* eslint-disable no-param-reassign, new-cap, jsdoc/require-jsdoc */ (function (factory) { - if (typeof define === "function" && define.amd) { - // Register as an anonymous AMD module: - define(["jquery"], factory); + 'use strict'; + if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module. + define(['jquery'], factory); + } else if (typeof exports === 'object') { + // Node/CommonJS + factory(require('jquery')); + } else { + // Browser globals + factory(window.jQuery); + } +})(function ($) { + ('use strict'); + + $.ui = $.ui || {}; + + $.ui.version = '1.12.1'; + + /*! + * jQuery UI Widget 1.12.1 + * http://jqueryui.com + * + * Copyright jQuery Foundation and other contributors + * Released under the MIT license. + * http://jquery.org/license + */ + + //>>label: Widget + //>>group: Core + //>>description: Provides a factory for creating stateful widgets with a common API. + //>>docs: http://api.jqueryui.com/jQuery.widget/ + //>>demos: http://jqueryui.com/widget/ + + // Support: jQuery 1.9.x or older + // $.expr[ ":" ] is deprecated. + if (!$.expr.pseudos) { + $.expr.pseudos = $.expr[':']; + } + + // Support: jQuery 1.11.x or older + // $.unique has been renamed to $.uniqueSort + if (!$.uniqueSort) { + $.uniqueSort = $.unique; + } + + var widgetUuid = 0; + var widgetHasOwnProperty = Array.prototype.hasOwnProperty; + var widgetSlice = Array.prototype.slice; + + $.cleanData = (function (orig) { + return function (elems) { + var events, elem, i; + // eslint-disable-next-line eqeqeq + for (i = 0; (elem = elems[i]) != null; i++) { + // Only trigger remove when necessary to save time + events = $._data(elem, 'events'); + if (events && events.remove) { + $(elem).triggerHandler('remove'); + } + } + orig(elems); + }; + })($.cleanData); + + $.widget = function (name, base, prototype) { + var existingConstructor, constructor, basePrototype; + + // ProxiedPrototype allows the provided prototype to remain unmodified + // so that it can be used as a mixin for multiple widgets (#8876) + var proxiedPrototype = {}; + + var namespace = name.split('.')[0]; + name = name.split('.')[1]; + var fullName = namespace + '-' + name; + + if (!prototype) { + prototype = base; + base = $.Widget; + } + + if ($.isArray(prototype)) { + prototype = $.extend.apply(null, [{}].concat(prototype)); + } + + // Create selector for plugin + $.expr.pseudos[fullName.toLowerCase()] = function (elem) { + return !!$.data(elem, fullName); + }; + + $[namespace] = $[namespace] || {}; + existingConstructor = $[namespace][name]; + constructor = $[namespace][name] = function (options, element) { + // Allow instantiation without "new" keyword + if (!this._createWidget) { + return new constructor(options, element); + } + + // Allow instantiation without initializing for simple inheritance + // must use "new" keyword (the code above always passes args) + if (arguments.length) { + this._createWidget(options, element); + } + }; + + // Extend with the existing constructor to carry over any static properties + $.extend(constructor, existingConstructor, { + version: prototype.version, + + // Copy the object used to create the prototype in case we need to + // redefine the widget later + _proto: $.extend({}, prototype), + + // Track widgets that inherit from this widget in case this widget is + // redefined after a widget inherits from it + _childConstructors: [] + }); + + basePrototype = new base(); + + // We need to make the options hash a property directly on the new instance + // otherwise we'll modify the options hash on the prototype that we're + // inheriting from + basePrototype.options = $.widget.extend({}, basePrototype.options); + $.each(prototype, function (prop, value) { + if (!$.isFunction(value)) { + proxiedPrototype[prop] = value; + return; + } + proxiedPrototype[prop] = (function () { + function _super() { + return base.prototype[prop].apply(this, arguments); + } + + function _superApply(args) { + return base.prototype[prop].apply(this, args); + } + + return function () { + var __super = this._super; + var __superApply = this._superApply; + var returnValue; + + this._super = _super; + this._superApply = _superApply; + + returnValue = value.apply(this, arguments); + + this._super = __super; + this._superApply = __superApply; + + return returnValue; + }; + })(); + }); + constructor.prototype = $.widget.extend( + basePrototype, + { + // TODO: remove support for widgetEventPrefix + // always use the name + a colon as the prefix, e.g., draggable:start + // don't prefix for widgets that aren't DOM-based + widgetEventPrefix: existingConstructor + ? basePrototype.widgetEventPrefix || name + : name + }, + proxiedPrototype, + { + constructor: constructor, + namespace: namespace, + widgetName: name, + widgetFullName: fullName + } + ); + + // If this widget is being redefined then we need to find all widgets that + // are inheriting from it and redefine all of them so that they inherit from + // the new version of this widget. We're essentially trying to replace one + // level in the prototype chain. + if (existingConstructor) { + $.each(existingConstructor._childConstructors, function (i, child) { + var childPrototype = child.prototype; + + // Redefine the child widget using the same prototype that was + // originally used, but inherit from the new version of the base + $.widget( + childPrototype.namespace + '.' + childPrototype.widgetName, + constructor, + child._proto + ); + }); + + // Remove the list of existing child constructors from the old constructor + // so the old child constructors can be garbage collected + delete existingConstructor._childConstructors; } else { - // Browser globals: - factory(jQuery); + base._childConstructors.push(constructor); + } + + $.widget.bridge(name, constructor); + + return constructor; + }; + + $.widget.extend = function (target) { + var input = widgetSlice.call(arguments, 1); + var inputIndex = 0; + var inputLength = input.length; + var key; + var value; + + for (; inputIndex < inputLength; inputIndex++) { + for (key in input[inputIndex]) { + value = input[inputIndex][key]; + if ( + widgetHasOwnProperty.call(input[inputIndex], key) && + value !== undefined + ) { + // Clone objects + if ($.isPlainObject(value)) { + target[key] = $.isPlainObject(target[key]) + ? $.widget.extend({}, target[key], value) + : // Don't extend strings, arrays, etc. with objects + $.widget.extend({}, value); + + // Copy everything else by reference + } else { + target[key] = value; + } + } + } + } + return target; + }; + + $.widget.bridge = function (name, object) { + var fullName = object.prototype.widgetFullName || name; + $.fn[name] = function (options) { + var isMethodCall = typeof options === 'string'; + var args = widgetSlice.call(arguments, 1); + var returnValue = this; + + if (isMethodCall) { + // If this is an empty collection, we need to have the instance method + // return undefined instead of the jQuery instance + if (!this.length && options === 'instance') { + returnValue = undefined; + } else { + this.each(function () { + var methodValue; + var instance = $.data(this, fullName); + + if (options === 'instance') { + returnValue = instance; + return false; + } + + if (!instance) { + return $.error( + 'cannot call methods on ' + + name + + ' prior to initialization; ' + + "attempted to call method '" + + options + + "'" + ); + } + + if (!$.isFunction(instance[options]) || options.charAt(0) === '_') { + return $.error( + "no such method '" + + options + + "' for " + + name + + ' widget instance' + ); + } + + methodValue = instance[options].apply(instance, args); + + if (methodValue !== instance && methodValue !== undefined) { + returnValue = + methodValue && methodValue.jquery + ? returnValue.pushStack(methodValue.get()) + : methodValue; + return false; + } + }); + } + } else { + // Allow multiple hashes to be passed on init + if (args.length) { + options = $.widget.extend.apply(null, [options].concat(args)); + } + + this.each(function () { + var instance = $.data(this, fullName); + if (instance) { + instance.option(options || {}); + if (instance._init) { + instance._init(); + } + } else { + $.data(this, fullName, new object(options, this)); + } + }); + } + + return returnValue; + }; + }; + + $.Widget = function (/* options, element */) {}; + $.Widget._childConstructors = []; + + $.Widget.prototype = { + widgetName: 'widget', + widgetEventPrefix: '', + defaultElement: '
', + + options: { + classes: {}, + disabled: false, + + // Callbacks + create: null + }, + + _createWidget: function (options, element) { + element = $(element || this.defaultElement || this)[0]; + this.element = $(element); + this.uuid = widgetUuid++; + this.eventNamespace = '.' + this.widgetName + this.uuid; + + this.bindings = $(); + this.hoverable = $(); + this.focusable = $(); + this.classesElementLookup = {}; + + if (element !== this) { + $.data(element, this.widgetFullName, this); + this._on(true, this.element, { + remove: function (event) { + if (event.target === element) { + this.destroy(); + } + } + }); + this.document = $( + element.style + ? // Element within the document + element.ownerDocument + : // Element is window or document + element.document || element + ); + this.window = $( + this.document[0].defaultView || this.document[0].parentWindow + ); + } + + this.options = $.widget.extend( + {}, + this.options, + this._getCreateOptions(), + options + ); + + this._create(); + + if (this.options.disabled) { + this._setOptionDisabled(this.options.disabled); + } + + this._trigger('create', null, this._getCreateEventData()); + this._init(); + }, + + _getCreateOptions: function () { + return {}; + }, + + _getCreateEventData: $.noop, + + _create: $.noop, + + _init: $.noop, + + destroy: function () { + var that = this; + + this._destroy(); + $.each(this.classesElementLookup, function (key, value) { + that._removeClass(value, key); + }); + + // We can probably remove the unbind calls in 2.0 + // all event bindings should go through this._on() + this.element.off(this.eventNamespace).removeData(this.widgetFullName); + this.widget().off(this.eventNamespace).removeAttr('aria-disabled'); + + // Clean up events and states + this.bindings.off(this.eventNamespace); + }, + + _destroy: $.noop, + + widget: function () { + return this.element; + }, + + option: function (key, value) { + var options = key; + var parts; + var curOption; + var i; + + if (arguments.length === 0) { + // Don't return a reference to the internal hash + return $.widget.extend({}, this.options); + } + + if (typeof key === 'string') { + // Handle nested keys, e.g., "foo.bar" => { foo: { bar: ___ } } + options = {}; + parts = key.split('.'); + key = parts.shift(); + if (parts.length) { + curOption = options[key] = $.widget.extend({}, this.options[key]); + for (i = 0; i < parts.length - 1; i++) { + curOption[parts[i]] = curOption[parts[i]] || {}; + curOption = curOption[parts[i]]; + } + key = parts.pop(); + if (arguments.length === 1) { + return curOption[key] === undefined ? null : curOption[key]; + } + curOption[key] = value; + } else { + if (arguments.length === 1) { + return this.options[key] === undefined ? null : this.options[key]; + } + options[key] = value; + } + } + + this._setOptions(options); + + return this; + }, + + _setOptions: function (options) { + var key; + + for (key in options) { + this._setOption(key, options[key]); + } + + return this; + }, + + _setOption: function (key, value) { + if (key === 'classes') { + this._setOptionClasses(value); + } + + this.options[key] = value; + + if (key === 'disabled') { + this._setOptionDisabled(value); + } + + return this; + }, + + _setOptionClasses: function (value) { + var classKey, elements, currentElements; + + for (classKey in value) { + currentElements = this.classesElementLookup[classKey]; + if ( + value[classKey] === this.options.classes[classKey] || + !currentElements || + !currentElements.length + ) { + continue; + } + + // We are doing this to create a new jQuery object because the _removeClass() call + // on the next line is going to destroy the reference to the current elements being + // tracked. We need to save a copy of this collection so that we can add the new classes + // below. + elements = $(currentElements.get()); + this._removeClass(currentElements, classKey); + + // We don't use _addClass() here, because that uses this.options.classes + // for generating the string of classes. We want to use the value passed in from + // _setOption(), this is the new value of the classes option which was passed to + // _setOption(). We pass this value directly to _classes(). + elements.addClass( + this._classes({ + element: elements, + keys: classKey, + classes: value, + add: true + }) + ); + } + }, + + _setOptionDisabled: function (value) { + this._toggleClass( + this.widget(), + this.widgetFullName + '-disabled', + null, + !!value + ); + + // If the widget is becoming disabled, then nothing is interactive + if (value) { + this._removeClass(this.hoverable, null, 'ui-state-hover'); + this._removeClass(this.focusable, null, 'ui-state-focus'); + } + }, + + enable: function () { + return this._setOptions({ disabled: false }); + }, + + disable: function () { + return this._setOptions({ disabled: true }); + }, + + _classes: function (options) { + var full = []; + var that = this; + + options = $.extend( + { + element: this.element, + classes: this.options.classes || {} + }, + options + ); + + function bindRemoveEvent() { + options.element.each(function (_, element) { + var isTracked = $.map(that.classesElementLookup, function (elements) { + return elements; + }).some(function (elements) { + return elements.is(element); + }); + + if (!isTracked) { + that._on($(element), { + remove: '_untrackClassesElement' + }); + } + }); + } + + function processClassString(classes, checkOption) { + var current, i; + for (i = 0; i < classes.length; i++) { + current = that.classesElementLookup[classes[i]] || $(); + if (options.add) { + bindRemoveEvent(); + current = $( + $.uniqueSort(current.get().concat(options.element.get())) + ); + } else { + current = $(current.not(options.element).get()); + } + that.classesElementLookup[classes[i]] = current; + full.push(classes[i]); + if (checkOption && options.classes[classes[i]]) { + full.push(options.classes[classes[i]]); + } + } + } + + if (options.keys) { + processClassString(options.keys.match(/\S+/g) || [], true); + } + if (options.extra) { + processClassString(options.extra.match(/\S+/g) || []); + } + + return full.join(' '); + }, + + _untrackClassesElement: function (event) { + var that = this; + $.each(that.classesElementLookup, function (key, value) { + if ($.inArray(event.target, value) !== -1) { + that.classesElementLookup[key] = $(value.not(event.target).get()); + } + }); + + this._off($(event.target)); + }, + + _removeClass: function (element, keys, extra) { + return this._toggleClass(element, keys, extra, false); + }, + + _addClass: function (element, keys, extra) { + return this._toggleClass(element, keys, extra, true); + }, + + _toggleClass: function (element, keys, extra, add) { + add = typeof add === 'boolean' ? add : extra; + var shift = typeof element === 'string' || element === null, + options = { + extra: shift ? keys : extra, + keys: shift ? element : keys, + element: shift ? this.element : element, + add: add + }; + options.element.toggleClass(this._classes(options), add); + return this; + }, + + _on: function (suppressDisabledCheck, element, handlers) { + var delegateElement; + var instance = this; + + // No suppressDisabledCheck flag, shuffle arguments + if (typeof suppressDisabledCheck !== 'boolean') { + handlers = element; + element = suppressDisabledCheck; + suppressDisabledCheck = false; + } + + // No element argument, shuffle and use this.element + if (!handlers) { + handlers = element; + element = this.element; + delegateElement = this.widget(); + } else { + element = delegateElement = $(element); + this.bindings = this.bindings.add(element); + } + + $.each(handlers, function (event, handler) { + function handlerProxy() { + // Allow widgets to customize the disabled handling + // - disabled as an array instead of boolean + // - disabled class as method for disabling individual parts + if ( + !suppressDisabledCheck && + (instance.options.disabled === true || + $(this).hasClass('ui-state-disabled')) + ) { + return; + } + return (typeof handler === 'string' + ? instance[handler] + : handler + ).apply(instance, arguments); + } + + // Copy the guid so direct unbinding works + if (typeof handler !== 'string') { + handlerProxy.guid = handler.guid = + handler.guid || handlerProxy.guid || $.guid++; + } + + var match = event.match(/^([\w:-]*)\s*(.*)$/); + var eventName = match[1] + instance.eventNamespace; + var selector = match[2]; + + if (selector) { + delegateElement.on(eventName, selector, handlerProxy); + } else { + element.on(eventName, handlerProxy); + } + }); + }, + + _off: function (element, eventName) { + eventName = + (eventName || '').split(' ').join(this.eventNamespace + ' ') + + this.eventNamespace; + element.off(eventName); + + // Clear the stack to avoid memory leaks (#10056) + this.bindings = $(this.bindings.not(element).get()); + this.focusable = $(this.focusable.not(element).get()); + this.hoverable = $(this.hoverable.not(element).get()); + }, + + _delay: function (handler, delay) { + var instance = this; + function handlerProxy() { + return (typeof handler === 'string' + ? instance[handler] + : handler + ).apply(instance, arguments); + } + return setTimeout(handlerProxy, delay || 0); + }, + + _hoverable: function (element) { + this.hoverable = this.hoverable.add(element); + this._on(element, { + mouseenter: function (event) { + this._addClass($(event.currentTarget), null, 'ui-state-hover'); + }, + mouseleave: function (event) { + this._removeClass($(event.currentTarget), null, 'ui-state-hover'); + } + }); + }, + + _focusable: function (element) { + this.focusable = this.focusable.add(element); + this._on(element, { + focusin: function (event) { + this._addClass($(event.currentTarget), null, 'ui-state-focus'); + }, + focusout: function (event) { + this._removeClass($(event.currentTarget), null, 'ui-state-focus'); + } + }); + }, + + _trigger: function (type, event, data) { + var prop, orig; + var callback = this.options[type]; + + data = data || {}; + event = $.Event(event); + event.type = (type === this.widgetEventPrefix + ? type + : this.widgetEventPrefix + type + ).toLowerCase(); + + // The original event may come from any element + // so we need to reset the target on the new event + event.target = this.element[0]; + + // Copy original event properties over to the new event + orig = event.originalEvent; + if (orig) { + for (prop in orig) { + if (!(prop in event)) { + event[prop] = orig[prop]; + } + } + } + + this.element.trigger(event, data); + return !( + ($.isFunction(callback) && + callback.apply(this.element[0], [event].concat(data)) === false) || + event.isDefaultPrevented() + ); } -}(function( $, undefined ) { - -// jQuery 1.4+ -if ( $.cleanData ) { - var _cleanData = $.cleanData; - $.cleanData = function( elems ) { - for ( var i = 0, elem; (elem = elems[i]) != null; i++ ) { - try { - $( elem ).triggerHandler( "remove" ); - // http://bugs.jquery.com/ticket/8235 - } catch( e ) {} - } - _cleanData( elems ); - }; -} else { - var _remove = $.fn.remove; - $.fn.remove = function( selector, keepData ) { - return this.each(function() { - if ( !keepData ) { - if ( !selector || $.filter( selector, [ this ] ).length ) { - $( "*", this ).add( [ this ] ).each(function() { - try { - $( this ).triggerHandler( "remove" ); - // http://bugs.jquery.com/ticket/8235 - } catch( e ) {} - }); - } - } - return _remove.call( $(this), selector, keepData ); - }); - }; -} - -$.widget = function( name, base, prototype ) { - var namespace = name.split( "." )[ 0 ], - fullName; - name = name.split( "." )[ 1 ]; - fullName = namespace + "-" + name; - - if ( !prototype ) { - prototype = base; - base = $.Widget; - } - - // create selector for plugin - $.expr[ ":" ][ fullName ] = function( elem ) { - return !!$.data( elem, name ); - }; - - $[ namespace ] = $[ namespace ] || {}; - $[ namespace ][ name ] = function( options, element ) { - // allow instantiation without initializing for simple inheritance - if ( arguments.length ) { - this._createWidget( options, element ); - } - }; - - var basePrototype = new base(); - // we need to make the options hash a property directly on the new instance - // otherwise we'll modify the options hash on the prototype that we're - // inheriting from -// $.each( basePrototype, function( key, val ) { -// if ( $.isPlainObject(val) ) { -// basePrototype[ key ] = $.extend( {}, val ); -// } -// }); - basePrototype.options = $.extend( true, {}, basePrototype.options ); - $[ namespace ][ name ].prototype = $.extend( true, basePrototype, { - namespace: namespace, - widgetName: name, - widgetEventPrefix: $[ namespace ][ name ].prototype.widgetEventPrefix || name, - widgetBaseClass: fullName - }, prototype ); - - $.widget.bridge( name, $[ namespace ][ name ] ); -}; - -$.widget.bridge = function( name, object ) { - $.fn[ name ] = function( options ) { - var isMethodCall = typeof options === "string", - args = Array.prototype.slice.call( arguments, 1 ), - returnValue = this; - - // allow multiple hashes to be passed on init - options = !isMethodCall && args.length ? - $.extend.apply( null, [ true, options ].concat(args) ) : - options; - - // prevent calls to internal methods - if ( isMethodCall && options.charAt( 0 ) === "_" ) { - return returnValue; - } - - if ( isMethodCall ) { - this.each(function() { - var instance = $.data( this, name ), - methodValue = instance && $.isFunction( instance[options] ) ? - instance[ options ].apply( instance, args ) : - instance; - // TODO: add this back in 1.9 and use $.error() (see #5972) -// if ( !instance ) { -// throw "cannot call methods on " + name + " prior to initialization; " + -// "attempted to call method '" + options + "'"; -// } -// if ( !$.isFunction( instance[options] ) ) { -// throw "no such method '" + options + "' for " + name + " widget instance"; -// } -// var methodValue = instance[ options ].apply( instance, args ); - if ( methodValue !== instance && methodValue !== undefined ) { - returnValue = methodValue; - return false; - } - }); - } else { - this.each(function() { - var instance = $.data( this, name ); - if ( instance ) { - instance.option( options || {} )._init(); - } else { - $.data( this, name, new object( options, this ) ); - } - }); - } - - return returnValue; - }; -}; - -$.Widget = function( options, element ) { - // allow instantiation without initializing for simple inheritance - if ( arguments.length ) { - this._createWidget( options, element ); - } -}; - -$.Widget.prototype = { - widgetName: "widget", - widgetEventPrefix: "", - options: { - disabled: false - }, - _createWidget: function( options, element ) { - // $.widget.bridge stores the plugin instance, but we do it anyway - // so that it's stored even before the _create function runs - $.data( element, this.widgetName, this ); - this.element = $( element ); - this.options = $.extend( true, {}, - this.options, - this._getCreateOptions(), - options ); - - var self = this; - this.element.bind( "remove." + this.widgetName, function() { - self.destroy(); - }); - - this._create(); - this._trigger( "create" ); - this._init(); - }, - _getCreateOptions: function() { - return $.metadata && $.metadata.get( this.element[0] )[ this.widgetName ]; - }, - _create: function() {}, - _init: function() {}, - - destroy: function() { - this.element - .unbind( "." + this.widgetName ) - .removeData( this.widgetName ); - this.widget() - .unbind( "." + this.widgetName ) - .removeAttr( "aria-disabled" ) - .removeClass( - this.widgetBaseClass + "-disabled " + - "ui-state-disabled" ); - }, - - widget: function() { - return this.element; - }, - - option: function( key, value ) { - var options = key; - - if ( arguments.length === 0 ) { - // don't return a reference to the internal hash - return $.extend( {}, this.options ); - } - - if (typeof key === "string" ) { - if ( value === undefined ) { - return this.options[ key ]; - } - options = {}; - options[ key ] = value; - } - - this._setOptions( options ); - - return this; - }, - _setOptions: function( options ) { - var self = this; - $.each( options, function( key, value ) { - self._setOption( key, value ); - }); - - return this; - }, - _setOption: function( key, value ) { - this.options[ key ] = value; - - if ( key === "disabled" ) { - this.widget() - [ value ? "addClass" : "removeClass"]( - this.widgetBaseClass + "-disabled" + " " + - "ui-state-disabled" ) - .attr( "aria-disabled", value ); - } - - return this; - }, - - enable: function() { - return this._setOption( "disabled", false ); - }, - disable: function() { - return this._setOption( "disabled", true ); - }, - - _trigger: function( type, event, data ) { - var prop, orig, - callback = this.options[ type ]; - - data = data || {}; - event = $.Event( event ); - event.type = ( type === this.widgetEventPrefix ? - type : - this.widgetEventPrefix + type ).toLowerCase(); - // the original event may come from any element - // so we need to reset the target on the new event - event.target = this.element[ 0 ]; - - // copy original event properties over to the new event - orig = event.originalEvent; - if ( orig ) { - for ( prop in orig ) { - if ( !( prop in event ) ) { - event[ prop ] = orig[ prop ]; - } - } - } - - this.element.trigger( event, data ); - - return !( $.isFunction(callback) && - callback.call( this.element[0], event, data ) === false || - event.isDefaultPrevented() ); - } -}; - -})); + }; + + $.each({ show: 'fadeIn', hide: 'fadeOut' }, function (method, defaultEffect) { + $.Widget.prototype['_' + method] = function (element, options, callback) { + if (typeof options === 'string') { + options = { effect: options }; + } + + var hasOptions; + var effectName = !options + ? method + : options === true || typeof options === 'number' + ? defaultEffect + : options.effect || defaultEffect; + + options = options || {}; + if (typeof options === 'number') { + options = { duration: options }; + } + + hasOptions = !$.isEmptyObject(options); + options.complete = callback; + + if (options.delay) { + element.delay(options.delay); + } + + if (hasOptions && $.effects && $.effects.effect[effectName]) { + element[method](options); + } else if (effectName !== method && element[effectName]) { + element[effectName](options.duration, options.easing, callback); + } else { + element.queue(function (next) { + $(this)[method](); + if (callback) { + callback.call(element[0]); + } + next(); + }); + } + }; + }); +}); diff --git a/lib/web/jquery/jquery.storageapi.min.js b/lib/web/jquery/jquery.storageapi.min.js index 886c3d847ed3b..fcf296a384bce 100644 --- a/lib/web/jquery/jquery.storageapi.min.js +++ b/lib/web/jquery/jquery.storageapi.min.js @@ -1,2 +1,2 @@ /* jQuery Storage API Plugin 1.7.3 https://github.com/julien-maurel/jQuery-Storage-API */ -!function(e){"function"==typeof define&&define.amd?define(["jquery", "jquery/jquery.cookie"],e):e("object"==typeof exports?require("jquery"):jQuery)}(function(e){function t(t){var r,i,n,o=arguments.length,s=window[t],a=arguments,u=a[1];if(2>o)throw Error("Minimum 2 arguments must be given");if(e.isArray(u)){i={};for(var f in u){r=u[f];try{i[r]=JSON.parse(s.getItem(r))}catch(c){i[r]=s.getItem(r)}}return i}if(2!=o){try{i=JSON.parse(s.getItem(u))}catch(c){throw new ReferenceError(u+" is not defined in this storage")}for(var f=2;o-1>f;f++)if(i=i[a[f]],void 0===i)throw new ReferenceError([].slice.call(a,1,f+1).join(".")+" is not defined in this storage");if(e.isArray(a[f])){n=i,i={};for(var m in a[f])i[a[f][m]]=n[a[f][m]];return i}return i[a[f]]}try{return JSON.parse(s.getItem(u))}catch(c){return s.getItem(u)}}function r(t){var r,i,n=arguments.length,o=window[t],s=arguments,a=s[1],u=s[2],f={};if(2>n||!e.isPlainObject(a)&&3>n)throw Error("Minimum 3 arguments must be given or second parameter must be an object");if(e.isPlainObject(a)){for(var c in a)r=a[c],e.isPlainObject(r)?o.setItem(c,JSON.stringify(r)):o.setItem(c,r);return a}if(3==n)return"object"==typeof u?o.setItem(a,JSON.stringify(u)):o.setItem(a,u),u;try{i=o.getItem(a),null!=i&&(f=JSON.parse(i))}catch(m){}i=f;for(var c=2;n-2>c;c++)r=s[c],i[r]&&e.isPlainObject(i[r])||(i[r]={}),i=i[r];return i[s[c]]=s[c+1],o.setItem(a,JSON.stringify(f)),f}function i(t){var r,i,n=arguments.length,o=window[t],s=arguments,a=s[1];if(2>n)throw Error("Minimum 2 arguments must be given");if(e.isArray(a)){for(var u in a)o.removeItem(a[u]);return!0}if(2==n)return o.removeItem(a),!0;try{r=i=JSON.parse(o.getItem(a))}catch(f){throw new ReferenceError(a+" is not defined in this storage")}for(var u=2;n-1>u;u++)if(i=i[s[u]],void 0===i)throw new ReferenceError([].slice.call(s,1,u).join(".")+" is not defined in this storage");if(e.isArray(s[u]))for(var c in s[u])delete i[s[u][c]];else delete i[s[u]];return o.setItem(a,JSON.stringify(r)),!0}function n(t,r){var n=a(t);for(var o in n)i(t,n[o]);if(r)for(var o in e.namespaceStorages)u(o)}function o(r){var i=arguments.length,n=arguments,s=(window[r],n[1]);if(1==i)return 0==a(r).length;if(e.isArray(s)){for(var u=0;ui)throw Error("Minimum 2 arguments must be given");if(e.isArray(o)){for(var a=0;a1?t.apply(this,o):n,a._cookie)for(var u in e.cookie())""!=u&&s.push(u.replace(a._prefix,""));else for(var f in a)s.push(f);return s}function u(t){if(!t||"string"!=typeof t)throw Error("First parameter must be a string");g?(window.localStorage.getItem(t)||window.localStorage.setItem(t,"{}"),window.sessionStorage.getItem(t)||window.sessionStorage.setItem(t,"{}")):(window.localCookieStorage.getItem(t)||window.localCookieStorage.setItem(t,"{}"),window.sessionCookieStorage.getItem(t)||window.sessionCookieStorage.setItem(t,"{}"));var r={localStorage:e.extend({},e.localStorage,{_ns:t}),sessionStorage:e.extend({},e.sessionStorage,{_ns:t})};return e.cookie&&(window.cookieStorage.getItem(t)||window.cookieStorage.setItem(t,"{}"),r.cookieStorage=e.extend({},e.cookieStorage,{_ns:t})),e.namespaceStorages[t]=r,r}function f(e){if(!window[e])return!1;var t="jsapi";try{return window[e].setItem(t,t),window[e].removeItem(t),!0}catch(r){return!1}}var c="ls_",m="ss_",g=f("localStorage"),h={_type:"",_ns:"",_callMethod:function(e,t){var r=[this._type],t=Array.prototype.slice.call(t),i=t[0];return this._ns&&r.push(this._ns),"string"==typeof i&&-1!==i.indexOf(".")&&(t.shift(),[].unshift.apply(t,i.split("."))),[].push.apply(r,t),e.apply(this,r)},get:function(){return this._callMethod(t,arguments)},set:function(){var t=arguments.length,i=arguments,n=i[0];if(1>t||!e.isPlainObject(n)&&2>t)throw Error("Minimum 2 arguments must be given or first parameter must be an object");if(e.isPlainObject(n)&&this._ns){for(var o in n)r(this._type,this._ns,o,n[o]);return n}var s=this._callMethod(r,i);return this._ns?s[n.split(".")[0]]:s},remove:function(){if(arguments.length<1)throw Error("Minimum 1 argument must be given");return this._callMethod(i,arguments)},removeAll:function(e){return this._ns?(r(this._type,this._ns,{}),!0):n(this._type,e)},isEmpty:function(){return this._callMethod(o,arguments)},isSet:function(){if(arguments.length<1)throw Error("Minimum 1 argument must be given");return this._callMethod(s,arguments)},keys:function(){return this._callMethod(a,arguments)}};if(e.cookie){window.name||(window.name=Math.floor(1e8*Math.random()));var l={_cookie:!0,_prefix:"",_expires:null,_path:null,_domain:null,setItem:function(t,r){e.cookie(this._prefix+t,r,{expires:this._expires,path:this._path,domain:this._domain})},getItem:function(t){return e.cookie(this._prefix+t)},removeItem:function(t){return e.removeCookie(this._prefix+t)},clear:function(){for(var t in e.cookie())""!=t&&(!this._prefix&&-1===t.indexOf(c)&&-1===t.indexOf(m)||this._prefix&&0===t.indexOf(this._prefix))&&e.removeCookie(t)},setExpires:function(e){return this._expires=e,this},setPath:function(e){return this._path=e,this},setDomain:function(e){return this._domain=e,this},setConf:function(e){return e.path&&(this._path=e.path),e.domain&&(this._domain=e.domain),e.expires&&(this._expires=e.expires),this},setDefaultConf:function(){this._path=this._domain=this._expires=null}};g||(window.localCookieStorage=e.extend({},l,{_prefix:c,_expires:3650}),window.sessionCookieStorage=e.extend({},l,{_prefix:m+window.name+"_"})),window.cookieStorage=e.extend({},l),e.cookieStorage=e.extend({},h,{_type:"cookieStorage",setExpires:function(e){return window.cookieStorage.setExpires(e),this},setPath:function(e){return window.cookieStorage.setPath(e),this},setDomain:function(e){return window.cookieStorage.setDomain(e),this},setConf:function(e){return window.cookieStorage.setConf(e),this},setDefaultConf:function(){return window.cookieStorage.setDefaultConf(),this}})}e.initNamespaceStorage=function(e){return u(e)},g?(e.localStorage=e.extend({},h,{_type:"localStorage"}),e.sessionStorage=e.extend({},h,{_type:"sessionStorage"})):(e.localStorage=e.extend({},h,{_type:"localCookieStorage"}),e.sessionStorage=e.extend({},h,{_type:"sessionCookieStorage"})),e.namespaceStorages={},e.removeAllStorages=function(t){e.localStorage.removeAll(t),e.sessionStorage.removeAll(t),e.cookieStorage&&e.cookieStorage.removeAll(t),t||(e.namespaceStorages={})}}); \ No newline at end of file +!function(e){"function"==typeof define&&define.amd?define(["jquery", "jquery/jquery.cookie"],e):e("object"==typeof exports?require("jquery"):jQuery)}(function(e){function t(t){var r,i,n,o=arguments.length,s=window[t],a=arguments,u=a[1];if(2>o)throw Error("Minimum 2 arguments must be given");if(e.isArray(u)){i={};for(var f in u){r=u[f];try{i[r]=JSON.parse(s.getItem(r))}catch(c){i[r]=s.getItem(r)}}return i}if(2!=o){try{i=JSON.parse(s.getItem(u))}catch(c){throw new ReferenceError(u+" is not defined in this storage")}for(var f=2;o-1>f;f++)if(i=i[a[f]],void 0===i)throw new ReferenceError([].slice.call(a,1,f+1).join(".")+" is not defined in this storage");if(e.isArray(a[f])){n=i,i={};for(var m in a[f])i[a[f][m]]=n[a[f][m]];return i}return i[a[f]]}try{return JSON.parse(s.getItem(u))}catch(c){return s.getItem(u)}}function r(t){var r,i,n=arguments.length,o=window[t],s=arguments,a=s[1],u=s[2],f={};if(2>n||!e.isPlainObject(a)&&3>n)throw Error("Minimum 3 arguments must be given or second parameter must be an object");if(e.isPlainObject(a)){for(var c in a)r=a[c],e.isPlainObject(r)?o.setItem(c,JSON.stringify(r)):o.setItem(c,r);return a}if(3==n)return"object"==typeof u?o.setItem(a,JSON.stringify(u)):o.setItem(a,u),u;try{i=o.getItem(a),null!=i&&(f=JSON.parse(i))}catch(m){}i=f;for(var c=2;n-2>c;c++)r=s[c],i[r]&&e.isPlainObject(i[r])||(i[r]={}),i=i[r];return i[s[c]]=s[c+1],o.setItem(a,JSON.stringify(f)),f}function i(t){var r,i,n=arguments.length,o=window[t],s=arguments,a=s[1];if(2>n)throw Error("Minimum 2 arguments must be given");if(e.isArray(a)){for(var u in a)o.removeItem(a[u]);return!0}if(2==n)return o.removeItem(a),!0;try{r=i=JSON.parse(o.getItem(a))}catch(f){throw new ReferenceError(a+" is not defined in this storage")}for(var u=2;n-1>u;u++)if(i=i[s[u]],void 0===i)throw new ReferenceError([].slice.call(s,1,u).join(".")+" is not defined in this storage");if(e.isArray(s[u]))for(var c in s[u])delete i[s[u][c]];else delete i[s[u]];return o.setItem(a,JSON.stringify(r)),!0}function n(t,r){var n=a(t);for(var o in n)i(t,n[o]);if(r)for(var o in e.namespaceStorages)u(o)}function o(r){var i=arguments.length,n=arguments,s=(window[r],n[1]);if(1==i)return 0==a(r).length;if(e.isArray(s)){for(var u=0;ui)throw Error("Minimum 2 arguments must be given");if(e.isArray(o)){for(var a=0;a1?t.apply(this,o):n,a._cookie)for(var u in e.cookie())""!=u&&s.push(u.replace(a._prefix,""));else for(var f in a)s.push(f);return s}function u(t){if(!t||"string"!=typeof t)throw Error("First parameter must be a string");g?(window.localStorage.getItem(t)||window.localStorage.setItem(t,"{}"),window.sessionStorage.getItem(t)||window.sessionStorage.setItem(t,"{}")):(window.localCookieStorage.getItem(t)||window.localCookieStorage.setItem(t,"{}"),window.sessionCookieStorage.getItem(t)||window.sessionCookieStorage.setItem(t,"{}"));var r={localStorage:e.extend({},e.localStorage,{_ns:t}),sessionStorage:e.extend({},e.sessionStorage,{_ns:t})};return e.cookie&&(window.cookieStorage.getItem(t)||window.cookieStorage.setItem(t,"{}"),r.cookieStorage=e.extend({},e.cookieStorage,{_ns:t})),e.namespaceStorages[t]=r,r}function f(e){if(!window[e])return!1;var t="jsapi";try{return window[e].setItem(t,t),window[e].removeItem(t),!0}catch(r){return!1}}var c="ls_",m="ss_",g=f("localStorage"),h={_type:"",_ns:"",_callMethod:function(e,t){var r=[this._type],t=Array.prototype.slice.call(t),i=t[0];return this._ns&&r.push(this._ns),"string"==typeof i&&-1!==i.indexOf(".")&&(t.shift(),[].unshift.apply(t,i.split("."))),[].push.apply(r,t),e.apply(this,r)},get:function(){return this._callMethod(t,arguments)},set:function(){var t=arguments.length,i=arguments,n=i[0];if(1>t||!e.isPlainObject(n)&&2>t)throw Error("Minimum 2 arguments must be given or first parameter must be an object");if(e.isPlainObject(n)&&this._ns){for(var o in n)r(this._type,this._ns,o,n[o]);return n}var s=this._callMethod(r,i);return this._ns?s[n.split(".")[0]]:s},remove:function(){if(arguments.length<1)throw Error("Minimum 1 argument must be given");return this._callMethod(i,arguments)},removeAll:function(e){return this._ns?(r(this._type,this._ns,{}),!0):n(this._type,e)},isEmpty:function(){return this._callMethod(o,arguments)},isSet:function(){if(arguments.length<1)throw Error("Minimum 1 argument must be given");return this._callMethod(s,arguments)},keys:function(){return this._callMethod(a,arguments)}};if(e.cookie){window.name||(window.name=Math.floor(1e8*Math.random()));var l={_cookie:!0,_prefix:"",_expires:null,_path:null,_domain:null,setItem:function(t,r){e.cookie(this._prefix+t,r,{expires:this._expires,path:this._path,domain:this._domain})},getItem:function(t){return e.cookie(this._prefix+t)},removeItem:function(t){return e.removeCookie(this._prefix+t)},clear:function(){for(var t in e.cookie())""!=t&&(!this._prefix&&-1===t.indexOf(c)&&-1===t.indexOf(m)||this._prefix&&0===t.indexOf(this._prefix))&&e.removeCookie(t)},setExpires:function(e){return this._expires=e,this},setPath:function(e){return this._path=e,this},setDomain:function(e){return this._domain=e,this},setConf:function(e){return e.path&&(this._path=e.path),e.domain&&(this._domain=e.domain),e.expires&&(this._expires=e.expires),this},setDefaultConf:function(){this._path=this._domain=this._expires=null}};g||(window.localCookieStorage=e.extend({},l,{_prefix:c,_expires:3650}),window.sessionCookieStorage=e.extend({},l,{_prefix:m+window.name+"_"})),window.cookieStorage=e.extend({},l),e.cookieStorage=e.extend({},h,{_type:"cookieStorage",setExpires:function(e){return window.cookieStorage.setExpires(e),this},setPath:function(e){return window.cookieStorage.setPath(e),this},setDomain:function(e){return window.cookieStorage.setDomain(e),this},setConf:function(e){return window.cookieStorage.setConf(e),this},setDefaultConf:function(){return window.cookieStorage.setDefaultConf(),this}})}e.initNamespaceStorage=function(e){return u(e)},g?(e.localStorage=e.extend({},h,{_type:"localStorage"}),e.sessionStorage=e.extend({},h,{_type:"sessionStorage"})):(e.localStorage=e.extend({},h,{_type:"localCookieStorage"}),e.sessionStorage=e.extend({},h,{_type:"sessionCookieStorage"})),e.namespaceStorages={},e.removeAllStorages=function(t){e.localStorage.removeAll(t),e.sessionStorage.removeAll(t),e.cookieStorage&&e.cookieStorage.removeAll(t),t||(e.namespaceStorages={})}}); diff --git a/lib/web/mage/adminhtml/form.js b/lib/web/mage/adminhtml/form.js index eae359c4b26a4..054594ff9e9f2 100644 --- a/lib/web/mage/adminhtml/form.js +++ b/lib/web/mage/adminhtml/form.js @@ -496,10 +496,15 @@ define([ } // toggle target row - headElement = $(idTo + '-head'); + headElement = jQuery('#' + idTo + '-head'); isInheritCheckboxChecked = $(idTo + '_inherit') && $(idTo + '_inherit').checked; target = $(idTo); + // Account for the chooser style parameters. + if (target === null && headElement.length === 0 && idTo.substring(0, 16) === 'options_fieldset') { + headElement = jQuery('.field-' + idTo).add('.field-chooser' + idTo); + } + // Target won't always exist (for example, if field type is "label") if (target) { inputs = target.up(this._config['levels_up']).select('input', 'select', 'td'); @@ -529,10 +534,10 @@ define([ }); } - if (headElement) { + if (headElement.length > 0) { headElement.show(); - if (headElement.hasClassName('open') && target) { + if (headElement.hasClass('open') && target) { target.show(); } else if (target) { target.hide(); @@ -567,7 +572,7 @@ define([ }); } - if (headElement) { + if (headElement.length > 0) { headElement.hide(); } diff --git a/lib/web/mage/adminhtml/wysiwyg/tiny_mce/tinymce4Adapter.js b/lib/web/mage/adminhtml/wysiwyg/tiny_mce/tinymce4Adapter.js index 4393b6c882039..d74838b0c26bf 100644 --- a/lib/web/mage/adminhtml/wysiwyg/tiny_mce/tinymce4Adapter.js +++ b/lib/web/mage/adminhtml/wysiwyg/tiny_mce/tinymce4Adapter.js @@ -205,6 +205,7 @@ define([ plugins: this.config.tinymce4.plugins, toolbar: this.config.tinymce4.toolbar, adapter: this, + 'body_id': 'html-body', /** * @param {Object} editor diff --git a/lib/web/mage/backend/floating-header.js b/lib/web/mage/backend/floating-header.js index a6f767259488a..1f3b49149a6e8 100644 --- a/lib/web/mage/backend/floating-header.js +++ b/lib/web/mage/backend/floating-header.js @@ -101,7 +101,9 @@ define([ * @private */ _destroy: function () { - this._placeholder.remove(); + if (this._placeholder) { + this._placeholder.remove(); + } this._off($(window)); } }); diff --git a/lib/web/mage/gallery/gallery.js b/lib/web/mage/gallery/gallery.js index 6309fa267ea93..ca4d2e2bc4c12 100644 --- a/lib/web/mage/gallery/gallery.js +++ b/lib/web/mage/gallery/gallery.js @@ -340,13 +340,17 @@ define([ settings = this.settings, config = this.config, startConfig = this.startConfig, + isInitialized = {}, isTouchEnabled = this.isTouchEnabled; if (_.isObject(settings.breakpoints)) { pairs = _.pairs(settings.breakpoints); _.each(pairs, function (pair) { + var mediaQuery = pair[0]; + + isInitialized[mediaQuery] = false; mediaCheck({ - media: pair[0], + media: mediaQuery, /** * Is triggered when breakpoint enties. @@ -361,29 +365,35 @@ define([ } if (isTouchEnabled) { - settings.breakpoints[pair[0]].options.arrows = false; + settings.breakpoints[mediaQuery].options.arrows = false; - if (settings.breakpoints[pair[0]].options.fullscreen) { - settings.breakpoints[pair[0]].options.fullscreen.arrows = false; + if (settings.breakpoints[mediaQuery].options.fullscreen) { + settings.breakpoints[mediaQuery].options.fullscreen.arrows = false; } } - settings.api.updateOptions(settings.breakpoints[pair[0]].options, true); - $.extend(true, config, settings.breakpoints[pair[0]]); - settings.activeBreakpoint = settings.breakpoints[pair[0]]; + settings.api.updateOptions(settings.breakpoints[mediaQuery].options, true); + $.extend(true, config, settings.breakpoints[mediaQuery]); + settings.activeBreakpoint = settings.breakpoints[mediaQuery]; + + isInitialized[mediaQuery] = true; }, /** * Is triggered when breakpoint exits. */ exit: function () { - $.extend(true, config, _.clone(startConfig)); - settings.api.updateOptions(settings.defaultConfig.options, true); + if (isInitialized[mediaQuery]) { + $.extend(true, config, _.clone(startConfig)); + settings.api.updateOptions(settings.defaultConfig.options, true); - if (settings.isFullscreen) { - settings.api.updateOptions(settings.fullscreenConfig, true); + if (settings.isFullscreen) { + settings.api.updateOptions(settings.fullscreenConfig, true); + } + settings.activeBreakpoint = {}; + } else { + isInitialized[mediaQuery] = true; } - settings.activeBreakpoint = {}; } }); }); diff --git a/lib/web/mage/requirejs/resolver.js b/lib/web/mage/requirejs/resolver.js index 5ba1f1351bcf6..9818bc00c1343 100644 --- a/lib/web/mage/requirejs/resolver.js +++ b/lib/web/mage/requirejs/resolver.js @@ -37,6 +37,16 @@ define([ return registry[module.id] && (registry[module.id].inited || registry[module.id].error); } + /** + * Checks if provided module had path fallback triggered. + * + * @param {Object} module - Module to be checked. + * @return {Boolean} + */ + function isPathFallback(module) { + return registry[module.id] && registry[module.id].events.error; + } + /** * Checks if provided module has unresolved dependencies. * @@ -48,7 +58,8 @@ define([ return false; } - return module.depCount > _.filter(module.depMaps, isRejected).length; + return module.depCount > + _.filter(module.depMaps, isRejected).length + _.filter(module.depMaps, isPathFallback).length; } /** diff --git a/lib/web/mage/storage.js b/lib/web/mage/storage.js index 1e136aa78477b..ba7cb6a8795cf 100644 --- a/lib/web/mage/storage.js +++ b/lib/web/mage/storage.js @@ -12,9 +12,11 @@ define(['jquery', 'mage/url'], function ($, urlBuilder) { * @param {String} url * @param {Boolean} global * @param {String} contentType + * @param {Object} headers * @returns {Deferred} */ - get: function (url, global, contentType) { + get: function (url, global, contentType, headers) { + headers = headers || {}; global = global === undefined ? true : global; contentType = contentType || 'application/json'; @@ -22,7 +24,8 @@ define(['jquery', 'mage/url'], function ($, urlBuilder) { url: urlBuilder.build(url), type: 'GET', global: global, - contentType: contentType + contentType: contentType, + headers: headers }); }, @@ -32,9 +35,11 @@ define(['jquery', 'mage/url'], function ($, urlBuilder) { * @param {String} data * @param {Boolean} global * @param {String} contentType + * @param {Object} headers * @returns {Deferred} */ - post: function (url, data, global, contentType) { + post: function (url, data, global, contentType, headers) { + headers = headers || {}; global = global === undefined ? true : global; contentType = contentType || 'application/json'; @@ -43,7 +48,8 @@ define(['jquery', 'mage/url'], function ($, urlBuilder) { type: 'POST', data: data, global: global, - contentType: contentType + contentType: contentType, + headers: headers }); }, @@ -53,11 +59,13 @@ define(['jquery', 'mage/url'], function ($, urlBuilder) { * @param {String} data * @param {Boolean} global * @param {String} contentType + * @param {Object} headers * @returns {Deferred} */ put: function (url, data, global, contentType, headers) { var ajaxSettings = {}; + headers = headers || {}; global = global === undefined ? true : global; contentType = contentType || 'application/json'; ajaxSettings.url = urlBuilder.build(url); @@ -65,10 +73,7 @@ define(['jquery', 'mage/url'], function ($, urlBuilder) { ajaxSettings.data = data; ajaxSettings.global = global; ajaxSettings.contentType = contentType; - - if (headers) { - ajaxSettings.headers = headers; - } + ajaxSettings.headers = headers; return $.ajax(ajaxSettings); }, @@ -78,9 +83,11 @@ define(['jquery', 'mage/url'], function ($, urlBuilder) { * @param {String} url * @param {Boolean} global * @param {String} contentType + * @param {Object} headers * @returns {Deferred} */ - delete: function (url, global, contentType) { + delete: function (url, global, contentType, headers) { + headers = headers || {}; global = global === undefined ? true : global; contentType = contentType || 'application/json'; @@ -88,7 +95,8 @@ define(['jquery', 'mage/url'], function ($, urlBuilder) { url: urlBuilder.build(url), type: 'DELETE', global: global, - contentType: contentType + contentType: contentType, + headers: headers }); } }; diff --git a/lib/web/mage/validation.js b/lib/web/mage/validation.js index de40e3afa40ab..ae8dad5865709 100644 --- a/lib/web/mage/validation.js +++ b/lib/web/mage/validation.js @@ -204,12 +204,24 @@ define([ * @returns {float} */ function resolveModulo(qty, qtyIncrements) { + var divideEpsilon = 10000, + epsilon, + remainder; + while (qtyIncrements < 1) { qty *= 10; qtyIncrements *= 10; } - return qty % qtyIncrements; + epsilon = qtyIncrements / divideEpsilon; + remainder = qty % qtyIncrements; + + if (Math.abs(remainder - qtyIncrements) < epsilon || + Math.abs(remainder) < epsilon) { + remainder = 0; + } + + return remainder; } /** diff --git a/nginx.conf.sample b/nginx.conf.sample index ead80ccb22ece..2dbba68c39c39 100644 --- a/nginx.conf.sample +++ b/nginx.conf.sample @@ -26,6 +26,9 @@ ## ## In production mode, you should uncomment the 'expires' directive in the /static/ location block +# Modules can be loaded only at the very beginning of the Nginx config file, please move the line below to the main config file +# load_module /etc/nginx/modules/ngx_http_image_filter_module.so; + root $MAGE_ROOT/pub; index index.php; @@ -134,6 +137,29 @@ location /static/ { } location /media/ { + +## The following section allows to offload image resizing from Magento instance to the Nginx. +## Catalog image URL format should be set accordingly. +## See https://docs.magento.com/m2/ee/user_guide/configuration/general/web.html#url-options +# location ~* ^/media/catalog/.* { +# +# # Replace placeholders and uncomment the line below to serve product images from public S3 +# # See examples of S3 authentication at https://github.com/anomalizer/ngx_aws_auth +# # resolver 8.8.8.8; +# # proxy_pass https://..amazonaws.com; +# +# set $width "-"; +# set $height "-"; +# if ($arg_width != '') { +# set $width $arg_width; +# } +# if ($arg_height != '') { +# set $height $arg_height; +# } +# image_filter resize $width $height; +# image_filter_jpeg_quality 90; +# } + try_files $uri $uri/ /get.php$is_args$args; location ~ ^/media/theme_customization/.*\.xml { diff --git a/package.json.sample b/package.json.sample index 93fe72afbd24a..1d07bcff26d8d 100644 --- a/package.json.sample +++ b/package.json.sample @@ -1,42 +1,42 @@ { - "name": "magento2", - "author": "Magento Commerce Inc.", - "description": "Magento2 node modules dependencies for local development", - "license": "(OSL-3.0 OR AFL-3.0)", - "repository": { - "type": "git", - "url": "https://github.com/magento/magento2.git" - }, - "homepage": "http://magento.com/", - "devDependencies": { - "glob": "~7.1.1", - "grunt": "~1.0.1", - "grunt-autoprefixer": "~3.0.4", - "grunt-banner": "~0.6.0", - "grunt-continue": "~0.1.0", - "grunt-contrib-clean": "~1.1.0", - "grunt-contrib-connect": "~1.0.2", - "grunt-contrib-cssmin": "~2.2.1", - "grunt-contrib-imagemin": "~2.0.1", - "grunt-contrib-jasmine": "~1.2.0", - "grunt-contrib-less": "~1.4.1", - "grunt-contrib-watch": "~1.0.0", - "grunt-eslint": "~20.1.0", - "grunt-exec": "~3.0.0", - "grunt-jscs": "~3.0.1", - "grunt-replace": "~1.0.1", - "grunt-styledocco": "~0.3.0", - "grunt-template-jasmine-requirejs": "~0.2.3", - "grunt-text-replace": "~0.4.0", - "imagemin-svgo": "~5.2.1", - "load-grunt-config": "~0.19.2", - "morgan": "~1.9.0", - "node-minify": "~2.3.1", - "path": "~0.12.7", - "serve-static": "~1.13.1", - "squirejs": "~0.2.1", - "strip-json-comments": "~2.0.1", - "time-grunt": "~1.4.0", - "underscore": "~1.8.0" - } + "name": "magento2", + "author": "Magento Commerce Inc.", + "description": "Magento2 node modules dependencies for local development", + "license": "(OSL-3.0 OR AFL-3.0)", + "repository": { + "type": "git", + "url": "https://github.com/magento/magento2.git" + }, + "homepage": "http://magento.com/", + "devDependencies": { + "glob": "~7.1.1", + "grunt": "~1.0.1", + "grunt-autoprefixer": "~3.0.4", + "grunt-banner": "~0.6.0", + "grunt-continue": "~0.1.0", + "grunt-contrib-clean": "~1.1.0", + "grunt-contrib-connect": "~1.0.2", + "grunt-contrib-cssmin": "~2.2.1", + "grunt-contrib-imagemin": "~2.0.1", + "grunt-contrib-jasmine": "~1.2.0", + "grunt-contrib-less": "~1.4.1", + "grunt-contrib-watch": "~1.0.0", + "grunt-eslint": "~20.1.0", + "grunt-exec": "~3.0.0", + "grunt-jscs": "~3.0.1", + "grunt-replace": "~1.0.1", + "grunt-styledocco": "~0.3.0", + "grunt-template-jasmine-requirejs": "~0.2.3", + "grunt-text-replace": "~0.4.0", + "imagemin-svgo": "~5.2.1", + "load-grunt-config": "~0.19.2", + "morgan": "~1.9.0", + "node-minify": "~2.3.1", + "path": "~0.12.7", + "serve-static": "~1.13.1", + "squirejs": "~0.2.1", + "strip-json-comments": "~2.0.1", + "time-grunt": "~1.4.0", + "underscore": "~1.8.0" + } } diff --git a/pub/.htaccess b/pub/.htaccess index 6a97a6d14dc00..d30951ee22ca5 100644 --- a/pub/.htaccess +++ b/pub/.htaccess @@ -22,6 +22,11 @@ ## cgi.fix_pathinfo = 1 ## If it still doesn't work, rename php.ini to php5.ini +############################################ +## Enable usage of methods arguments in backtrace + + #SetEnv MAGE_DEBUG_SHOW_ARGS 1 + ############################################ ## This line is specific for 1and1 hosting @@ -33,24 +38,6 @@ DirectoryIndex index.php - -############################################ -## Adjust memory limit - - php_value memory_limit 756M - php_value max_execution_time 18000 - -############################################ -## Disable automatic session start -## before autoload was initialized - - php_flag session.auto_start off - -############################################ -# Disable user agent verification to not break multiple image upload - - php_flag suhosin.session.cryptua off - ############################################ ## Adjust memory limit @@ -75,7 +62,6 @@ php_flag suhosin.session.cryptua off - ########################################### # Disable POST processing to not break multiple image upload @@ -93,7 +79,7 @@ # Insert filter on all content ###SetOutputFilter DEFLATE # Insert filter on selected content types only - #AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css text/javascript + #AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css text/javascript application/javascript application/x-javascript application/json image/svg+xml # Netscape 4.x has some problems... #BrowserMatch ^Mozilla/4 gzip-only-text/html @@ -121,6 +107,13 @@ +############################################ +## Workaround for Apache 2.4.6 CentOS build when working via ProxyPassMatch with HHVM (or any other) +## Please, set it on virtual host configuration level + +## SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1 +############################################ + ############################################ @@ -147,6 +140,13 @@ RewriteCond %{REQUEST_METHOD} ^TRAC[EK] RewriteRule .* - [L,R=405] +############################################ +## Redirect for mobile user agents + + #RewriteCond %{REQUEST_URI} !^/mobiledirectoryhere/.*$ + #RewriteCond %{HTTP_USER_AGENT} "android|blackberry|ipad|iphone|ipod|iemobile|opera mobile|palmos|webos|googlebot-mobile" [NC] + #RewriteRule ^(.*)$ /mobiledirectoryhere/ [L,R=302] + ############################################ ## Never rewrite for existing files, directories and links @@ -168,6 +168,7 @@ AddDefaultCharset Off #AddDefaultCharset UTF-8 + AddType 'text/html; charset=UTF-8' html @@ -193,18 +194,15 @@ Require all denied - -# For 404s and 403s that aren't handled by the application, show plain 404 response -ErrorDocument 404 /errors/404.php -ErrorDocument 403 /errors/404.php - -############################################ -## If running in cluster environment, uncomment this -## http://developer.yahoo.com/performance/rules.html#etags - - #FileETag none - -########################################### + + + order allow,deny + deny from all + + = 2.4> + Require all denied + + ## Deny access to cron.php @@ -226,8 +224,48 @@ ErrorDocument 403 /errors/404.php +# For 404s and 403s that aren't handled by the application, show plain 404 response +ErrorDocument 404 /errors/404.php +ErrorDocument 403 /errors/404.php + +################################ +## If running in cluster environment, uncomment this +## http://developer.yahoo.com/performance/rules.html#etags + + #FileETag none + +# ###################################################################### +# # INTERNET EXPLORER # +# ###################################################################### + +# ---------------------------------------------------------------------- +# | Document modes | +# ---------------------------------------------------------------------- + +# Force Internet Explorer 8/9/10 to render pages in the highest mode +# available in the various cases when it may not. +# +# https://hsivonen.fi/doctype/#ie8 +# +# (!) Starting with Internet Explorer 11, document modes are deprecated. +# If your business still relies on older web apps and services that were +# designed for older versions of Internet Explorer, you might want to +# consider enabling `Enterprise Mode` throughout your company. +# +# https://msdn.microsoft.com/en-us/library/ie/bg182625.aspx#docmode +# http://blogs.msdn.com/b/ie/archive/2014/04/02/stay-up-to-date-with-enterprise-mode-for-internet-explorer-11.aspx + ############################################ + Header set X-UA-Compatible "IE=edge" + + # `mod_headers` cannot match based on the content-type, however, + # the `X-UA-Compatible` response header should be send only for + # HTML documents and not for the other resources. + + Header unset X-UA-Compatible + + ## Prevent clickjacking Header set X-Frame-Options SAMEORIGIN diff --git a/pub/errors/processor.php b/pub/errors/processor.php index 7cab4add51a92..ac335211f97e0 100644 --- a/pub/errors/processor.php +++ b/pub/errors/processor.php @@ -7,6 +7,7 @@ namespace Magento\Framework\Error; +use Magento\Config\Model\Config\Reader\Source\Deployed\DocumentRoot; use Magento\Framework\Serialize\Serializer\Json; use Magento\Framework\Escaper; use Magento\Framework\App\ObjectManager; @@ -149,21 +150,29 @@ class Processor */ private $escaper; + /** + * @var DocumentRoot + */ + private $documentRoot; + /** * @param Http $response * @param Json $serializer * @param Escaper $escaper + * @param DocumentRoot|null $documentRoot */ public function __construct( Http $response, Json $serializer = null, - Escaper $escaper = null + Escaper $escaper = null, + DocumentRoot $documentRoot = null ) { $this->_response = $response; $this->_errorDir = __DIR__ . '/'; $this->_reportDir = dirname(dirname($this->_errorDir)) . '/var/report/'; $this->serializer = $serializer ?: ObjectManager::getInstance()->get(Json::class); $this->escaper = $escaper ?: ObjectManager::getInstance()->get(Escaper::class); + $this->documentRoot = $documentRoot ?? ObjectManager::getInstance()->get(DocumentRoot::class); if (!empty($_SERVER['SCRIPT_NAME'])) { if (in_array(basename($_SERVER['SCRIPT_NAME'], '.php'), ['404', '503', 'report'])) { @@ -255,12 +264,13 @@ public function processReport() public function getViewFileUrl() { //The url needs to be updated base on Document root path. - return $this->getBaseUrl() . - str_replace( - str_replace('\\', '/', $this->_indexDir), - '', - str_replace('\\', '/', $this->_errorDir) - ) . $this->_config->skin . '/'; + $indexDir = str_replace('\\', '/', $this->_indexDir); + $errorDir = str_replace('\\', '/', $this->_errorDir); + $errorPathSuffix = $this->documentRoot->isPub() ? 'errors/' : 'pub/errors/'; + $errorPath = strpos($errorDir, $indexDir) === 0 ? + str_replace($indexDir, '', $errorDir) : $errorPathSuffix; + + return $this->getBaseUrl() . $errorPath . $this->_config->skin . '/'; } /** diff --git a/pub/get.php b/pub/get.php index 215a83b74fbca..c59365c98727c 100644 --- a/pub/get.php +++ b/pub/get.php @@ -43,13 +43,16 @@ // Serve file if it's materialized if ($mediaDirectory) { - if (!$isAllowed($relativePath, $allowedResources)) { + $fileAbsolutePath = __DIR__ . '/' . $relativePath; + $fileRelativePath = str_replace(rtrim($mediaDirectory, '/') . '/', '', $fileAbsolutePath); + + if (!$isAllowed($fileRelativePath, $allowedResources)) { require_once 'errors/404.php'; exit; } - $mediaAbsPath = $mediaDirectory . '/' . $relativePath; - if (is_readable($mediaAbsPath)) { - if (is_dir($mediaAbsPath)) { + + if (is_readable($fileAbsolutePath)) { + if (is_dir($fileAbsolutePath)) { require_once 'errors/404.php'; exit; } @@ -57,7 +60,7 @@ new \Magento\Framework\HTTP\PhpEnvironment\Response(), new \Magento\Framework\File\Mime() ); - $transfer->send($mediaAbsPath); + $transfer->send($fileAbsolutePath); exit; } } diff --git a/pub/index.php b/pub/index.php index 612e190719053..9e91f3bfa5488 100644 --- a/pub/index.php +++ b/pub/index.php @@ -7,7 +7,6 @@ */ use Magento\Framework\App\Bootstrap; -use Magento\Framework\App\Filesystem\DirectoryList; try { require __DIR__ . '/../app/bootstrap.php'; @@ -24,17 +23,7 @@ exit(1); } -$params = $_SERVER; -$params[Bootstrap::INIT_PARAM_FILESYSTEM_DIR_PATHS] = array_replace_recursive( - $params[Bootstrap::INIT_PARAM_FILESYSTEM_DIR_PATHS] ?? [], - [ - DirectoryList::PUB => [DirectoryList::URL_PATH => ''], - DirectoryList::MEDIA => [DirectoryList::URL_PATH => 'media'], - DirectoryList::STATIC_VIEW => [DirectoryList::URL_PATH => 'static'], - DirectoryList::UPLOAD => [DirectoryList::URL_PATH => 'media/upload'], - ] -); -$bootstrap = \Magento\Framework\App\Bootstrap::create(BP, $params); +$bootstrap = Bootstrap::create(BP, $_SERVER); /** @var \Magento\Framework\App\Http $app */ $app = $bootstrap->createApplication(\Magento\Framework\App\Http::class); $bootstrap->run($app); diff --git a/pub/media/sitemap/.htaccess b/pub/media/sitemap/.htaccess new file mode 100644 index 0000000000000..187517e43efb2 --- /dev/null +++ b/pub/media/sitemap/.htaccess @@ -0,0 +1 @@ +Allow From All diff --git a/setup/src/Magento/Setup/Console/Command/AdminUserCreateCommand.php b/setup/src/Magento/Setup/Console/Command/AdminUserCreateCommand.php index 173ea9e49a8a4..8e64aae20573c 100644 --- a/setup/src/Magento/Setup/Console/Command/AdminUserCreateCommand.php +++ b/setup/src/Magento/Setup/Console/Command/AdminUserCreateCommand.php @@ -7,6 +7,7 @@ namespace Magento\Setup\Console\Command; use Magento\Framework\Setup\ConsoleLogger; +use Magento\Framework\Validation\ValidationException; use Magento\Setup\Model\AdminAccount; use Magento\Setup\Model\InstallerFactory; use Magento\User\Model\UserValidationRules; @@ -81,7 +82,7 @@ protected function interact(InputInterface $input, OutputInterface $output) $question = new Question('Admin password: ', ''); $question->setHidden(true); - $question->setValidator(function ($value) use ($output) { + $question->setValidator(function ($value) { $user = new \Magento\Framework\DataObject(); $user->setPassword($value); @@ -90,7 +91,7 @@ protected function interact(InputInterface $input, OutputInterface $output) $validator->isValid($user); foreach ($validator->getMessages() as $message) { - throw new \Exception($message); + throw new ValidationException(__($message)); } return $value; @@ -143,7 +144,7 @@ private function addNotEmptyValidator(Question $question) { $question->setValidator(function ($value) { if (trim($value) == '') { - throw new \Exception('The value cannot be empty'); + throw new ValidationException(__('The value cannot be empty')); } return $value; diff --git a/setup/src/Magento/Setup/Console/Command/ConfigSetCommand.php b/setup/src/Magento/Setup/Console/Command/ConfigSetCommand.php index e8ff8f09c345e..9380a7b72d6b2 100644 --- a/setup/src/Magento/Setup/Console/Command/ConfigSetCommand.php +++ b/setup/src/Magento/Setup/Console/Command/ConfigSetCommand.php @@ -14,9 +14,6 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\Question; -/** - * Config Set Command - */ class ConfigSetCommand extends AbstractSetupCommand { /** @@ -72,6 +69,7 @@ protected function configure() /** * @inheritdoc + * @SuppressWarnings(PHPMD.CyclomaticComplexity) */ protected function execute(InputInterface $input, OutputInterface $output) { @@ -84,7 +82,10 @@ protected function execute(InputInterface $input, OutputInterface $output) $commandOptions[$option->getName()] = false; $currentValue = $this->deploymentConfig->get($option->getConfigPath()); - if (($currentValue !== null) && ($inputOptions[$option->getName()] !== null)) { + $needOverwrite = ($currentValue !== null) && + ($inputOptions[$option->getName()] !== null) && + ($inputOptions[$option->getName()] !== $currentValue); + if ($needOverwrite) { $dialog = $this->getHelperSet()->get('question'); $question = new Question( 'Overwrite the existing configuration for ' . $option->getName() . '?[Y/n]', diff --git a/setup/src/Magento/Setup/Console/Command/RollbackCommand.php b/setup/src/Magento/Setup/Console/Command/RollbackCommand.php index a9138b9faefa1..e114c84ba79bc 100644 --- a/setup/src/Magento/Setup/Console/Command/RollbackCommand.php +++ b/setup/src/Magento/Setup/Console/Command/RollbackCommand.php @@ -80,7 +80,7 @@ public function __construct( } /** - * {@inheritdoc} + * @inheritDoc */ protected function configure() { @@ -111,7 +111,7 @@ protected function configure() } /** - * {@inheritdoc} + * @inheritDoc */ protected function execute(InputInterface $input, OutputInterface $output) { @@ -122,8 +122,9 @@ protected function execute(InputInterface $input, OutputInterface $output) // we must have an exit code higher than zero to indicate something was wrong return \Magento\Framework\Console\Cli::RETURN_FAILURE; } - $returnValue = $this->maintenanceModeEnabler->executeInMaintenanceMode( - function () use ($input, $output, &$returnValue) { + + return $this->maintenanceModeEnabler->executeInMaintenanceMode( + function () use ($input, $output) { try { $helper = $this->getHelper('question'); $question = new ConfirmationQuestion( @@ -152,7 +153,6 @@ function () use ($input, $output, &$returnValue) { $output, false ); - return $returnValue; } /** diff --git a/setup/src/Magento/Setup/Console/Command/UpgradeCommand.php b/setup/src/Magento/Setup/Console/Command/UpgradeCommand.php index 10a2ffa05a796..948e77f81fd2c 100644 --- a/setup/src/Magento/Setup/Console/Command/UpgradeCommand.php +++ b/setup/src/Magento/Setup/Console/Command/UpgradeCommand.php @@ -144,7 +144,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $searchConfig->validateSearchEngine(); $installer->removeUnusedTriggers(); $installer->installSchema($request); - $installer->installDataFixtures($request); + $installer->installDataFixtures($request, true); if ($this->deploymentConfig->isAvailable()) { $importConfigCommand = $this->getApplication()->find(ConfigImportCommand::COMMAND_NAME); diff --git a/setup/src/Magento/Setup/Console/CommandList.php b/setup/src/Magento/Setup/Console/CommandList.php index ab31a3add07ed..ae65e82bba12b 100644 --- a/setup/src/Magento/Setup/Console/CommandList.php +++ b/setup/src/Magento/Setup/Console/CommandList.php @@ -66,10 +66,6 @@ protected function getCommandsClasses() \Magento\Setup\Console\Command\ModuleStatusCommand::class, \Magento\Setup\Console\Command\ModuleUninstallCommand::class, \Magento\Setup\Console\Command\ModuleConfigStatusCommand::class, - \Magento\Setup\Console\Command\MaintenanceAllowIpsCommand::class, - \Magento\Setup\Console\Command\MaintenanceDisableCommand::class, - \Magento\Setup\Console\Command\MaintenanceEnableCommand::class, - \Magento\Setup\Console\Command\MaintenanceStatusCommand::class, \Magento\Setup\Console\Command\RollbackCommand::class, \Magento\Setup\Console\Command\UpgradeCommand::class, \Magento\Setup\Console\Command\UninstallCommand::class, diff --git a/setup/src/Magento/Setup/Console/Style/MagentoStyle.php b/setup/src/Magento/Setup/Console/Style/MagentoStyle.php index 60cfcbb67c217..43f85d092b0a7 100644 --- a/setup/src/Magento/Setup/Console/Style/MagentoStyle.php +++ b/setup/src/Magento/Setup/Console/Style/MagentoStyle.php @@ -565,6 +565,7 @@ private function createBlock( ) { $indentLength = 0; $prefixLength = Helper::strlenWithoutDecoration($this->getFormatter(), $prefix); + $lineIndentation = ''; if (null !== $type) { $type = sprintf('[%s] ', $type); $indentLength = strlen($type); @@ -605,7 +606,7 @@ private function getBlockLines( int $prefixLength, int $indentLength ) { - $lines = [[]]; + $lines = []; foreach ($messages as $key => $message) { $message = OutputFormatter::escape($message); $wordwrap = wordwrap($message, $this->lineLength - $prefixLength - $indentLength, PHP_EOL, true); @@ -614,7 +615,7 @@ private function getBlockLines( $lines[][] = ''; } } - $lines = array_merge(...$lines); + $lines = array_merge([], ...$lines); return $lines; } diff --git a/setup/src/Magento/Setup/Fixtures/AttributeSet/SwatchesGenerator.php b/setup/src/Magento/Setup/Fixtures/AttributeSet/SwatchesGenerator.php index 26e7857703b4f..56263d0ec0adb 100644 --- a/setup/src/Magento/Setup/Fixtures/AttributeSet/SwatchesGenerator.php +++ b/setup/src/Magento/Setup/Fixtures/AttributeSet/SwatchesGenerator.php @@ -91,7 +91,7 @@ function ($values, $index) use ($optionCount, $data, $type) { ); $attribute['optionvisual']['value'] = array_reduce( range(1, $optionCount), - function ($values, $index) use ($optionCount) { + function ($values, $index) { $values['option_' . $index] = ['option ' . $index]; return $values; }, @@ -129,6 +129,7 @@ private function generateSwatchImage($data) $this->imagesGenerator = $this->imagesGeneratorFactory->create(); } + // phpcs:ignore Magento2.Security.InsecureFunction $imageName = md5($data) . '.jpg'; $this->imagesGenerator->generate([ 'image-width' => self::GENERATED_SWATCH_WIDTH, diff --git a/setup/src/Magento/Setup/Fixtures/EavVariationsFixture.php b/setup/src/Magento/Setup/Fixtures/EavVariationsFixture.php index f143685f1903d..671627bcea8a9 100644 --- a/setup/src/Magento/Setup/Fixtures/EavVariationsFixture.php +++ b/setup/src/Magento/Setup/Fixtures/EavVariationsFixture.php @@ -77,7 +77,7 @@ public function __construct( } /** - * {@inheritdoc} + * @inheritDoc */ public function execute() { @@ -93,7 +93,7 @@ public function execute() } /** - * {@inheritdoc} + * @inheritDoc */ public function getActionTitle() { @@ -101,7 +101,7 @@ public function getActionTitle() } /** - * {@inheritdoc} + * @inheritDoc */ public function introduceParamLabels() { @@ -109,6 +109,8 @@ public function introduceParamLabels() } /** + * Generate Attribute + * * @param int $optionCount * @return void */ @@ -169,7 +171,7 @@ function ($values, $index) use ($optionCount) { ); $data['optionvisual']['value'] = array_reduce( range(1, $optionCount), - function ($values, $index) use ($optionCount) { + function ($values, $index) { $values['option_' . $index] = ['option ' . $index]; return $values; }, @@ -194,6 +196,8 @@ function ($values, $index) use ($optionCount) { } /** + * Get attribute code + * * @return string */ private function getAttributeCode() diff --git a/setup/src/Magento/Setup/Fixtures/ImagesFixture.php b/setup/src/Magento/Setup/Fixtures/ImagesFixture.php index 1878a48977156..cd403897de07a 100644 --- a/setup/src/Magento/Setup/Fixtures/ImagesFixture.php +++ b/setup/src/Magento/Setup/Fixtures/ImagesFixture.php @@ -8,6 +8,7 @@ use Magento\Framework\App\Filesystem\DirectoryList; use Magento\Framework\Exception\ValidatorException; +use Magento\MediaStorage\Service\ImageResize; use Symfony\Component\Console\Output\OutputInterface; /** @@ -106,6 +107,10 @@ class ImagesFixture extends Fixture * @var array */ private $tableCache = []; + /** + * @var ImageResize + */ + private $imageResize; /** * @param FixtureModel $fixtureModel @@ -117,6 +122,7 @@ class ImagesFixture extends Fixture * @param \Magento\Framework\DB\Sql\ColumnValueExpressionFactory $expressionFactory * @param \Magento\Setup\Model\BatchInsertFactory $batchInsertFactory * @param \Magento\Framework\EntityManager\MetadataPool $metadataPool + * @param ImageResize $imageResize */ public function __construct( FixtureModel $fixtureModel, @@ -127,7 +133,8 @@ public function __construct( \Magento\Eav\Model\AttributeRepository $attributeRepository, \Magento\Framework\DB\Sql\ColumnValueExpressionFactory $expressionFactory, \Magento\Setup\Model\BatchInsertFactory $batchInsertFactory, - \Magento\Framework\EntityManager\MetadataPool $metadataPool + \Magento\Framework\EntityManager\MetadataPool $metadataPool, + ImageResize $imageResize ) { parent::__construct($fixtureModel); @@ -139,6 +146,7 @@ public function __construct( $this->expressionFactory = $expressionFactory; $this->batchInsertFactory = $batchInsertFactory; $this->metadataPool = $metadataPool; + $this->imageResize = $imageResize; } /** @@ -147,9 +155,10 @@ public function __construct( */ public function execute() { - if (!$this->checkIfImagesExists()) { + if (!$this->checkIfImagesExists() && $this->getImagesToGenerate()) { $this->createImageEntities(); $this->assignImagesToProducts(); + iterator_to_array($this->imageResize->resizeFromThemes(), false); } } diff --git a/setup/src/Magento/Setup/Fixtures/ImagesGenerator/ImagesGenerator.php b/setup/src/Magento/Setup/Fixtures/ImagesGenerator/ImagesGenerator.php index cfcdebd4ac373..dc730b69f8775 100644 --- a/setup/src/Magento/Setup/Fixtures/ImagesGenerator/ImagesGenerator.php +++ b/setup/src/Magento/Setup/Fixtures/ImagesGenerator/ImagesGenerator.php @@ -36,9 +36,9 @@ public function __construct( /** * Generates image from $data and puts its to /tmp folder - * - * @param string $config + * @param array $config * @return string $imagePath + * @throws \Exception */ public function generate($config) { @@ -70,9 +70,15 @@ public function generate($config) $relativePathToMedia = $mediaDirectory->getRelativePath($this->mediaConfig->getBaseTmpMediaPath()); $mediaDirectory->create($relativePathToMedia); - $absolutePathToMedia = $mediaDirectory->getAbsolutePath($this->mediaConfig->getBaseTmpMediaPath()); - $imagePath = $absolutePathToMedia . DIRECTORY_SEPARATOR . $config['image-name']; - imagejpeg($image, $imagePath, 100); + $imagePath = $relativePathToMedia . DIRECTORY_SEPARATOR . $config['image-name']; + $imagePath = preg_replace('|/{2,}|', '/', $imagePath); + $memory = fopen('php://memory', 'r+'); + if(!imagejpeg($image, $memory)) { + throw new \Exception('Could not create picture ' . $imagePath); + } + $mediaDirectory->writeFile($imagePath, stream_get_contents($memory, -1, 0)); + fclose($memory); + imagedestroy($image); // phpcs:enable return $imagePath; diff --git a/setup/src/Magento/Setup/Model/ConfigGenerator.php b/setup/src/Magento/Setup/Model/ConfigGenerator.php index 09d15489812e2..57f462c747ee4 100644 --- a/setup/src/Magento/Setup/Model/ConfigGenerator.php +++ b/setup/src/Magento/Setup/Model/ConfigGenerator.php @@ -140,7 +140,7 @@ public function createSessionConfig(array $data) * Creates definitions config data * * @param array $data - * @return ConfigData + * @return ConfigData|null * @deprecated 2.2.0 * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ diff --git a/setup/src/Magento/Setup/Model/ConfigOptionsList.php b/setup/src/Magento/Setup/Model/ConfigOptionsList.php index a8d0a8591f539..f2135d8bf5202 100644 --- a/setup/src/Magento/Setup/Model/ConfigOptionsList.php +++ b/setup/src/Magento/Setup/Model/ConfigOptionsList.php @@ -205,12 +205,12 @@ public function getOptions() ), ]; + $options = [$options]; foreach ($this->configOptionsCollection as $configOptionsList) { - // phpcs:ignore Magento2.Performance.ForeachArrayMerge - $options = array_merge($options, $configOptionsList->getOptions()); + $options[] = $configOptionsList->getOptions(); } - return $options; + return array_merge([], ...$options); } /** @@ -245,34 +245,24 @@ public function validate(array $options, DeploymentConfig $deploymentConfig) $errors = []; if (isset($options[ConfigOptionsListConstants::INPUT_KEY_CACHE_HOSTS])) { - $errors = array_merge( - $errors, - $this->validateHttpCacheHosts($options[ConfigOptionsListConstants::INPUT_KEY_CACHE_HOSTS]) - ); + $errors[] = $this->validateHttpCacheHosts($options[ConfigOptionsListConstants::INPUT_KEY_CACHE_HOSTS]); } if (isset($options[ConfigOptionsListConstants::INPUT_KEY_DB_PREFIX])) { - $errors = array_merge( - $errors, - $this->validateDbPrefix($options[ConfigOptionsListConstants::INPUT_KEY_DB_PREFIX]) - ); + $errors[] = $this->validateDbPrefix($options[ConfigOptionsListConstants::INPUT_KEY_DB_PREFIX]); } if (!$options[ConfigOptionsListConstants::INPUT_KEY_SKIP_DB_VALIDATION]) { - $errors = array_merge($errors, $this->validateDbSettings($options, $deploymentConfig)); + $errors[] = $this->validateDbSettings($options, $deploymentConfig); } foreach ($this->configOptionsCollection as $configOptionsList) { - // phpcs:ignore Magento2.Performance.ForeachArrayMerge - $errors = array_merge($errors, $configOptionsList->validate($options, $deploymentConfig)); + $errors[] = $configOptionsList->validate($options, $deploymentConfig); } - $errors = array_merge( - $errors, - $this->validateEncryptionKey($options) - ); + $errors[] = $this->validateEncryptionKey($options); - return $errors; + return array_merge([], ...$errors); } /** diff --git a/setup/src/Magento/Setup/Model/ConfigOptionsList/Directory.php b/setup/src/Magento/Setup/Model/ConfigOptionsList/Directory.php index e838dbee33603..0e9cc65f17bd9 100644 --- a/setup/src/Magento/Setup/Model/ConfigOptionsList/Directory.php +++ b/setup/src/Magento/Setup/Model/ConfigOptionsList/Directory.php @@ -15,6 +15,7 @@ /** * Deployment configuration options for the folders. + * @deprecared Magento always uses the pub directory */ class Directory implements ConfigOptionsListInterface { @@ -70,7 +71,7 @@ public function getOptions() $this->selectOptions, self::CONFIG_PATH_DOCUMENT_ROOT_IS_PUB, 'Flag to show is Pub is on root, can be true or false only', - false + true ), ]; } diff --git a/setup/src/Magento/Setup/Model/Installer.php b/setup/src/Magento/Setup/Model/Installer.php index 296782c3873c0..734df8ba84e78 100644 --- a/setup/src/Magento/Setup/Model/Installer.php +++ b/setup/src/Magento/Setup/Model/Installer.php @@ -8,6 +8,7 @@ use Magento\Backend\Setup\ConfigOptionsList as BackendConfigOptionsList; use Magento\Framework\App\Cache\Manager; +use Magento\Framework\App\Cache\Manager as CacheManager; use Magento\Framework\App\Cache\Type\Block as BlockCache; use Magento\Framework\App\Cache\Type\Config as ConfigCache; use Magento\Framework\App\Cache\Type\Layout as LayoutCache; @@ -920,11 +921,12 @@ private function convertationOfOldScriptsIsAllowed(array $request) * Installs data fixtures * * @param array $request + * @param boolean $keepCacheStatuses * @return void * @throws Exception * @throws \Magento\Framework\Setup\Exception */ - public function installDataFixtures(array $request = []) + public function installDataFixtures(array $request = [], $keepCacheStatuses = false) { $frontendCaches = [ PageCache::TYPE_IDENTIFIER, @@ -932,6 +934,12 @@ public function installDataFixtures(array $request = []) LayoutCache::TYPE_IDENTIFIER, ]; + if ($keepCacheStatuses) { + $disabledCaches = $this->getDisabledCacheTypes($frontendCaches); + + $frontendCaches = array_diff($frontendCaches, $disabledCaches); + } + /** @var \Magento\Framework\Registry $registry */ $registry = $this->objectManagerProvider->get()->get(\Magento\Framework\Registry::class); //For backward compatibility in install and upgrade scripts with enabled parallelization. @@ -942,11 +950,20 @@ public function installDataFixtures(array $request = []) $setup = $this->dataSetupFactory->create(); $this->checkFilePermissionsForDbUpgrade(); $this->log->log('Data install/update:'); - $this->log->log('Disabling caches:'); - $this->updateCaches(false, $frontendCaches); - $this->handleDBSchemaData($setup, 'data', $request); - $this->log->log('Enabling caches:'); - $this->updateCaches(true, $frontendCaches); + + if ($frontendCaches) { + $this->log->log('Disabling caches:'); + $this->updateCaches(false, $frontendCaches); + } + + try { + $this->handleDBSchemaData($setup, 'data', $request); + } finally { + if ($frontendCaches) { + $this->log->log('Enabling caches:'); + $this->updateCaches(true, $frontendCaches); + } + } $registry->unregister('setup-mode-enabled'); } @@ -995,7 +1012,7 @@ private function throwExceptionForNotWritablePaths(array $paths) */ private function handleDBSchemaData($setup, $type, array $request) { - if (!($type === 'schema' || $type === 'data')) { + if ($type !== 'schema' && $type !== 'data') { // phpcs:ignore Magento2.Exceptions.DirectThrow throw new Exception("Unsupported operation type $type is requested"); } @@ -1014,17 +1031,13 @@ private function handleDBSchemaData($setup, $type, array $request) 'objectManager' => $this->objectManagerProvider->get() ] ); + + $patchApplierParams = $type === 'schema' ? + ['schemaSetup' => $setup] : + ['moduleDataSetup' => $setup, 'objectManager' => $this->objectManagerProvider->get()]; + /** @var PatchApplier $patchApplier */ - if ($type === 'schema') { - $patchApplier = $this->patchApplierFactory->create(['schemaSetup' => $setup]); - } elseif ($type === 'data') { - $patchApplier = $this->patchApplierFactory->create( - [ - 'moduleDataSetup' => $setup, - 'objectManager' => $this->objectManagerProvider->get() - ] - ); - } + $patchApplier = $this->patchApplierFactory->create($patchApplierParams); foreach ($moduleNames as $moduleName) { if ($this->isDryRun($request)) { @@ -1086,11 +1099,11 @@ private function handleDBSchemaData($setup, $type, array $request) if ($type === 'schema') { $this->log->log('Schema post-updates:'); - $handlerType = 'schema-recurring'; } elseif ($type === 'data') { $this->log->log('Data post-updates:'); - $handlerType = 'data-recurring'; } + $handlerType = $type === 'schema' ? 'schema-recurring' : 'data-recurring'; + foreach ($moduleNames as $moduleName) { if ($this->isDryRun($request)) { $this->log->log("Module '{$moduleName}':"); @@ -1726,4 +1739,27 @@ public function removeUnusedTriggers(): void $this->triggerCleaner->removeTriggers(); $this->cleanCaches(); } + + /** + * Returns list of disabled cache types + * + * @param array $cacheTypesToCheck + * @return array + */ + private function getDisabledCacheTypes(array $cacheTypesToCheck): array + { + $disabledCaches = []; + + /** @var CacheManager $cacheManager */ + $cacheManager = $this->objectManagerProvider->get()->create(CacheManager::class); + $cacheStatus = $cacheManager->getStatus(); + + foreach ($cacheTypesToCheck as $cacheType) { + if (isset($cacheStatus[$cacheType]) && $cacheStatus[$cacheType] === 0) { + $disabledCaches[] = $cacheType; + } + } + + return $disabledCaches; + } } diff --git a/setup/src/Magento/Setup/Module/Dependency/ServiceLocator.php b/setup/src/Magento/Setup/Module/Dependency/ServiceLocator.php index 27f0c7e8e616f..d162d07b38cf8 100644 --- a/setup/src/Magento/Setup/Module/Dependency/ServiceLocator.php +++ b/setup/src/Magento/Setup/Module/Dependency/ServiceLocator.php @@ -95,7 +95,7 @@ public static function getCircularDependenciesReportBuilder() self::$circularDependenciesReportBuilder = new CircularReport\Builder( self::getComposerJsonParser(), new CircularReport\Writer(self::getCsvWriter()), - new CircularTool([], null) + new CircularTool() ); } return self::$circularDependenciesReportBuilder; diff --git a/setup/src/Magento/Setup/Module/Di/Code/Scanner/PhpScanner.php b/setup/src/Magento/Setup/Module/Di/Code/Scanner/PhpScanner.php index acb55e29afddd..7355ac30ac59d 100644 --- a/setup/src/Magento/Setup/Module/Di/Code/Scanner/PhpScanner.php +++ b/setup/src/Magento/Setup/Module/Di/Code/Scanner/PhpScanner.php @@ -175,7 +175,7 @@ protected function _fetchMissingExtensionAttributesClasses($reflectionClass, $fi */ public function collectEntities(array $files) { - $output = [[]]; + $output = []; foreach ($files as $file) { $classes = $this->getDeclaredClasses($file); foreach ($classes as $className) { @@ -184,7 +184,7 @@ public function collectEntities(array $files) $output[] = $this->_fetchMissingExtensionAttributesClasses($reflectionClass, $file); } } - return array_unique(array_merge(...$output)); + return array_unique(array_merge([], ...$output)); } /** diff --git a/setup/src/Magento/Setup/Module/I18n/Parser/Adapter/Html.php b/setup/src/Magento/Setup/Module/I18n/Parser/Adapter/Html.php index cf38fd70884f3..ec62ab8b84482 100644 --- a/setup/src/Magento/Setup/Module/I18n/Parser/Adapter/Html.php +++ b/setup/src/Magento/Setup/Module/I18n/Parser/Adapter/Html.php @@ -3,8 +3,11 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); + namespace Magento\Setup\Module\I18n\Parser\Adapter; +use Exception; use Magento\Email\Model\Template\Filter; /** @@ -16,17 +19,30 @@ class Html extends AbstractAdapter * Covers * *
+ * @deprecated Not used anymore because of newly introduced constant + * @see self::HTML_REGEX_LIST */ const HTML_FILTER = "/i18n:\s?'(?[^'\\\\]*(?:\\\\.[^'\\\\]*)*)'/i"; + private const HTML_REGEX_LIST = [ + // + // + "/i18n:\s?'(?[^'\\\\]*(?:\\\\.[^'\\\\]*)*)'/i", + // + // + "/translate( args|)=\"'(?[^\"\\\\]*(?:\\\\.[^\"\\\\]*)*)'\"/i" + ]; + /** * @inheritdoc */ protected function _parse() { + // phpcs:ignore Magento2.Functions.DiscouragedFunction $data = file_get_contents($this->_file); if ($data === false) { - throw new \Exception('Failed to load file from disk.'); + // phpcs:ignore Magento2.Exceptions.DirectThrow + throw new Exception('Failed to load file from disk.'); } $results = []; @@ -37,15 +53,19 @@ protected function _parse() if (preg_match(Filter::TRANS_DIRECTIVE_REGEX, $results[$i][2], $directive) !== 1) { continue; } + $quote = $directive[1]; $this->_addPhrase($quote . $directive[2] . $quote); } } - preg_match_all(self::HTML_FILTER, $data, $results, PREG_SET_ORDER); - for ($i = 0, $count = count($results); $i < $count; $i++) { - if (!empty($results[$i]['value'])) { - $this->_addPhrase($results[$i]['value']); + foreach (self::HTML_REGEX_LIST as $regex) { + preg_match_all($regex, $data, $results, PREG_SET_ORDER); + + for ($i = 0, $count = count($results); $i < $count; $i++) { + if (!empty($results[$i]['value'])) { + $this->_addPhrase($results[$i]['value']); + } } } } diff --git a/setup/src/Magento/Setup/Test/Unit/Model/InstallerTest.php b/setup/src/Magento/Setup/Test/Unit/Model/InstallerTest.php index 48afa684bb9d2..99d8d566323a1 100644 --- a/setup/src/Magento/Setup/Test/Unit/Model/InstallerTest.php +++ b/setup/src/Magento/Setup/Test/Unit/Model/InstallerTest.php @@ -62,7 +62,18 @@ class InstallerTest extends TestCase { /** - * @var \Magento\Setup\Model\Installer + * @var array + */ + private $request = [ + ConfigOptionsListConstants::INPUT_KEY_DB_HOST => '127.0.0.1', + ConfigOptionsListConstants::INPUT_KEY_DB_NAME => 'magento', + ConfigOptionsListConstants::INPUT_KEY_DB_USER => 'magento', + ConfigOptionsListConstants::INPUT_KEY_ENCRYPTION_KEY => 'encryption_key', + ConfigOptionsList::INPUT_KEY_BACKEND_FRONTNAME => 'backend', + ]; + + /** + * @var Installer */ private $object; @@ -426,13 +437,7 @@ public function installDataProvider() { return [ [ - 'request' => [ - ConfigOptionsListConstants::INPUT_KEY_DB_HOST => '127.0.0.1', - ConfigOptionsListConstants::INPUT_KEY_DB_NAME => 'magento', - ConfigOptionsListConstants::INPUT_KEY_DB_USER => 'magento', - ConfigOptionsListConstants::INPUT_KEY_ENCRYPTION_KEY => 'encryption_key', - ConfigOptionsList::INPUT_KEY_BACKEND_FRONTNAME => 'backend', - ], + 'request' => $this->request, 'logMessages' => [ ['Starting Magento installation:'], ['File permissions check...'], @@ -526,6 +531,100 @@ public function installDataProvider() ]; } + /** + * Test for InstallDataFixtures + * + * @dataProvider testInstallDataFixturesDataProvider + * + * @param bool $keepCache + * @param array $expectedToEnableCacheTypes + * @return void + */ + public function testInstallDataFixtures(bool $keepCache, array $expectedToEnableCacheTypes): void + { + $cacheManagerMock = $this->createMock(Manager::class); + //simulate disabled layout cache type + $cacheManagerMock->expects($this->atLeastOnce()) + ->method('getStatus') + ->willReturn(['layout' => 0]); + $cacheManagerMock->expects($this->atLeastOnce()) + ->method('getAvailableTypes') + ->willReturn(['block_html', 'full_page', 'layout' , 'config', 'collections']); + $cacheManagerMock->expects($this->exactly(2)) + ->method('setEnabled') + ->withConsecutive([$expectedToEnableCacheTypes, false], [$expectedToEnableCacheTypes, true]) + ->willReturn([]); + + $this->objectManager->expects($this->atLeastOnce()) + ->method('create') + ->willReturnMap([ + [Manager::class, [], $cacheManagerMock], + [ + PatchApplierFactory::class, + ['objectManager' => $this->objectManager], + $this->patchApplierFactoryMock + ], + ]); + + $registryMock = $this->createMock(Registry::class); + $this->objectManager->expects($this->atLeastOnce()) + ->method('get') + ->with(Registry::class) + ->willReturn($registryMock); + + $this->config->expects($this->atLeastOnce()) + ->method('get') + ->willReturn(true); + + $this->filePermissions->expects($this->atLeastOnce()) + ->method('getMissingWritableDirectoriesForDbUpgrade') + ->willReturn([]); + + $connection = $this->getMockBuilder(AdapterInterface::class) + ->addMethods(['getSchemaListener']) + ->getMockForAbstractClass(); + $connection->expects($this->once()) + ->method('getSchemaListener') + ->willReturn($this->schemaListenerMock); + + $resource = $this->createMock(ResourceConnection::class); + $resource->expects($this->atLeastOnce()) + ->method('getConnection') + ->willReturn($connection); + $this->contextMock->expects($this->once()) + ->method('getResources') + ->willReturn($resource); + + $dataSetup = $this->createMock(DataSetup::class); + $dataSetup->expects($this->once()) + ->method('getConnection') + ->willReturn($connection); + + $this->dataSetupFactory->expects($this->atLeastOnce()) + ->method('create') + ->willReturn($dataSetup); + + $this->object->installDataFixtures($this->request, $keepCache); + } + + /** + * DataProvider for testInstallDataFixtures + * + * @return array + */ + public function testInstallDataFixturesDataProvider(): array + { + return [ + 'keep cache' => [ + true, ['block_html', 'full_page'] + ], + 'do not keep a cache' => [ + false, + ['block_html', 'full_page', 'layout'] + ], + ]; + } + public function testCheckInstallationFilePermissions() { $this->filePermissions diff --git a/setup/src/Magento/Setup/Test/Unit/Model/ModuleUninstallerTest.php b/setup/src/Magento/Setup/Test/Unit/Model/ModuleUninstallerTest.php index bc0050513ad85..b5e616a95437c 100644 --- a/setup/src/Magento/Setup/Test/Unit/Model/ModuleUninstallerTest.php +++ b/setup/src/Magento/Setup/Test/Unit/Model/ModuleUninstallerTest.php @@ -15,7 +15,6 @@ use Magento\Framework\Setup\Patch\PatchApplier; use Magento\Framework\Setup\UninstallInterface; use Magento\Setup\Model\ModuleContext; -use Magento\Setup\Model\ModuleRegistryUninstaller; use Magento\Setup\Model\ModuleUninstaller; use Magento\Setup\Model\ObjectManagerProvider; use Magento\Setup\Model\UninstallCollector; @@ -60,11 +59,6 @@ class ModuleUninstallerTest extends TestCase */ private $output; - /** - * @var MockObject|ModuleRegistryUninstaller - */ - private $moduleRegistryUninstaller; - /** * @var PatchApplier|MockObject */ @@ -72,7 +66,6 @@ class ModuleUninstallerTest extends TestCase protected function setUp(): void { - $this->moduleRegistryUninstaller = $this->createMock(ModuleRegistryUninstaller::class); $this->objectManager = $this->getMockForAbstractClass( ObjectManagerInterface::class, [], @@ -94,8 +87,7 @@ protected function setUp(): void $objectManagerProvider, $this->remove, $this->collector, - $setupFactory, - $this->moduleRegistryUninstaller + $setupFactory ); $this->output = $this->getMockForAbstractClass(OutputInterface::class); @@ -103,7 +95,6 @@ protected function setUp(): void public function testUninstallRemoveData() { - $this->moduleRegistryUninstaller->expects($this->never())->method($this->anything()); $uninstall = $this->getMockForAbstractClass(UninstallInterface::class, [], '', false); $uninstall->expects($this->atLeastOnce()) ->method('uninstall') @@ -136,7 +127,6 @@ public function testUninstallRemoveData() public function testUninstallRemoveCode() { - $this->moduleRegistryUninstaller->expects($this->never())->method($this->anything()); $this->output->expects($this->once())->method('writeln'); $packageInfoFactory = $this->createMock(PackageInfoFactory::class); $packageInfo = $this->createMock(PackageInfo::class); diff --git a/setup/src/Magento/Setup/Test/Unit/Module/Di/App/Task/OperationFactoryTest.php b/setup/src/Magento/Setup/Test/Unit/Module/Di/App/Task/OperationFactoryTest.php index 4682b0b035798..b2af18b34959a 100644 --- a/setup/src/Magento/Setup/Test/Unit/Module/Di/App/Task/OperationFactoryTest.php +++ b/setup/src/Magento/Setup/Test/Unit/Module/Di/App/Task/OperationFactoryTest.php @@ -65,8 +65,7 @@ public function testCreateException() $notRegisteredOperation = 'coffee'; $this->expectException(OperationException::class); $this->expectExceptionMessage( - sprintf('Unrecognized operation "%s"', $notRegisteredOperation), - OperationException::UNAVAILABLE_OPERATION + sprintf('Unrecognized operation "%s"', $notRegisteredOperation) ); $this->factory->create($notRegisteredOperation); } diff --git a/setup/src/Magento/Setup/Test/Unit/Module/I18n/Parser/Adapter/HtmlTest.php b/setup/src/Magento/Setup/Test/Unit/Module/I18n/Parser/Adapter/HtmlTest.php index 15c442e9bac98..d7a2f0b4a9397 100644 --- a/setup/src/Magento/Setup/Test/Unit/Module/I18n/Parser/Adapter/HtmlTest.php +++ b/setup/src/Magento/Setup/Test/Unit/Module/I18n/Parser/Adapter/HtmlTest.php @@ -7,33 +7,25 @@ namespace Magento\Setup\Test\Unit\Module\I18n\Parser\Adapter; -use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; use Magento\Setup\Module\I18n\Parser\Adapter\Html; use PHPUnit\Framework\TestCase; class HtmlTest extends TestCase { /** - * @var string - */ - protected $_testFile; - - /** - * @var int + * @var Html */ - protected $_stringsCount; + private $model; /** - * @var Html + * @var string */ - protected $_adapter; + private $testFile; protected function setUp(): void { - $this->_testFile = str_replace('\\', '/', realpath(dirname(__FILE__))) . '/_files/email.html'; - $this->_stringsCount = count(file($this->_testFile)); - - $this->_adapter = (new ObjectManager($this))->getObject(Html::class); + $this->testFile = str_replace('\\', '/', realpath(__DIR__)) . '/_files/email.html'; + $this->model = new Html(); } public function testParse() @@ -41,68 +33,80 @@ public function testParse() $expectedResult = [ [ 'phrase' => 'Phrase 1', - 'file' => $this->_testFile, + 'file' => $this->testFile, 'line' => '', 'quote' => '\'', ], [ 'phrase' => 'Phrase 2 with %a_lot of extra info for the brilliant %customer_name.', - 'file' => $this->_testFile, + 'file' => $this->testFile, 'line' => '', 'quote' => '"', ], [ 'phrase' => 'This is test data', - 'file' => $this->_testFile, + 'file' => $this->testFile, 'line' => '', 'quote' => '', ], [ 'phrase' => 'This is test data at right side of attr', - 'file' => $this->_testFile, + 'file' => $this->testFile, 'line' => '', 'quote' => '', ], [ 'phrase' => 'This is \\\' test \\\' data', - 'file' => $this->_testFile, + 'file' => $this->testFile, 'line' => '', 'quote' => '', ], [ 'phrase' => 'This is \\" test \\" data', - 'file' => $this->_testFile, + 'file' => $this->testFile, 'line' => '', 'quote' => '', ], [ 'phrase' => 'This is test data with a quote after', - 'file' => $this->_testFile, + 'file' => $this->testFile, 'line' => '', 'quote' => '', ], [ 'phrase' => 'This is test data with space after ', - 'file' => $this->_testFile, + 'file' => $this->testFile, 'line' => '', 'quote' => '', ], [ 'phrase' => '\\\'', - 'file' => $this->_testFile, + 'file' => $this->testFile, 'line' => '', 'quote' => '', ], [ 'phrase' => '\\\\\\\\ ', - 'file' => $this->_testFile, + 'file' => $this->testFile, + 'line' => '', + 'quote' => '', + ], + [ + 'phrase' => 'This is test content in translate tag', + 'file' => $this->testFile, + 'line' => '', + 'quote' => '', + ], + [ + 'phrase' => 'This is test content in translate attribute', + 'file' => $this->testFile, 'line' => '', 'quote' => '', ], ]; - $this->_adapter->parse($this->_testFile); + $this->model->parse($this->testFile); - $this->assertEquals($expectedResult, $this->_adapter->getPhrases()); + $this->assertEquals($expectedResult, $this->model->getPhrases()); } } diff --git a/setup/src/Magento/Setup/Test/Unit/Module/I18n/Parser/Adapter/_files/email.html b/setup/src/Magento/Setup/Test/Unit/Module/I18n/Parser/Adapter/_files/email.html index 90579b48a07b5..f5603768ef306 100644 --- a/setup/src/Magento/Setup/Test/Unit/Module/I18n/Parser/Adapter/_files/email.html +++ b/setup/src/Magento/Setup/Test/Unit/Module/I18n/Parser/Adapter/_files/email.html @@ -29,5 +29,7 @@ + +