diff --git a/app/code/Magento/CatalogGraphQl/Observer/AfterImportDataObserver.php b/app/code/Magento/CatalogGraphQl/Observer/AfterImportDataObserver.php index 06f7809eeb1a5..de290372a31f0 100644 --- a/app/code/Magento/CatalogGraphQl/Observer/AfterImportDataObserver.php +++ b/app/code/Magento/CatalogGraphQl/Observer/AfterImportDataObserver.php @@ -16,7 +16,7 @@ use Magento\GraphQlResolverCache\Model\Resolver\Result\Type as GraphQlResolverCache; /** - * Clean media gallery resolver cache for product SKUs after importing data to database + * Clean media gallery resolver cache after product import */ class AfterImportDataObserver implements ObserverInterface { @@ -58,10 +58,12 @@ public function execute(Observer $observer) $mediaGalleryEntriesChanged = (array) $observer->getEvent()->getMediaGallery(); $mediaGalleryLabelsChanged = (array) $observer->getEvent()->getMediaGalleryLabels(); $productIdsToDelete = (array) $observer->getEvent()->getIdsToDelete(); + $mediaGalleryRemovedSkus = (array) $observer->getEvent()->getMediaGalleryRemovedSkus(); if (empty($mediaGalleryEntriesChanged) && empty($mediaGalleryLabelsChanged) && - empty($productIdsToDelete) + empty($productIdsToDelete) && + empty($mediaGalleryRemovedSkus) ) { return; } @@ -76,16 +78,29 @@ public function execute(Observer $observer) $productSkusToInvalidate[] = [$label['imageData']['sku']]; } - $productSkusToInvalidate = array_unique(array_merge(...$productSkusToInvalidate)); - $products = $this->productRepository->getList( - $this->criteriaBuilder->addFilter('sku', $productSkusToInvalidate, 'in')->create() - )->getItems(); + if (!empty($mediaGalleryRemovedSkus)) { + $productSkusToInvalidate[] = array_values($mediaGalleryRemovedSkus); + } + + $productSkusToInvalidate = $productSkusToInvalidate + ? array_unique(array_merge(...$productSkusToInvalidate)) + : []; + + $productIds = []; + if (!empty($productSkusToInvalidate)) { + $products = $this->productRepository->getList( + $this->criteriaBuilder->addFilter('sku', $productSkusToInvalidate, 'in')->create() + )->getItems(); - $productIds = array_map(function ($product) { - return $product->getId(); - }, $products); + $productIds = array_map(function ($product) { + return (int) $product->getId(); + }, $products); + } $productIdsToInvalidate = array_unique(array_merge($productIds, $productIdsToDelete)); + if (empty($productIdsToInvalidate)) { + return; + } $tags = array_map(function ($productId) { return sprintf('%s_%s', ResolverCacheIdentity::CACHE_TAG, $productId); diff --git a/app/code/Magento/CatalogImportExport/Model/Import/Product.php b/app/code/Magento/CatalogImportExport/Model/Import/Product.php index 4facd428ccca7..6dd9e868f6b55 100644 --- a/app/code/Magento/CatalogImportExport/Model/Import/Product.php +++ b/app/code/Magento/CatalogImportExport/Model/Import/Product.php @@ -15,6 +15,7 @@ use Magento\CatalogImportExport\Model\Import\Product\ImageTypeProcessor; use Magento\CatalogImportExport\Model\Import\Product\LinkProcessor; use Magento\CatalogImportExport\Model\Import\Product\MediaGalleryProcessor; +use Magento\CatalogImportExport\Model\Import\Product\MediaGalleryReplaceCoordinator; use Magento\CatalogImportExport\Model\Import\Product\RowValidatorInterface as ValidatorInterface; use Magento\CatalogImportExport\Model\Import\Product\Skip; use Magento\CatalogImportExport\Model\Import\Product\SkuStorage; @@ -764,6 +765,11 @@ class Product extends AbstractEntity */ private $productRepository; + /** + * @var MediaGalleryReplaceCoordinator + */ + private $mediaGalleryReplace; + /** * @var StatusProcessor */ @@ -850,6 +856,7 @@ class Product extends AbstractEntity * @param StockItemProcessorInterface|null $stockItemProcessor * @param SkuStorage|null $skuStorage * @param DomainValidator|null $domainValidator + * @param MediaGalleryReplaceCoordinator|null $mediaGalleryReplace * @throws LocalizedException * @throws \Magento\Framework\Exception\FileSystemException * @SuppressWarnings(PHPMD.ExcessiveParameterList) @@ -907,7 +914,8 @@ public function __construct( ?File $fileDriver = null, ?StockItemProcessorInterface $stockItemProcessor = null, ?SkuStorage $skuStorage = null, - ?DomainValidator $domainValidator = null + ?DomainValidator $domainValidator = null, + ?MediaGalleryReplaceCoordinator $mediaGalleryReplace = null ) { $this->_eventManager = $eventManager; $this->stockRegistry = $stockRegistry; @@ -941,6 +949,8 @@ public function __construct( $this->catalogConfig = $catalogConfig ?: ObjectManager::getInstance()->get(CatalogConfig::class); $this->imageTypeProcessor = $imageTypeProcessor ?: ObjectManager::getInstance()->get(ImageTypeProcessor::class); $this->mediaProcessor = $mediaProcessor ?: ObjectManager::getInstance()->get(MediaGalleryProcessor::class); + $this->mediaGalleryReplace = $mediaGalleryReplace + ?: ObjectManager::getInstance()->get(MediaGalleryReplaceCoordinator::class); $this->stockItemImporter = $stockItemImporter ?: ObjectManager::getInstance() ->get(StockItemImporterInterface::class); $this->statusProcessor = $statusProcessor ?: ObjectManager::getInstance() @@ -1677,6 +1687,10 @@ protected function _saveProducts() $previousType = null; $prevAttributeSet = null; $productMediaPath = $this->getProductMediaPath(); + $this->mediaGalleryReplace->configure( + $this->isImageReplaceMode(), + $this->getMediaImageRoleAttributeCodes() + ); while ($bunch = $this->_dataSourceModel->getNextUniqueBunch($this->getIds())) { $entityRowsIn = []; $entityRowsUp = []; @@ -1688,6 +1702,10 @@ protected function _saveProducts() $imagesForChangeVisibility = []; $uploadedImages = []; $existingImages = $this->getExistingImages($bunch); + $this->mediaGalleryReplace->planRoleAssignments( + $this->extractImageRoleAssignmentsFromBunch($bunch) + ); + $this->mediaGalleryReplace->warmRolesCache(array_column($bunch, self::COL_SKU)); $attributes = []; foreach ($bunch as $rowNum => $rowData) { try { @@ -1776,11 +1794,68 @@ protected function _saveProducts() 'bunch' => $bunch, 'media_gallery' => $mediaGallery, 'media_gallery_labels' => $labelsForUpdate, + 'media_gallery_removed_skus' => [], ] ); } + $this->applyDeferredMediaGalleryReplaceRemovals(); return $this; } + + /** + * Apply deferred replace-mode gallery unlinks. + * + * @return void + */ + private function applyDeferredMediaGalleryReplaceRemovals(): void + { + if (!$this->mediaGalleryReplace->hasRegisteredProducts()) { + return; + } + $existingImages = $this->getExistingImages( + array_map( + static function (string $sku): array { + return [self::COL_SKU => $sku]; + }, + $this->mediaGalleryReplace->getRegisteredSkus() + ) + ); + foreach ($this->mediaGalleryReplace->getSkusWithSkippedRemovals() as $sku) { + $this->addRowError( + 'productImageReplaceSkippedDueToUploadFailure', + null, + null, + (string)__( + 'Gallery replace was skipped for SKU "%1" because one or more images could not be loaded. ' + . 'Existing images were kept. Fix the files and import again.', + $sku + ), + ProcessingError::ERROR_LEVEL_NOT_CRITICAL + ); + } + [$imagesToRemove, $mediaGalleryRemovedSkus] = $this->mediaGalleryReplace->collectRemovals( + $existingImages + ); + if ($imagesToRemove) { + $this->mediaProcessor->removeProductImages( + $imagesToRemove, + $this->shouldDeleteUnusedImageFiles() + ); + } + if ($mediaGalleryRemovedSkus === []) { + return; + } + $this->_eventManager->dispatch( + 'catalog_product_import_bunch_save_after', + [ + 'adapter' => $this, + 'bunch' => [], + 'media_gallery' => [], + 'media_gallery_labels' => [], + 'media_gallery_removed_skus' => $mediaGalleryRemovedSkus, + ] + ); + } //phpcs:enable Generic.Metrics.NestingLevel // phpcs:enable @@ -1959,8 +2034,10 @@ private function saveProductMediaGalleryPhase( $rowImages[self::COL_MEDIA_IMAGE][] = $image; } } + $this->mediaGalleryReplace->registerProduct($rowSku, $rowData, $storeId); $rowData[self::COL_MEDIA_IMAGE] = []; list($rowImages, $rowData) = $this->clearNoSelectionImages($rowImages, $rowData); + /* * Note: to avoid problems with undefined sorting, the value of media gallery items positions * must be unique in scope of one product. @@ -1990,6 +2067,7 @@ private function saveProductMediaGalleryPhase( $uploadedImages[$columnImage] = $uploadedFile; } else { unset($rowData[$column]); + $this->mediaGalleryReplace->markUploadFailed((string)$rowSku); $this->addRowError( ValidatorInterface::ERROR_MEDIA_URL_NOT_ACCESSIBLE, $rowNum, @@ -2012,6 +2090,7 @@ private function saveProductMediaGalleryPhase( continue; } $uploadedFileNormalized = ltrim($uploadedFile, '/\\'); + $this->mediaGalleryReplace->keepPath($rowSku, $uploadedFileNormalized); if (isset($rowExistingImages[$uploadedFileNormalized])) { $currentFileData = $rowExistingImages[$uploadedFileNormalized]; $currentFileData['store_id'] = $storeId; @@ -2062,6 +2141,89 @@ private function saveProductMediaGalleryPhase( } } + /** + * Check if product image replace mode is active. + * + * @return bool + */ + private function isImageReplaceMode(): bool + { + $mode = $this->_parameters[Import::FIELD_NAME_PRODUCT_IMAGE_IMPORT_MODE] + ?? Import::PRODUCT_IMAGE_IMPORT_MODE_ADD; + + return $mode === Import::PRODUCT_IMAGE_IMPORT_MODE_REPLACE + && $this->getBehavior() === Import::BEHAVIOR_APPEND; + } + + /** + * Check if unused media files should be deleted after replace. + * + * @return bool + */ + private function shouldDeleteUnusedImageFiles(): bool + { + if (!$this->isImageReplaceMode()) { + return false; + } + $flag = $this->_parameters[Import::FIELD_NAME_PRODUCT_IMAGE_DELETE_UNUSED] ?? false; + + return $flag === true + || $flag === 1 + || $flag === '1' + || $flag === 'true' + || $flag === 'on'; + } + + /** + * Get media image role attribute codes. + * + * @return string[] + */ + private function getMediaImageRoleAttributeCodes(): array + { + return array_values( + array_filter( + $this->_imagesArrayKeys, + static function (string $code): bool { + return $code !== self::COL_MEDIA_IMAGE; + } + ) + ); + } + + /** + * Collect image-role column values from all store rows in the bunch. + * + * Used so replace-mode protection reflects multi-store role reassignments in + * the same import batch, not only pre-import DB values. + * + * @param array $bunch + * @return array>> lowercase sku => store_id => code => value + */ + private function extractImageRoleAssignmentsFromBunch(array $bunch): array + { + $roleCodes = $this->getMediaImageRoleAttributeCodes(); + if ($roleCodes === []) { + return []; + } + $assignments = []; + foreach ($bunch as $rowData) { + if (empty($rowData[self::COL_SKU])) { + continue; + } + $skuKey = mb_strtolower((string)$rowData[self::COL_SKU]); + $storeId = !empty($rowData[self::COL_STORE]) + ? (int)$this->getStoreIdByCode($rowData[self::COL_STORE]) + : Store::DEFAULT_STORE_ID; + foreach ($roleCodes as $code) { + if (array_key_exists($code, $rowData)) { + $assignments[$skuKey][$storeId][$code] = $rowData[$code]; + } + } + } + return $assignments; + } + /** * In _saveProducts loop, save product's attributes * diff --git a/app/code/Magento/CatalogImportExport/Model/Import/Product/MediaGalleryCleanup.php b/app/code/Magento/CatalogImportExport/Model/Import/Product/MediaGalleryCleanup.php new file mode 100644 index 0000000000000..58bc0489d69e3 --- /dev/null +++ b/app/code/Magento/CatalogImportExport/Model/Import/Product/MediaGalleryCleanup.php @@ -0,0 +1,381 @@ +connection = $resourceConnection->getConnection(); + $this->mediaDirectory = $filesystem->getDirectoryWrite(DirectoryList::MEDIA); + $this->mediaGalleryTableName = $resourceConnection->getTableName( + 'catalog_product_entity_media_gallery' + ); + $this->mediaGalleryValueTableName = $resourceConnection->getTableName( + 'catalog_product_entity_media_gallery_value' + ); + $this->mediaGalleryEntityToValueTableName = $resourceConnection->getTableName( + 'catalog_product_entity_media_gallery_value_to_entity' + ); + } + + /** + * Unlink gallery images, drop orphan main rows, optionally delete unused files. + * + * @param array $removals + * @param bool $deleteUnusedFiles + * @return void + */ + public function removeProductImages(array $removals, bool $deleteUnusedFiles = false): void + { + if ($removals === []) { + return; + } + + $linkField = $this->getProductEntityLinkField(); + $pairs = []; + $valueIdToFile = []; + foreach ($removals as $removal) { + if (!isset($removal['value_id'], $removal[$linkField])) { + continue; + } + $valueId = (int)$removal['value_id']; + $productId = (int)$removal[$linkField]; + $key = $valueId . ':' . $productId; + if (isset($pairs[$key])) { + continue; + } + $pairs[$key] = [$valueId, $productId]; + if (isset($removal['value']) && $removal['value'] !== '') { + $valueIdToFile[$valueId] = (string)$removal['value']; + } + } + if ($pairs === []) { + return; + } + + $conditions = []; + foreach ($pairs as [$valueId, $productId]) { + $conditions[] = sprintf( + '(%s AND %s)', + $this->connection->quoteInto('value_id = ?', $valueId), + $this->connection->quoteInto($linkField . ' = ?', $productId) + ); + } + $where = implode(' OR ', $conditions); + $this->connection->delete($this->mediaGalleryEntityToValueTableName, $where); + $this->connection->delete($this->mediaGalleryValueTableName, $where); + + foreach ($pairs as [$valueId]) { + if (!isset($valueIdToFile[$valueId])) { + $valueIdToFile[$valueId] = ''; + } + } + $this->cleanupOrphansAndFiles($valueIdToFile, $deleteUnusedFiles); + } + + /** + * Remove orphan main gallery rows; optionally delete unused media files. + * + * @param array $valueIdToFile value_id => path + * @param bool $deleteUnusedFiles + * @return void + */ + private function cleanupOrphansAndFiles(array $valueIdToFile, bool $deleteUnusedFiles): void + { + $valueIds = array_map('intval', array_keys($valueIdToFile)); + $linkedValueIds = $this->connection->fetchCol( + $this->connection->select() + ->from($this->mediaGalleryEntityToValueTableName, ['value_id']) + ->distinct(true) + ->where('value_id IN (?)', $valueIds) + ); + $linkedValueIds = array_map('intval', $linkedValueIds); + $orphanValueIds = array_values(array_diff($valueIds, $linkedValueIds)); + if ($orphanValueIds === []) { + return; + } + + $pathsByValueId = $this->resolvePathsForValueIds($orphanValueIds, $valueIdToFile); + + $this->connection->delete( + $this->mediaGalleryTableName, + $this->connection->quoteInto('value_id IN (?)', $orphanValueIds) + ); + + if (!$deleteUnusedFiles) { + return; + } + + $filesToDelete = $this->collectUnusedFilePaths($pathsByValueId); + if ($filesToDelete === []) { + return; + } + + $this->deletePhysicalFilesAndCache($filesToDelete); + } + + /** + * Resolve paths for orphan value_ids. + * + * @param int[] $orphanValueIds + * @param array $valueIdToFile + * @return array + */ + private function resolvePathsForValueIds(array $orphanValueIds, array $valueIdToFile): array + { + $pathsByValueId = []; + foreach ($orphanValueIds as $valueId) { + if (!empty($valueIdToFile[$valueId])) { + $pathsByValueId[$valueId] = (string)$valueIdToFile[$valueId]; + } + } + + $missingIds = array_values(array_diff($orphanValueIds, array_keys($pathsByValueId))); + if ($missingIds === []) { + return $pathsByValueId; + } + + $rows = $this->connection->fetchPairs( + $this->connection->select() + ->from($this->mediaGalleryTableName, ['value_id', 'value']) + ->where('value_id IN (?)', $missingIds) + ) ?: []; + foreach ($rows as $valueId => $path) { + $pathsByValueId[(int)$valueId] = (string)$path; + } + + return $pathsByValueId; + } + + /** + * Collect relative paths with no remaining gallery references. + * + * @param array $pathsByValueId + * @return string[] Relative paths without leading slash + */ + private function collectUnusedFilePaths(array $pathsByValueId): array + { + $candidates = []; + foreach ($pathsByValueId as $path) { + $relative = $this->normalizeGalleryRelativePath((string)$path); + if ($relative === null) { + continue; + } + $candidates[$relative] = true; + } + if ($candidates === []) { + return []; + } + + $usageByRelative = $this->countImageUsesByRelativePath(array_keys($candidates)); + $filesToDelete = []; + foreach (array_keys($candidates) as $relative) { + if (($usageByRelative[$relative] ?? 0) < 1) { + $filesToDelete[] = $relative; + } + } + + return $filesToDelete; + } + + /** + * Batch-count remaining gallery rows per relative path. + * + * @param string[] $relativePaths Paths without leading slash + * @return array relative path => remaining uses + */ + private function countImageUsesByRelativePath(array $relativePaths): array + { + $usageByRelative = array_fill_keys($relativePaths, 0); + $variants = []; + $variantToRelative = []; + foreach ($relativePaths as $relative) { + foreach (['/' . $relative, $relative] as $variant) { + $variants[] = $variant; + $variantToRelative[$variant] = $relative; + } + } + $variants = array_values(array_unique($variants)); + if ($variants === []) { + return $usageByRelative; + } + + $rows = $this->connection->fetchPairs( + $this->connection->select() + ->from( + $this->mediaGalleryTableName, + ['value', 'cnt' => new \Zend_Db_Expr('COUNT(value_id)')] + ) + ->where('value IN (?)', $variants) + ->group('value') + ) ?: []; + + foreach ($rows as $value => $count) { + $value = (string)$value; + $relative = $variantToRelative[$value] + ?? $this->normalizeGalleryRelativePath($value); + if ($relative === null || !array_key_exists($relative, $usageByRelative)) { + continue; + } + $usageByRelative[$relative] += (int)$count; + } + + return $usageByRelative; + } + + /** + * Delete media files and resized cache under the catalog media path only. + * + * @param string[] $filesToDelete Relative paths without leading slash + * @return void + */ + private function deletePhysicalFilesAndCache(array $filesToDelete): void + { + $catalogPath = rtrim(str_replace('\\', '/', $this->mediaConfig->getBaseMediaPath()), '/'); + $safeFiles = []; + try { + foreach ($filesToDelete as $filePath) { + $relative = $this->normalizeGalleryRelativePath((string)$filePath); + if ($relative === null) { + continue; + } + $relativePath = $catalogPath . '/' . $relative; + if (!$this->isPathInsideBase($catalogPath, $relativePath)) { + continue; + } + if ($this->mediaDirectory->isFile($relativePath)) { + $this->mediaDirectory->delete($relativePath); + } + $safeFiles[] = $relative; + } + if ($safeFiles !== []) { + $this->removeDeletedImagesFromCache->removeDeletedImagesFromCache($safeFiles); + } + } catch (FileSystemException $e) { + $this->logger->critical($e); + } + } + + /** + * Normalize gallery file path to a safe relative path under media (no traversal). + * + * @param string $path + * @return string|null Relative path without leading slash, or null if unsafe/empty + */ + private function normalizeGalleryRelativePath(string $path): ?string + { + $path = str_replace('\\', '/', $path); + $path = ltrim($path, '/'); + if ($path === '' || str_contains($path, "\0")) { + return null; + } + if (preg_match('#^[a-zA-Z]:#', $path) === 1) { + return null; + } + foreach (explode('/', $path) as $segment) { + if ($segment === '..') { + return null; + } + } + + return $path; + } + + /** + * Whether $path stays under $base (both media-relative, forward slashes). + * + * @param string $base + * @param string $path + * @return bool + */ + private function isPathInsideBase(string $base, string $path): bool + { + $base = rtrim(str_replace('\\', '/', $base), '/') . '/'; + $path = str_replace('\\', '/', $path); + + return str_starts_with($path, $base); + } + + /** + * Get product entity link field. + * + * @return string + */ + private function getProductEntityLinkField(): string + { + if ($this->productEntityLinkField === null) { + $this->productEntityLinkField = $this->metadataPool + ->getMetadata(ProductInterface::class) + ->getLinkField(); + } + return $this->productEntityLinkField; + } +} diff --git a/app/code/Magento/CatalogImportExport/Model/Import/Product/MediaGalleryProcessor.php b/app/code/Magento/CatalogImportExport/Model/Import/Product/MediaGalleryProcessor.php index 931e36650713b..af5b51ddb5e4d 100644 --- a/app/code/Magento/CatalogImportExport/Model/Import/Product/MediaGalleryProcessor.php +++ b/app/code/Magento/CatalogImportExport/Model/Import/Product/MediaGalleryProcessor.php @@ -9,6 +9,7 @@ use Magento\Catalog\Api\Data\ProductInterface; use Magento\CatalogImportExport\Model\Import\Product; use Magento\CatalogImportExport\Model\Import\Proxy\Product\ResourceModelFactory; +use Magento\Framework\App\ObjectManager; use Magento\Framework\App\ResourceConnection; use Magento\Framework\EntityManager\MetadataPool; use Magento\ImportExport\Model\Import\ErrorProcessing\ProcessingErrorAggregatorInterface; @@ -77,6 +78,11 @@ class MediaGalleryProcessor */ private $productEntityTableName; + /** + * @var MediaGalleryCleanup + */ + private $mediaGalleryCleanup; + /** * MediaProcessor constructor. * @@ -85,19 +91,23 @@ class MediaGalleryProcessor * @param ResourceConnection $resourceConnection * @param ResourceModelFactory $resourceModelFactory * @param ProcessingErrorAggregatorInterface $errorAggregator + * @param MediaGalleryCleanup|null $mediaGalleryCleanup */ public function __construct( SkuProcessor $skuProcessor, MetadataPool $metadataPool, ResourceConnection $resourceConnection, ResourceModelFactory $resourceModelFactory, - ProcessingErrorAggregatorInterface $errorAggregator + ProcessingErrorAggregatorInterface $errorAggregator, + ?MediaGalleryCleanup $mediaGalleryCleanup = null ) { $this->skuProcessor = $skuProcessor; $this->metadataPool = $metadataPool; $this->connection = $resourceConnection->getConnection(); $this->resourceFactory = $resourceModelFactory; $this->errorAggregator = $errorAggregator; + $this->mediaGalleryCleanup = $mediaGalleryCleanup + ?? ObjectManager::getInstance()->get(MediaGalleryCleanup::class); } /** @@ -269,6 +279,86 @@ private function prepareMediaGalleryValueData( return $mediaGalleryValueData; } + /** + * Remove product gallery images (delegates to MediaGalleryCleanup). + * + * @param array $removals + * @param bool $deleteUnusedFiles + * @return void + */ + public function removeProductImages(array $removals, bool $deleteUnusedFiles = false): void + { + $this->mediaGalleryCleanup->removeProductImages($removals, $deleteUnusedFiles); + } + + /** + * Load image role values for SKUs across all stores. + * + * @param string[] $skus + * @param string[] $roleAttributeCodes + * @return array + */ + public function getProductImageRoles(array $skus, array $roleAttributeCodes): array + { + $result = []; + if (empty($skus) || empty($roleAttributeCodes)) { + return $result; + } + foreach ($skus as $sku) { + $result[mb_strtolower((string)$sku)] = []; + } + + $attributeIdToCode = []; + $attributeIdsByTable = []; + foreach ($roleAttributeCodes as $attributeCode) { + $attribute = $this->getResource()->getAttribute($attributeCode); + if (!$attribute || !$attribute->getId()) { + continue; + } + $attributeId = (int)$attribute->getId(); + $attributeIdToCode[$attributeId] = $attributeCode; + $backendTable = $attribute->getBackendTable(); + $attributeIdsByTable[$backendTable][] = $attributeId; + } + if (empty($attributeIdToCode)) { + return $result; + } + + $this->initMediaGalleryResources(); + $linkField = $this->getProductEntityLinkField(); + foreach ($attributeIdsByTable as $backendTable => $attributeIds) { + $select = $this->connection->select() + ->from( + ['e' => $this->productEntityTableName], + ['sku' => 'e.sku'] + )->joinInner( + ['v' => $backendTable], + sprintf('e.%1$s = v.%1$s', $linkField), + [ + 'attribute_id' => 'v.attribute_id', + 'store_id' => 'v.store_id', + 'value' => 'v.value', + ] + )->where( + 'e.sku IN (?)', + $skus + )->where( + 'v.attribute_id IN (?)', + $attributeIds + ); + foreach ($this->connection->fetchAll($select) as $row) { + $skuKey = mb_strtolower((string)$row['sku']); + $code = $attributeIdToCode[(int)$row['attribute_id']] ?? null; + if ($code === null) { + continue; + } + $result[$skuKey][$code][(int)$row['store_id']] = $row['value']; + } + } + + return $result; + } + /** * Update media gallery labels. * @@ -355,7 +445,10 @@ public function getExistingImages(array $bunch) ); $select = $this->connection->select()->from( ['mg' => $this->mediaGalleryTableName], - ['value' => 'mg.value'] + [ + 'value' => 'mg.value', + 'media_type' => 'mg.media_type', + ] )->joinInner( ['mgvte' => $this->mediaGalleryEntityToValueTableName], '(mg.value_id = mgvte.value_id)', diff --git a/app/code/Magento/CatalogImportExport/Model/Import/Product/MediaGalleryReplaceCoordinator.php b/app/code/Magento/CatalogImportExport/Model/Import/Product/MediaGalleryReplaceCoordinator.php new file mode 100644 index 0000000000000..febb9c98ec623 --- /dev/null +++ b/app/code/Magento/CatalogImportExport/Model/Import/Product/MediaGalleryReplaceCoordinator.php @@ -0,0 +1,300 @@ +enabled = $enabled; + $this->replaceSkus = []; + $this->keptPaths = []; + $this->uploadFailedSkus = []; + $this->rolePlan->reset($enabled ? $roleAttributeCodes : []); + } + + /** + * Check if any SKUs were registered for replace. + * + * @return bool + */ + public function hasRegisteredProducts(): bool + { + return $this->enabled && $this->replaceSkus !== []; + } + + /** + * Get registered replace SKUs. + * + * @return string[] + */ + public function getRegisteredSkus(): array + { + return array_values($this->replaceSkus); + } + + /** + * Mark SKU when an image failed to load. + * + * @param string $sku + * @return void + */ + public function markUploadFailed(string $sku): void + { + if (!$this->enabled || $sku === '') { + return; + } + $this->uploadFailedSkus[mb_strtolower($sku)] = true; + } + + /** + * Get SKUs whose gallery unlinks were skipped. + * + * @return string[] + */ + public function getSkusWithSkippedRemovals(): array + { + $skus = []; + foreach ($this->replaceSkus as $skuKey => $originalSku) { + if (isset($this->uploadFailedSkus[$skuKey])) { + $skus[] = $originalSku; + } + } + return $skus; + } + + /** + * Merge CSV role assignments into the plan. + * + * @param array $assignments + * @return void + */ + public function planRoleAssignments(array $assignments): void + { + if ($this->enabled) { + $this->rolePlan->mergeCsvAssignments($assignments); + } + } + + /** + * Prefetch role attribute values for SKUs. + * + * @param string[] $skus + * @return void + */ + public function warmRolesCache(array $skus): void + { + if ($this->enabled) { + $this->rolePlan->warm($skus); + } + } + + /** + * Register SKU for replace when default-store row has additional_images. + * + * @param string $sku + * @param array $rowData + * @param int $storeId + * @return void + */ + public function registerProduct(string $sku, array $rowData, int $storeId): void + { + if (!$this->enabled + || (int)$storeId !== Store::DEFAULT_STORE_ID + || !$this->rowHasAdditionalImagesColumn($rowData) + ) { + return; + } + $this->replaceSkus[mb_strtolower($sku)] = $sku; + } + + /** + * Keep a resolved gallery path for the SKU. + * + * @param string $sku + * @param string $normalizedPath + * @return void + */ + public function keepPath(string $sku, string $normalizedPath): void + { + $this->keptPaths[mb_strtolower($sku)][$normalizedPath] = true; + } + + /** + * Build gallery unlink list for registered SKUs. + * + * @param array $existingImages + * @return array + */ + public function collectRemovals(array $existingImages): array + { + if (!$this->hasRegisteredProducts()) { + return [[], []]; + } + + $removals = []; + $removedSkus = []; + $seen = []; + $linkField = $this->getProductEntityLinkField(); + + foreach ($this->replaceSkus as $skuKey => $originalSku) { + if (isset($this->uploadFailedSkus[$skuKey])) { + continue; + } + $keep = $this->buildKeepSet($originalSku, $skuKey); + $skuHadRemoval = false; + + foreach ($this->iterateImageEntries($existingImages, $skuKey, $linkField) as $imageData) { + $pathNormalized = ltrim((string)($imageData['_path'] ?? ''), '/\\'); + $valueNormalized = ltrim((string)($imageData['value'] ?? $pathNormalized), '/\\'); + if (isset($keep[$pathNormalized]) || isset($keep[$valueNormalized])) { + continue; + } + $dedupeKey = $imageData['value_id'] . ':' . $imageData[$linkField]; + if (isset($seen[$dedupeKey])) { + continue; + } + $seen[$dedupeKey] = true; + $skuHadRemoval = true; + $filePath = (string)($imageData['value'] ?? $pathNormalized); + $removals[] = [ + 'value_id' => $imageData['value_id'], + $linkField => $imageData[$linkField], + 'value' => $filePath !== '' ? $filePath : $pathNormalized, + ]; + } + + if ($skuHadRemoval) { + $removedSkus[] = (string)$originalSku; + } + } + + return [$removals, array_values(array_unique($removedSkus))]; + } + + /** + * Build keep-set from kept paths and protected roles. + * + * @param string $sku + * @param string $skuKey + * @return array + */ + private function buildKeepSet(string $sku, string $skuKey): array + { + $keep = $this->keptPaths[$skuKey] ?? []; + foreach ($this->rolePlan->protectedPaths($sku) as $path) { + $keep[$path] = true; + } + return $keep; + } + + /** + * Yield removable image entries for a SKU. + * + * @param array $existingImages + * @param string $skuKey + * @param string $linkField + * @return \Generator + */ + private function iterateImageEntries(array $existingImages, string $skuKey, string $linkField): \Generator + { + foreach ($existingImages as $bySku) { + if (!isset($bySku[$skuKey])) { + continue; + } + foreach ($bySku[$skuKey] as $path => $imageData) { + if (!isset($imageData['value_id'], $imageData[$linkField])) { + continue; + } + $mediaType = $imageData['media_type'] ?? null; + if ($mediaType !== null && $mediaType !== '' && $mediaType !== 'image') { + continue; + } + $imageData['_path'] = $path; + yield $imageData; + } + } + } + + /** + * Check if row has additional_images column. + * + * @param array $rowData + * @return bool + */ + private function rowHasAdditionalImagesColumn(array $rowData): bool + { + return array_key_exists(Product::COL_MEDIA_IMAGE, $rowData) + || array_key_exists('additional_images', $rowData); + } + + /** + * Get product entity link field. + * + * @return string + */ + private function getProductEntityLinkField(): string + { + if ($this->productEntityLinkField === null) { + $this->productEntityLinkField = $this->metadataPool + ->getMetadata(ProductInterface::class) + ->getLinkField(); + } + return $this->productEntityLinkField; + } +} diff --git a/app/code/Magento/CatalogImportExport/Model/Import/Product/MediaGalleryReplaceRolePlan.php b/app/code/Magento/CatalogImportExport/Model/Import/Product/MediaGalleryReplaceRolePlan.php new file mode 100644 index 0000000000000..5f8925dfb5887 --- /dev/null +++ b/app/code/Magento/CatalogImportExport/Model/Import/Product/MediaGalleryReplaceRolePlan.php @@ -0,0 +1,126 @@ +roleAttributeCodes = $roleAttributeCodes; + $this->dbRoles = []; + $this->csvOverrides = []; + } + + /** + * Merge CSV role column values into the plan. + * + * @param array $assignments + * @return void + */ + public function mergeCsvAssignments(array $assignments): void + { + foreach ($assignments as $skuKey => $byStore) { + foreach ($byStore as $storeId => $byCode) { + foreach ($byCode as $code => $value) { + $this->csvOverrides[(string)$skuKey][(int)$storeId][(string)$code] = $value; + } + } + } + } + + /** + * Prefetch role values for SKUs not yet loaded. + * + * @param string[] $skus + * @return void + */ + public function warm(array $skus): void + { + if ($this->roleAttributeCodes === [] || $skus === []) { + return; + } + $toLoad = []; + foreach (array_unique($skus) as $sku) { + $sku = (string)$sku; + if ($sku === '' || isset($this->dbRoles[mb_strtolower($sku)])) { + continue; + } + $toLoad[] = $sku; + } + if ($toLoad === []) { + return; + } + foreach ($this->mediaProcessor->getProductImageRoles($toLoad, $this->roleAttributeCodes) as $skuKey => $roles) { + $this->dbRoles[$skuKey] = $roles; + } + } + + /** + * Gallery paths protected by remaining image roles. + * + * @param string $sku + * @return string[] + */ + public function protectedPaths(string $sku): array + { + if ($this->roleAttributeCodes === []) { + return []; + } + $skuKey = mb_strtolower($sku); + if (!isset($this->dbRoles[$skuKey])) { + $this->warm([$sku]); + $this->dbRoles[$skuKey] = $this->dbRoles[$skuKey] ?? []; + } + + $protected = []; + foreach ($this->roleAttributeCodes as $code) { + foreach ($this->dbRoles[$skuKey][$code] ?? [] as $storeId => $path) { + if (!$path || $path === 'no_selection') { + continue; + } + if (array_key_exists($code, $this->csvOverrides[$skuKey][(int)$storeId] ?? [])) { + continue; + } + $protected[ltrim((string)$path, '/\\')] = true; + } + } + return array_keys($protected); + } +} diff --git a/app/code/Magento/CatalogImportExport/Test/Unit/Model/Import/Product/MediaGalleryCleanupTest.php b/app/code/Magento/CatalogImportExport/Test/Unit/Model/Import/Product/MediaGalleryCleanupTest.php new file mode 100644 index 0000000000000..e9cc7b200836f --- /dev/null +++ b/app/code/Magento/CatalogImportExport/Test/Unit/Model/Import/Product/MediaGalleryCleanupTest.php @@ -0,0 +1,337 @@ +connection = $this->createMock(AdapterInterface::class); + $resourceConnection = $this->createMock(ResourceConnection::class); + $resourceConnection->method('getConnection')->willReturn($this->connection); + $resourceConnection->method('getTableName')->willReturnCallback( + static function (string $table): string { + return $table; + } + ); + + $this->mediaDirectory = $this->createMock(WriteInterface::class); + $filesystem = $this->createMock(Filesystem::class); + $filesystem->method('getDirectoryWrite')->willReturn($this->mediaDirectory); + + $mediaConfig = $this->createMock(MediaConfig::class); + $mediaConfig->method('getBaseMediaPath')->willReturn('catalog/product'); + + $this->removeDeletedImagesFromCache = $this->createMock(RemoveDeletedImagesFromCache::class); + + $metadata = $this->createMock(EntityMetadata::class); + $metadata->method('getLinkField')->willReturn('row_id'); + $metadataPool = $this->createMock(MetadataPool::class); + $metadataPool->method('getMetadata')->willReturn($metadata); + + $this->cleanup = new MediaGalleryCleanup( + $resourceConnection, + $filesystem, + $mediaConfig, + $this->removeDeletedImagesFromCache, + $this->createMock(LoggerInterface::class), + $metadataPool + ); + } + + public function testRemoveProductImagesWithEmptyListDoesNothing(): void + { + $this->connection->expects($this->never())->method('delete'); + $this->mediaDirectory->expects($this->never())->method('delete'); + $this->removeDeletedImagesFromCache->expects($this->never())->method('removeDeletedImagesFromCache'); + + $this->cleanup->removeProductImages([]); + } + + public function testRemoveProductImagesDeletesLinksValuesAndOrphanRow(): void + { + $this->connection->method('quoteInto') + ->willReturnCallback(static function (string $text, $value): string { + if (is_array($value)) { + return str_replace('?', implode(',', $value), $text); + } + return str_replace('?', (string)$value, $text); + }); + + $select = $this->createMock(Select::class); + $select->method('from')->willReturnSelf(); + $select->method('where')->willReturnSelf(); + $select->method('distinct')->willReturnSelf(); + $select->method('group')->willReturnSelf(); + $this->connection->method('select')->willReturn($select); + $this->connection->method('fetchCol')->willReturn([]); + + $deletedTables = []; + $this->connection->expects($this->exactly(3)) + ->method('delete') + ->willReturnCallback(function (string $table) use (&$deletedTables) { + $deletedTables[] = $table; + return 1; + }); + + $this->mediaDirectory->expects($this->never())->method('delete'); + $this->removeDeletedImagesFromCache->expects($this->never())->method('removeDeletedImagesFromCache'); + + $this->cleanup->removeProductImages( + [ + ['value_id' => 10, 'row_id' => 5, 'value' => '/o/l/old_extra.jpg'], + ['value_id' => 10, 'row_id' => 5, 'value' => '/o/l/old_extra.jpg'], + ], + false + ); + + $this->assertSame( + [ + 'catalog_product_entity_media_gallery_value_to_entity', + 'catalog_product_entity_media_gallery_value', + 'catalog_product_entity_media_gallery', + ], + $deletedTables + ); + } + + public function testRemoveProductImagesDeletesUnusedFileWhenEnabled(): void + { + $this->connection->method('quoteInto') + ->willReturnCallback(static function (string $text, $value): string { + if (is_array($value)) { + return str_replace('?', implode(',', $value), $text); + } + return str_replace('?', (string)$value, $text); + }); + + $select = $this->createMock(Select::class); + $select->method('from')->willReturnSelf(); + $select->method('where')->willReturnSelf(); + $select->method('distinct')->willReturnSelf(); + $select->method('group')->willReturnSelf(); + $this->connection->method('select')->willReturn($select); + $this->connection->method('fetchCol')->willReturn([]); + // No remaining gallery rows for the path (batched COUNT via fetchPairs). + $this->connection->method('fetchPairs')->willReturn([]); + $this->connection->method('delete')->willReturn(1); + + $this->mediaDirectory->expects($this->once()) + ->method('isFile') + ->with('catalog/product/o/l/old_extra.jpg') + ->willReturn(true); + $this->mediaDirectory->expects($this->once()) + ->method('delete') + ->with('catalog/product/o/l/old_extra.jpg'); + $this->removeDeletedImagesFromCache->expects($this->once()) + ->method('removeDeletedImagesFromCache') + ->with(['o/l/old_extra.jpg']); + + $this->cleanup->removeProductImages( + [ + ['value_id' => 10, 'row_id' => 5, 'value' => '/o/l/old_extra.jpg'], + ], + true + ); + } + + public function testRemoveProductImagesRejectsPathTraversalWhenDeletingFiles(): void + { + $this->connection->method('quoteInto') + ->willReturnCallback(static function (string $text, $value): string { + if (is_array($value)) { + return str_replace('?', implode(',', $value), $text); + } + return str_replace('?', (string)$value, $text); + }); + + $select = $this->createMock(Select::class); + $select->method('from')->willReturnSelf(); + $select->method('where')->willReturnSelf(); + $select->method('distinct')->willReturnSelf(); + $select->method('group')->willReturnSelf(); + $this->connection->method('select')->willReturn($select); + $this->connection->method('fetchCol')->willReturn([]); + $this->connection->method('delete')->willReturn(1); + + $this->mediaDirectory->expects($this->never())->method('isFile'); + $this->mediaDirectory->expects($this->never())->method('delete'); + $this->removeDeletedImagesFromCache->expects($this->never())->method('removeDeletedImagesFromCache'); + + $this->cleanup->removeProductImages( + [ + ['value_id' => 10, 'row_id' => 5, 'value' => '/../../etc/passwd'], + ], + true + ); + } + + public function testRemoveProductImagesBatchesUsageCountsForMultiplePaths(): void + { + $this->connection->method('quoteInto') + ->willReturnCallback(static function (string $text, $value): string { + if (is_array($value)) { + return str_replace('?', implode(',', $value), $text); + } + return str_replace('?', (string)$value, $text); + }); + + $select = $this->createMock(Select::class); + $select->method('from')->willReturnSelf(); + $select->method('where')->willReturnSelf(); + $select->method('distinct')->willReturnSelf(); + $select->method('group')->willReturnSelf(); + $this->connection->method('select')->willReturn($select); + $this->connection->method('fetchCol')->willReturn([]); + $this->connection->method('delete')->willReturn(1); + + // One batched fetchPairs for usage counts (not per-path). + $this->connection->expects($this->once()) + ->method('fetchPairs') + ->willReturn([ + '/a/a/a.jpg' => '0', + 'a/a/a.jpg' => '0', + '/b/b/b.jpg' => '2', + 'b/b/b.jpg' => '0', + ]); + + $this->mediaDirectory->method('isFile')->willReturn(true); + $this->mediaDirectory->expects($this->once()) + ->method('delete') + ->with('catalog/product/a/a/a.jpg'); + $this->removeDeletedImagesFromCache->expects($this->once()) + ->method('removeDeletedImagesFromCache') + ->with(['a/a/a.jpg']); + + $this->cleanup->removeProductImages( + [ + ['value_id' => 10, 'row_id' => 5, 'value' => '/a/a/a.jpg'], + ['value_id' => 11, 'row_id' => 5, 'value' => '/b/b/b.jpg'], + ], + true + ); + } + + public function testRemoveProductImagesKeepsSharedLinkedValueId(): void + { + $this->connection->method('quoteInto') + ->willReturnCallback(static function (string $text, $value): string { + if (is_array($value)) { + return str_replace('?', implode(',', $value), $text); + } + return str_replace('?', (string)$value, $text); + }); + + $select = $this->createMock(Select::class); + $select->method('from')->willReturnSelf(); + $select->method('where')->willReturnSelf(); + $select->method('distinct')->willReturnSelf(); + $this->connection->method('select')->willReturn($select); + $this->connection->method('fetchCol')->willReturn(['10']); + + $deletedTables = []; + $this->connection->expects($this->exactly(2)) + ->method('delete') + ->willReturnCallback(function (string $table) use (&$deletedTables) { + $deletedTables[] = $table; + return 1; + }); + + $this->mediaDirectory->expects($this->never())->method('delete'); + $this->removeDeletedImagesFromCache->expects($this->never())->method('removeDeletedImagesFromCache'); + + $this->cleanup->removeProductImages( + [ + ['value_id' => 10, 'row_id' => 5, 'value' => '/s/h/shared.jpg'], + ], + true + ); + + $this->assertSame( + [ + 'catalog_product_entity_media_gallery_value_to_entity', + 'catalog_product_entity_media_gallery_value', + ], + $deletedTables + ); + } + + public function testRemoveProductImagesBatchDeletesMultiplePairs(): void + { + $this->connection->method('quoteInto') + ->willReturnCallback(static function (string $text, $value): string { + if (is_array($value)) { + return str_replace('?', implode(',', $value), $text); + } + return str_replace('?', (string)$value, $text); + }); + + $select = $this->createMock(Select::class); + $select->method('from')->willReturnSelf(); + $select->method('where')->willReturnSelf(); + $select->method('distinct')->willReturnSelf(); + $select->method('group')->willReturnSelf(); + $this->connection->method('select')->willReturn($select); + $this->connection->method('fetchCol')->willReturn([]); + $this->connection->method('fetchPairs')->willReturn([]); + + $deleted = []; + $this->connection->expects($this->exactly(3)) + ->method('delete') + ->willReturnCallback(function (string $table, string $where = '') use (&$deleted) { + $deleted[] = [$table, $where]; + return 1; + }); + + $this->cleanup->removeProductImages([ + ['value_id' => 10, 'row_id' => 5], + ['value_id' => 11, 'row_id' => 6], + ]); + + $this->assertSame('catalog_product_entity_media_gallery_value_to_entity', $deleted[0][0]); + $this->assertSame('catalog_product_entity_media_gallery_value', $deleted[1][0]); + $this->assertStringContainsString('(value_id = 10 AND row_id = 5)', $deleted[0][1]); + $this->assertStringContainsString('(value_id = 11 AND row_id = 6)', $deleted[0][1]); + $this->assertStringContainsString(' OR ', $deleted[0][1]); + $this->assertSame($deleted[0][1], $deleted[1][1]); + } +} diff --git a/app/code/Magento/CatalogImportExport/Test/Unit/Model/Import/Product/MediaGalleryProcessorTest.php b/app/code/Magento/CatalogImportExport/Test/Unit/Model/Import/Product/MediaGalleryProcessorTest.php new file mode 100644 index 0000000000000..fee636f974ce6 --- /dev/null +++ b/app/code/Magento/CatalogImportExport/Test/Unit/Model/Import/Product/MediaGalleryProcessorTest.php @@ -0,0 +1,82 @@ +createMock(AdapterInterface::class); + $resourceConnection = $this->createMock(ResourceConnection::class); + $resourceConnection->method('getConnection')->willReturn($connection); + + $metadata = $this->createMock(EntityMetadata::class); + $metadata->method('getLinkField')->willReturn('row_id'); + $metadataPool = $this->createMock(MetadataPool::class); + $metadataPool->method('getMetadata')->willReturn($metadata); + + $resourceModel = $this->createMock(ResourceModel::class); + $resourceFactory = $this->createMock(ResourceModelFactory::class); + $resourceFactory->method('create')->willReturn($resourceModel); + + $this->mediaGalleryCleanup = $this->createMock(MediaGalleryCleanup::class); + + $this->processor = new MediaGalleryProcessor( + $this->createMock(SkuProcessor::class), + $metadataPool, + $resourceConnection, + $resourceFactory, + $this->createMock(ProcessingErrorAggregatorInterface::class), + $this->mediaGalleryCleanup + ); + } + + public function testRemoveProductImagesDelegatesToCleanup(): void + { + $removals = [ + ['value_id' => 10, 'row_id' => 5, 'value' => '/o/l/old.jpg'], + ]; + $this->mediaGalleryCleanup->expects($this->once()) + ->method('removeProductImages') + ->with($removals, true); + + $this->processor->removeProductImages($removals, true); + } + + public function testRemoveProductImagesDelegatesEmptyList(): void + { + $this->mediaGalleryCleanup->expects($this->once()) + ->method('removeProductImages') + ->with([], false); + + $this->processor->removeProductImages([]); + } +} diff --git a/app/code/Magento/CatalogImportExport/Test/Unit/Model/Import/Product/MediaGalleryReplaceCoordinatorTest.php b/app/code/Magento/CatalogImportExport/Test/Unit/Model/Import/Product/MediaGalleryReplaceCoordinatorTest.php new file mode 100644 index 0000000000000..813827eab75c3 --- /dev/null +++ b/app/code/Magento/CatalogImportExport/Test/Unit/Model/Import/Product/MediaGalleryReplaceCoordinatorTest.php @@ -0,0 +1,594 @@ +mediaProcessor = $this->createMock(MediaGalleryProcessor::class); + + $metadata = $this->createStub(EntityMetadata::class); + $metadata->method('getLinkField')->willReturn('entity_id'); + $metadataPool = $this->createStub(MetadataPool::class); + $metadataPool->method('getMetadata')->willReturn($metadata); + + $this->coordinator = new MediaGalleryReplaceCoordinator( + new MediaGalleryReplaceRolePlan($this->mediaProcessor), + $metadataPool + ); + } + + public function testCollectRemovalsIsNoOpWhenDisabled(): void + { + $this->mediaProcessor->expects($this->never())->method('getProductImageRoles'); + + $this->coordinator->configure(false, ['image']); + $this->coordinator->registerProduct( + 'SKU1', + [Product::COL_MEDIA_IMAGE => 'a.jpg'], + Store::DEFAULT_STORE_ID + ); + $this->coordinator->keepPath('SKU1', 'a.jpg'); + + $this->assertFalse($this->coordinator->hasRegisteredProducts()); + $this->assertSame([[], []], $this->coordinator->collectRemovals([ + Store::DEFAULT_STORE_ID => [ + 'sku1' => [ + 'old.jpg' => [ + 'value_id' => 1, + 'entity_id' => 10, + 'value' => '/old.jpg', + 'media_type' => 'image', + ], + ], + ], + ])); + } + + public function testRegisterProductIgnoresRowsWithoutAdditionalImagesColumn(): void + { + $this->mediaProcessor->expects($this->never())->method('getProductImageRoles'); + + $this->coordinator->configure(true, ['image']); + $this->coordinator->registerProduct( + 'SKU1', + ['image' => '/i/m/img.jpg'], + Store::DEFAULT_STORE_ID + ); + + $this->assertFalse($this->coordinator->hasRegisteredProducts()); + } + + public function testRegisterProductIgnoresNonDefaultStore(): void + { + $this->mediaProcessor->expects($this->never())->method('getProductImageRoles'); + + $this->coordinator->configure(true, ['image']); + $this->coordinator->registerProduct( + 'SKU1', + [Product::COL_MEDIA_IMAGE => 'a.jpg'], + 1 + ); + + $this->assertFalse($this->coordinator->hasRegisteredProducts()); + } + + public function testCollectRemovalsDropsUnlistedImagesAndKeepsProtectedRoles(): void + { + $this->mediaProcessor->expects($this->once()) + ->method('getProductImageRoles') + ->with(['SKU1'], ['image', 'small_image', 'thumbnail', 'swatch_image']) + ->willReturn([ + 'sku1' => [ + 'image' => [Store::DEFAULT_STORE_ID => '/r/o/role.jpg'], + ], + ]); + + $this->coordinator->configure(true, ['image', 'small_image', 'thumbnail', 'swatch_image']); + $this->coordinator->registerProduct( + 'SKU1', + [Product::COL_MEDIA_IMAGE => 'new.jpg'], + Store::DEFAULT_STORE_ID + ); + $this->coordinator->keepPath('SKU1', 'n/e/new.jpg'); + + [$removals, $removedSkus] = $this->coordinator->collectRemovals([ + Store::DEFAULT_STORE_ID => [ + 'sku1' => [ + 'r/o/role.jpg' => [ + 'value_id' => 1, + 'entity_id' => 10, + 'value' => '/r/o/role.jpg', + 'media_type' => 'image', + ], + 'o/l/old_extra.jpg' => [ + 'value_id' => 2, + 'entity_id' => 10, + 'value' => '/o/l/old_extra.jpg', + 'media_type' => 'image', + ], + 'v/i/video.mp4' => [ + 'value_id' => 3, + 'entity_id' => 10, + 'value' => '/v/i/video.mp4', + 'media_type' => 'external-video', + ], + 'n/e/new.jpg' => [ + 'value_id' => 4, + 'entity_id' => 10, + 'value' => '/n/e/new.jpg', + 'media_type' => 'image', + ], + ], + ], + ]); + + $this->assertSame(['SKU1'], $removedSkus); + $this->assertCount(1, $removals); + $this->assertSame(2, (int)$removals[0]['value_id']); + $this->assertSame('/o/l/old_extra.jpg', $removals[0]['value']); + } + + public function testWarmRolesCacheUsesBulkLoad(): void + { + $this->mediaProcessor->expects($this->once()) + ->method('getProductImageRoles') + ->with(['SKU1'], ['image']) + ->willReturn([ + 'sku1' => [ + 'image' => [Store::DEFAULT_STORE_ID => '/r/o/role.jpg'], + ], + ]); + + $this->coordinator->configure(true, ['image']); + $this->coordinator->warmRolesCache(['SKU1']); + $this->coordinator->registerProduct( + 'SKU1', + ['additional_images' => 'new.jpg'], + Store::DEFAULT_STORE_ID + ); + $this->coordinator->keepPath('SKU1', 'n/e/new.jpg'); + + [$removals] = $this->coordinator->collectRemovals([ + Store::DEFAULT_STORE_ID => [ + 'sku1' => [ + 'r/o/role.jpg' => [ + 'value_id' => 1, + 'entity_id' => 10, + 'value' => '/r/o/role.jpg', + 'media_type' => 'image', + ], + 'o/l/old.jpg' => [ + 'value_id' => 2, + 'entity_id' => 10, + 'value' => '/o/l/old.jpg', + 'media_type' => 'image', + ], + ], + ], + ]); + + $this->assertCount(1, $removals); + $this->assertSame(2, (int)$removals[0]['value_id']); + } + + public function testWarmRolesCacheAppendsWithoutReloadingCachedSkus(): void + { + $this->mediaProcessor->expects($this->exactly(2)) + ->method('getProductImageRoles') + ->willReturnCallback(static function (array $skus) { + if ($skus === ['SKU1']) { + return ['sku1' => ['image' => [Store::DEFAULT_STORE_ID => '/r/o/role1.jpg']]]; + } + if ($skus === ['SKU2']) { + return ['sku2' => ['image' => [Store::DEFAULT_STORE_ID => '/r/o/role2.jpg']]]; + } + self::fail('Unexpected SKU list: ' . implode(',', $skus)); + }); + + $this->coordinator->configure(true, ['image']); + $this->coordinator->warmRolesCache(['SKU1']); + $this->coordinator->warmRolesCache(['SKU1', 'SKU2']); + $this->coordinator->registerProduct('SKU1', [Product::COL_MEDIA_IMAGE => 'new1.jpg'], Store::DEFAULT_STORE_ID); + $this->coordinator->registerProduct('SKU2', [Product::COL_MEDIA_IMAGE => 'new2.jpg'], Store::DEFAULT_STORE_ID); + $this->coordinator->keepPath('SKU1', 'n/e/new1.jpg'); + $this->coordinator->keepPath('SKU2', 'n/e/new2.jpg'); + + [$removals, $removedSkus] = $this->coordinator->collectRemovals([ + Store::DEFAULT_STORE_ID => [ + 'sku1' => [ + 'r/o/role1.jpg' => [ + 'value_id' => 1, + 'entity_id' => 10, + 'value' => '/r/o/role1.jpg', + 'media_type' => 'image', + ], + 'o/l/old1.jpg' => [ + 'value_id' => 2, + 'entity_id' => 10, + 'value' => '/o/l/old1.jpg', + 'media_type' => 'image', + ], + ], + 'sku2' => [ + 'r/o/role2.jpg' => [ + 'value_id' => 3, + 'entity_id' => 20, + 'value' => '/r/o/role2.jpg', + 'media_type' => 'image', + ], + 'o/l/old2.jpg' => [ + 'value_id' => 4, + 'entity_id' => 20, + 'value' => '/o/l/old2.jpg', + 'media_type' => 'image', + ], + ], + ], + ]); + + $this->assertEqualsCanonicalizing(['SKU1', 'SKU2'], $removedSkus); + $this->assertEqualsCanonicalizing([2, 4], array_map('intval', array_column($removals, 'value_id'))); + } + + public function testKeepPathBeforeRegisterIsAppliedWhenSkuIsRegistered(): void + { + $this->mediaProcessor->method('getProductImageRoles')->willReturn(['sku1' => []]); + + $this->coordinator->configure(true, ['image']); + $this->coordinator->keepPath('SKU1', 's/t/store_added.jpg'); + $this->coordinator->registerProduct( + 'SKU1', + [Product::COL_MEDIA_IMAGE => 'new.jpg'], + Store::DEFAULT_STORE_ID + ); + $this->coordinator->keepPath('SKU1', 'n/e/new.jpg'); + + [$removals] = $this->coordinator->collectRemovals([ + Store::DEFAULT_STORE_ID => [ + 'sku1' => [ + 's/t/store_added.jpg' => [ + 'value_id' => 1, + 'entity_id' => 10, + 'value' => '/s/t/store_added.jpg', + 'media_type' => 'image', + ], + 'n/e/new.jpg' => [ + 'value_id' => 2, + 'entity_id' => 10, + 'value' => '/n/e/new.jpg', + 'media_type' => 'image', + ], + 'o/l/old.jpg' => [ + 'value_id' => 3, + 'entity_id' => 10, + 'value' => '/o/l/old.jpg', + 'media_type' => 'image', + ], + ], + ], + ]); + + $this->assertCount(1, $removals); + $this->assertSame(3, (int)$removals[0]['value_id']); + } + + public function testUploadFailureSkipsRemovalsForSku(): void + { + $this->mediaProcessor->method('getProductImageRoles')->willReturn(['sku1' => []]); + + $this->coordinator->configure(true, ['image']); + $this->coordinator->registerProduct( + 'SKU1', + [Product::COL_MEDIA_IMAGE => 'good.jpg,bad.jpg'], + Store::DEFAULT_STORE_ID + ); + $this->coordinator->keepPath('SKU1', 'g/o/good.jpg'); + $this->coordinator->markUploadFailed('SKU1'); + + $this->assertSame(['SKU1'], $this->coordinator->getSkusWithSkippedRemovals()); + + [$removals, $removedSkus] = $this->coordinator->collectRemovals([ + Store::DEFAULT_STORE_ID => [ + 'sku1' => [ + 'g/o/good.jpg' => [ + 'value_id' => 1, + 'entity_id' => 10, + 'value' => '/g/o/good.jpg', + 'media_type' => 'image', + ], + 'o/l/old.jpg' => [ + 'value_id' => 2, + 'entity_id' => 10, + 'value' => '/o/l/old.jpg', + 'media_type' => 'image', + ], + ], + ], + ]); + + $this->assertSame([], $removals); + $this->assertSame([], $removedSkus); + } + + public function testConfigureClearsPreviousImportState(): void + { + $this->mediaProcessor->method('getProductImageRoles')->willReturn([ + 'sku1' => ['image' => [Store::DEFAULT_STORE_ID => '/r/o/role.jpg']], + ]); + + $this->coordinator->configure(true, ['image']); + $this->coordinator->warmRolesCache(['SKU1']); + $this->coordinator->registerProduct( + 'SKU1', + [Product::COL_MEDIA_IMAGE => 'a.jpg'], + Store::DEFAULT_STORE_ID + ); + + $this->coordinator->configure(false, ['image']); + $this->assertFalse($this->coordinator->hasRegisteredProducts()); + $this->assertSame([[], []], $this->coordinator->collectRemovals([ + Store::DEFAULT_STORE_ID => [ + 'sku1' => [ + 'old.jpg' => [ + 'value_id' => 1, + 'entity_id' => 10, + 'value' => '/old.jpg', + 'media_type' => 'image', + ], + ], + ], + ])); + } + + public function testDefaultRoleReassignmentDropsOldDefaultRolePath(): void + { + $this->mediaProcessor->method('getProductImageRoles')->willReturn([ + 'sku1' => ['image' => [Store::DEFAULT_STORE_ID => '/r/o/role.jpg']], + ]); + + $this->coordinator->configure(true, ['image']); + $this->coordinator->planRoleAssignments([ + 'sku1' => [Store::DEFAULT_STORE_ID => ['image' => '/n/e/new.jpg']], + ]); + $this->coordinator->registerProduct( + 'SKU1', + [Product::COL_MEDIA_IMAGE => 'new.jpg', 'image' => '/n/e/new.jpg'], + Store::DEFAULT_STORE_ID + ); + $this->coordinator->keepPath('SKU1', 'n/e/new.jpg'); + + [$removals] = $this->coordinator->collectRemovals([ + Store::DEFAULT_STORE_ID => [ + 'sku1' => [ + 'r/o/role.jpg' => [ + 'value_id' => 1, + 'entity_id' => 10, + 'value' => '/r/o/role.jpg', + 'media_type' => 'image', + ], + 'n/e/new.jpg' => [ + 'value_id' => 2, + 'entity_id' => 10, + 'value' => '/n/e/new.jpg', + 'media_type' => 'image', + ], + ], + ], + ]); + + $this->assertCount(1, $removals); + $this->assertSame(1, (int)$removals[0]['value_id']); + } + + public function testNonDefaultStoreRoleIsProtectedWhenNotReassigned(): void + { + $this->mediaProcessor->method('getProductImageRoles')->willReturn([ + 'sku1' => [ + 'image' => [ + Store::DEFAULT_STORE_ID => '/d/e/default_role.jpg', + 1 => '/s/t/store_role.jpg', + ], + ], + ]); + + $this->coordinator->configure(true, ['image']); + $this->coordinator->planRoleAssignments([ + 'sku1' => [Store::DEFAULT_STORE_ID => ['image' => '/n/e/new.jpg']], + ]); + $this->coordinator->registerProduct( + 'SKU1', + [Product::COL_MEDIA_IMAGE => 'new.jpg', 'image' => '/n/e/new.jpg'], + Store::DEFAULT_STORE_ID + ); + $this->coordinator->keepPath('SKU1', 'n/e/new.jpg'); + + [$removals] = $this->coordinator->collectRemovals([ + Store::DEFAULT_STORE_ID => [ + 'sku1' => [ + 'd/e/default_role.jpg' => [ + 'value_id' => 1, + 'entity_id' => 10, + 'value' => '/d/e/default_role.jpg', + 'media_type' => 'image', + ], + 's/t/store_role.jpg' => [ + 'value_id' => 2, + 'entity_id' => 10, + 'value' => '/s/t/store_role.jpg', + 'media_type' => 'image', + ], + 'o/l/old_extra.jpg' => [ + 'value_id' => 3, + 'entity_id' => 10, + 'value' => '/o/l/old_extra.jpg', + 'media_type' => 'image', + ], + 'n/e/new.jpg' => [ + 'value_id' => 4, + 'entity_id' => 10, + 'value' => '/n/e/new.jpg', + 'media_type' => 'image', + ], + ], + ], + ]); + + $this->assertEqualsCanonicalizing([1, 3], array_map('intval', array_column($removals, 'value_id'))); + } + + public function testNonDefaultStoreRoleReassignmentAllowsDroppingOldStoreRolePath(): void + { + $this->mediaProcessor->method('getProductImageRoles')->willReturn([ + 'sku1' => [ + 'image' => [ + Store::DEFAULT_STORE_ID => '/d/e/default_role.jpg', + 1 => '/s/t/store_role.jpg', + ], + ], + ]); + + $this->coordinator->configure(true, ['image']); + $this->coordinator->planRoleAssignments([ + 'sku1' => [ + Store::DEFAULT_STORE_ID => ['image' => '/n/e/new.jpg'], + 1 => ['image' => '/n/e/new.jpg'], + ], + ]); + $this->coordinator->registerProduct( + 'SKU1', + [Product::COL_MEDIA_IMAGE => 'new.jpg', 'image' => '/n/e/new.jpg'], + Store::DEFAULT_STORE_ID + ); + $this->coordinator->keepPath('SKU1', 'n/e/new.jpg'); + + [$removals] = $this->coordinator->collectRemovals([ + Store::DEFAULT_STORE_ID => [ + 'sku1' => [ + 'd/e/default_role.jpg' => [ + 'value_id' => 1, + 'entity_id' => 10, + 'value' => '/d/e/default_role.jpg', + 'media_type' => 'image', + ], + 's/t/store_role.jpg' => [ + 'value_id' => 2, + 'entity_id' => 10, + 'value' => '/s/t/store_role.jpg', + 'media_type' => 'image', + ], + 'o/l/old_extra.jpg' => [ + 'value_id' => 3, + 'entity_id' => 10, + 'value' => '/o/l/old_extra.jpg', + 'media_type' => 'image', + ], + 'n/e/new.jpg' => [ + 'value_id' => 4, + 'entity_id' => 10, + 'value' => '/n/e/new.jpg', + 'media_type' => 'image', + ], + ], + ], + ]); + + $this->assertEqualsCanonicalizing([1, 2, 3], array_map('intval', array_column($removals, 'value_id'))); + } + + public function testAccumulatesKeepAndRolePlanAcrossSimulatedBunches(): void + { + $this->mediaProcessor->expects($this->once()) + ->method('getProductImageRoles') + ->willReturn([ + 'sku1' => [ + 'image' => [ + Store::DEFAULT_STORE_ID => '/d/e/default_role.jpg', + 1 => '/s/t/store_role.jpg', + ], + ], + ]); + + $this->coordinator->configure(true, ['image']); + + // Bunch 1 + $this->coordinator->planRoleAssignments([ + 'sku1' => [Store::DEFAULT_STORE_ID => ['image' => '/n/e/new.jpg']], + ]); + $this->coordinator->warmRolesCache(['SKU1']); + $this->coordinator->registerProduct( + 'SKU1', + [Product::COL_MEDIA_IMAGE => 'new.jpg', 'image' => '/n/e/new.jpg'], + Store::DEFAULT_STORE_ID + ); + $this->coordinator->keepPath('SKU1', 'n/e/new.jpg'); + + // Bunch 2 + $this->coordinator->planRoleAssignments([ + 'sku1' => [1 => ['image' => '/n/e/new.jpg']], + ]); + $this->coordinator->keepPath('SKU1', 'n/e/new.jpg'); + + $this->assertTrue($this->coordinator->hasRegisteredProducts()); + $this->assertSame(['SKU1'], $this->coordinator->getRegisteredSkus()); + + [$removals] = $this->coordinator->collectRemovals([ + Store::DEFAULT_STORE_ID => [ + 'sku1' => [ + 'd/e/default_role.jpg' => [ + 'value_id' => 1, + 'entity_id' => 10, + 'value' => '/d/e/default_role.jpg', + 'media_type' => 'image', + ], + 's/t/store_role.jpg' => [ + 'value_id' => 2, + 'entity_id' => 10, + 'value' => '/s/t/store_role.jpg', + 'media_type' => 'image', + ], + 'o/l/old_extra.jpg' => [ + 'value_id' => 3, + 'entity_id' => 10, + 'value' => '/o/l/old_extra.jpg', + 'media_type' => 'image', + ], + 'n/e/new.jpg' => [ + 'value_id' => 4, + 'entity_id' => 10, + 'value' => '/n/e/new.jpg', + 'media_type' => 'image', + ], + ], + ], + ]); + + $this->assertEqualsCanonicalizing([1, 2, 3], array_map('intval', array_column($removals, 'value_id'))); + } +} 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 3b9647ad71b58..a0fc10c817873 100644 --- a/app/code/Magento/ImportExport/Block/Adminhtml/Import/Edit/Form.php +++ b/app/code/Magento/ImportExport/Block/Adminhtml/Import/Edit/Form.php @@ -254,6 +254,43 @@ protected function _prepareForm() ), ] ); + $fieldset->addField( + Import::FIELD_NAME_PRODUCT_IMAGE_IMPORT_MODE, + 'select', + [ + 'name' => Import::FIELD_NAME_PRODUCT_IMAGE_IMPORT_MODE, + 'label' => __('Product Images Import Mode'), + 'title' => __('Product Images Import Mode'), + 'required' => false, + 'disabled' => true, + 'css_class' => 'no-display', + 'values' => [ + Import::PRODUCT_IMAGE_IMPORT_MODE_ADD => __('Add new images only (default)'), + Import::PRODUCT_IMAGE_IMPORT_MODE_REPLACE => __('Replace existing images with images from file'), + ], + 'value' => Import::PRODUCT_IMAGE_IMPORT_MODE_ADD, + 'note' => __( + 'Add keeps existing images. Replace removes gallery images not listed in ' + . 'additional_images. Image roles are kept. No effect if additional_images is omitted.' + ), + ] + ); + $fieldset->addField( + Import::FIELD_NAME_PRODUCT_IMAGE_DELETE_UNUSED, + 'checkbox', + [ + 'name' => Import::FIELD_NAME_PRODUCT_IMAGE_DELETE_UNUSED, + 'label' => __('Delete Unused Image Files'), + 'title' => __('Delete Unused Image Files'), + 'required' => false, + 'disabled' => true, + 'css_class' => 'no-display', + 'value' => 1, + 'note' => __( + 'Deletes media files unused by any product (including cache).' + ), + ] + ); $fieldset->addField( Import::FIELD_IMPORT_IDS, 'hidden', diff --git a/app/code/Magento/ImportExport/Model/Import.php b/app/code/Magento/ImportExport/Model/Import.php index 896a0bc3237aa..2cf82b765610b 100644 --- a/app/code/Magento/ImportExport/Model/Import.php +++ b/app/code/Magento/ImportExport/Model/Import.php @@ -73,6 +73,14 @@ class Import extends AbstractModel */ public const FIELD_NAME_IMG_FILE_DIR = 'import_images_file_dir'; + public const string FIELD_NAME_PRODUCT_IMAGE_IMPORT_MODE = 'product_image_import_mode'; + + public const string PRODUCT_IMAGE_IMPORT_MODE_ADD = 'add'; + + public const string PRODUCT_IMAGE_IMPORT_MODE_REPLACE = 'replace'; + + public const string FIELD_NAME_PRODUCT_IMAGE_DELETE_UNUSED = 'product_image_delete_unused'; + /** * Allowed errors count field name */ diff --git a/app/code/Magento/ImportExport/Test/Mftf/ActionGroup/AdminAssertProductImageDeleteUnusedHiddenActionGroup.xml b/app/code/Magento/ImportExport/Test/Mftf/ActionGroup/AdminAssertProductImageDeleteUnusedHiddenActionGroup.xml new file mode 100644 index 0000000000000..f0e21330585a4 --- /dev/null +++ b/app/code/Magento/ImportExport/Test/Mftf/ActionGroup/AdminAssertProductImageDeleteUnusedHiddenActionGroup.xml @@ -0,0 +1,24 @@ + + + + + + + + Asserts Delete Unused Image Files checkbox is not visible (hidden and/or disabled). + + + + + + 1 + deleteUnusedHiddenOrDisabled + + + diff --git a/app/code/Magento/ImportExport/Test/Mftf/ActionGroup/AdminAssertProductImageDeleteUnusedVisibleActionGroup.xml b/app/code/Magento/ImportExport/Test/Mftf/ActionGroup/AdminAssertProductImageDeleteUnusedVisibleActionGroup.xml new file mode 100644 index 0000000000000..1219614b70e2a --- /dev/null +++ b/app/code/Magento/ImportExport/Test/Mftf/ActionGroup/AdminAssertProductImageDeleteUnusedVisibleActionGroup.xml @@ -0,0 +1,25 @@ + + + + + + + + Asserts Delete Unused Image Files checkbox is visible and enabled on the Import form. + + + + + + + 1 + deleteUnusedEnabledVisible + + + diff --git a/app/code/Magento/ImportExport/Test/Mftf/ActionGroup/AdminAssertProductImageImportModeHiddenActionGroup.xml b/app/code/Magento/ImportExport/Test/Mftf/ActionGroup/AdminAssertProductImageImportModeHiddenActionGroup.xml new file mode 100644 index 0000000000000..91452ec17bd1f --- /dev/null +++ b/app/code/Magento/ImportExport/Test/Mftf/ActionGroup/AdminAssertProductImageImportModeHiddenActionGroup.xml @@ -0,0 +1,24 @@ + + + + + + + + Asserts Product Images Import Mode is not visible (hidden and/or disabled). + + + + + + 1 + modeFieldHiddenOrDisabled + + + diff --git a/app/code/Magento/ImportExport/Test/Mftf/ActionGroup/AdminAssertProductImageImportModeVisibleActionGroup.xml b/app/code/Magento/ImportExport/Test/Mftf/ActionGroup/AdminAssertProductImageImportModeVisibleActionGroup.xml new file mode 100644 index 0000000000000..f536e9a6c45fd --- /dev/null +++ b/app/code/Magento/ImportExport/Test/Mftf/ActionGroup/AdminAssertProductImageImportModeVisibleActionGroup.xml @@ -0,0 +1,26 @@ + + + + + + + + Asserts Product Images Import Mode is visible and enabled on the Import form. + + + + + + + + 1 + modeFieldEnabledVisible + + + diff --git a/app/code/Magento/ImportExport/Test/Mftf/ActionGroup/AdminSelectProductImageImportModeActionGroup.xml b/app/code/Magento/ImportExport/Test/Mftf/ActionGroup/AdminSelectProductImageImportModeActionGroup.xml new file mode 100644 index 0000000000000..5c604ec55054d --- /dev/null +++ b/app/code/Magento/ImportExport/Test/Mftf/ActionGroup/AdminSelectProductImageImportModeActionGroup.xml @@ -0,0 +1,28 @@ + + + + + + + + Ensures Products + Add/Update are selected, then selects Product Images Import Mode. + The field is visible only for Products entity with Add/Update behavior. + + + + + + + + + + + + + diff --git a/app/code/Magento/ImportExport/Test/Mftf/Section/AdminImportMainSection.xml b/app/code/Magento/ImportExport/Test/Mftf/Section/AdminImportMainSection.xml index 57322b3481e26..f8e91f8583605 100644 --- a/app/code/Magento/ImportExport/Test/Mftf/Section/AdminImportMainSection.xml +++ b/app/code/Magento/ImportExport/Test/Mftf/Section/AdminImportMainSection.xml @@ -22,6 +22,10 @@ + + + + diff --git a/app/code/Magento/ImportExport/Test/Mftf/Test/AdminProductImageDeleteUnusedVisibilityTest.xml b/app/code/Magento/ImportExport/Test/Mftf/Test/AdminProductImageDeleteUnusedVisibilityTest.xml new file mode 100644 index 0000000000000..4ef575c79803d --- /dev/null +++ b/app/code/Magento/ImportExport/Test/Mftf/Test/AdminProductImageDeleteUnusedVisibilityTest.xml @@ -0,0 +1,59 @@ + + + + + + + + + + <description value="Checkbox is hidden by default, when image mode is Add, and for non-qualifying entity/behavior; shown only when Product Images Import Mode is Replace."/> + <severity value="AVERAGE"/> + <group value="importExport"/> + <group value="cloud"/> + </annotations> + <before> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + </before> + <after> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logoutFromAdmin"/> + </after> + + <actionGroup ref="AdminNavigateToImportPageActionGroup" stepKey="navigateToImportPage"/> + <waitForPageLoad stepKey="waitForImportPageLoad"/> + <waitForElementVisible selector="{{AdminImportMainSection.entityType}}" stepKey="waitForEntityType"/> + + <actionGroup ref="AdminAssertProductImageDeleteUnusedHiddenActionGroup" stepKey="assertHiddenBeforeEntity"/> + + <selectOption selector="{{AdminImportMainSection.entityType}}" userInput="Products" stepKey="selectProductsEntity"/> + <waitForElementVisible selector="{{AdminImportMainSection.importBehavior}}" time="30" stepKey="waitForImportBehavior"/> + <waitForElementVisible selector="{{AdminImportMainSection.imagesFileDirectory}}" time="30" stepKey="waitForUploadFieldset"/> + <selectOption selector="{{AdminImportMainSection.importBehavior}}" userInput="Add/Update" stepKey="selectAddUpdateBehavior"/> + <waitForElementVisible selector="{{AdminImportMainSection.productImageImportMode}}" time="30" stepKey="waitForImageMode"/> + + <!-- Image mode defaults to Add: checkbox must stay hidden. --> + <actionGroup ref="AdminAssertProductImageDeleteUnusedHiddenActionGroup" stepKey="assertHiddenWhenImageModeAdd"/> + + <selectOption selector="{{AdminImportMainSection.productImageImportMode}}" userInput="Replace existing images with images from file" stepKey="selectReplaceImageMode"/> + <actionGroup ref="AdminAssertProductImageDeleteUnusedVisibleActionGroup" stepKey="assertVisibleForReplaceImageMode"/> + + <selectOption selector="{{AdminImportMainSection.productImageImportMode}}" userInput="Add new images only (default)" stepKey="selectAddImageMode"/> + <actionGroup ref="AdminAssertProductImageDeleteUnusedHiddenActionGroup" stepKey="assertHiddenAfterSwitchBackToAdd"/> + + <selectOption selector="{{AdminImportMainSection.productImageImportMode}}" userInput="Replace existing images with images from file" stepKey="selectReplaceImageModeAgain"/> + <actionGroup ref="AdminAssertProductImageDeleteUnusedVisibleActionGroup" stepKey="assertVisibleAgainForReplace"/> + + <selectOption selector="{{AdminImportMainSection.importBehavior}}" userInput="Replace" stepKey="selectReplaceBehavior"/> + <actionGroup ref="AdminAssertProductImageDeleteUnusedHiddenActionGroup" stepKey="assertHiddenForReplaceBehavior"/> + + <selectOption selector="{{AdminImportMainSection.entityType}}" userInput="Advanced Pricing" stepKey="selectAdvancedPricingEntity"/> + <waitForPageLoad stepKey="waitAfterNonProductEntity"/> + <actionGroup ref="AdminAssertProductImageDeleteUnusedHiddenActionGroup" stepKey="assertHiddenForNonProductEntity"/> + </test> +</tests> diff --git a/app/code/Magento/ImportExport/Test/Mftf/Test/AdminProductImageImportModeVisibilityTest.xml b/app/code/Magento/ImportExport/Test/Mftf/Test/AdminProductImageImportModeVisibilityTest.xml new file mode 100644 index 0000000000000..096f26ecd4c21 --- /dev/null +++ b/app/code/Magento/ImportExport/Test/Mftf/Test/AdminProductImageImportModeVisibilityTest.xml @@ -0,0 +1,56 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- +/** + * Copyright 2026 Adobe + * All Rights Reserved. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminProductImageImportModeVisibilityTest"> + <annotations> + <features value="Import/Export"/> + <stories value="Import product images mode"/> + <title value="Product Images Import Mode field visibility depends on entity and behavior"/> + <description value="Product Images Import Mode is shown only for Products + Add/Update; hidden for Replace/Delete and non-product entities."/> + <severity value="AVERAGE"/> + <group value="importExport"/> + <group value="cloud"/> + </annotations> + <before> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + </before> + <after> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logoutFromAdmin"/> + </after> + + <actionGroup ref="AdminNavigateToImportPageActionGroup" stepKey="navigateToImportPage"/> + <waitForPageLoad stepKey="waitForImportPageLoad"/> + <waitForElementVisible selector="{{AdminImportMainSection.entityType}}" stepKey="waitForEntityType"/> + + <!-- Field starts hidden until entity/behavior qualify. --> + <actionGroup ref="AdminAssertProductImageImportModeHiddenActionGroup" stepKey="assertHiddenBeforeEntity"/> + + <!-- Products alone is not enough: explicitly set Add/Update (do not rely on empty-option fallback). --> + <selectOption selector="{{AdminImportMainSection.entityType}}" userInput="Products" stepKey="selectProductsEntity"/> + <waitForElementVisible selector="{{AdminImportMainSection.importBehavior}}" time="30" stepKey="waitForImportBehavior"/> + <waitForElementVisible selector="{{AdminImportMainSection.imagesFileDirectory}}" time="30" stepKey="waitForUploadFieldset"/> + <selectOption selector="{{AdminImportMainSection.importBehavior}}" userInput="Add/Update" stepKey="selectAddUpdateBehavior"/> + <actionGroup ref="AdminAssertProductImageImportModeVisibleActionGroup" stepKey="assertVisibleForProductsAddUpdate"/> + + <selectOption selector="{{AdminImportMainSection.importBehavior}}" userInput="Replace" stepKey="selectReplaceBehavior"/> + <actionGroup ref="AdminAssertProductImageImportModeHiddenActionGroup" stepKey="assertHiddenForReplace"/> + + <selectOption selector="{{AdminImportMainSection.importBehavior}}" userInput="Add/Update" stepKey="selectAddUpdateBehaviorAgain"/> + <actionGroup ref="AdminAssertProductImageImportModeVisibleActionGroup" stepKey="assertVisibleAgainForAddUpdate"/> + + <selectOption selector="{{AdminImportMainSection.importBehavior}}" userInput="Delete" stepKey="selectDeleteBehavior"/> + <actionGroup ref="AdminAssertProductImageImportModeHiddenActionGroup" stepKey="assertHiddenForDelete"/> + + <!-- Non-product entity: field must stay hidden even if behavior options change. --> + <selectOption selector="{{AdminImportMainSection.entityType}}" userInput="Advanced Pricing" stepKey="selectAdvancedPricingEntity"/> + <waitForPageLoad stepKey="waitAfterNonProductEntity"/> + <actionGroup ref="AdminAssertProductImageImportModeHiddenActionGroup" stepKey="assertHiddenForNonProductEntity"/> + </test> +</tests> diff --git a/app/code/Magento/ImportExport/i18n/en_US.csv b/app/code/Magento/ImportExport/i18n/en_US.csv index 6218e1288f2bc..b8ea8442fa056 100644 --- a/app/code/Magento/ImportExport/i18n/en_US.csv +++ b/app/code/Magento/ImportExport/i18n/en_US.csv @@ -130,3 +130,7 @@ Summary,Summary "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" +"Product Images Import Mode","Product Images Import Mode" +"Add new images only (default)","Add new images only (default)" +"Replace existing images with images from file","Replace existing images with images from file" +"Add keeps existing images. Replace removes gallery images not listed in additional_images. Image roles are kept. No effect if additional_images is omitted.","Add keeps existing images. Replace removes gallery images not listed in additional_images. Image roles are kept. No effect if additional_images is omitted." 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 a213bf9047985..79d90b2d102d1 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 @@ -8,6 +8,11 @@ /** @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; +$productImageImportModeField = \Magento\ImportExport\Model\Import::FIELD_NAME_PRODUCT_IMAGE_IMPORT_MODE; +$productImageDeleteUnusedField = \Magento\ImportExport\Model\Import::FIELD_NAME_PRODUCT_IMAGE_DELETE_UNUSED; +$behaviorAppend = \Magento\ImportExport\Model\Import::BEHAVIOR_APPEND; +$productImageImportModeAdd = \Magento\ImportExport\Model\Import::PRODUCT_IMAGE_IMPORT_MODE_ADD; +$productImageImportModeReplace = \Magento\ImportExport\Model\Import::PRODUCT_IMAGE_IMPORT_MODE_REPLACE; $uploaderErrorMessage = $block->escapeHtml( __('Content of uploaded file was changed, please re-upload the file') ); @@ -17,8 +22,9 @@ $uploaderErrorMessage = $block->escapeHtml( require([ 'jquery', 'Magento_Ui/js/modal/alert', + 'Magento_ImportExport/js/form/product-image-import-mode', 'prototype' -], function(jQuery, alert){ +], function(jQuery, alert, productImageImportModeHelpers){ //<![CDATA[ // Temporary Class will be replaced after refactoring of import/export functionality @@ -53,6 +59,13 @@ require([ */ sampleFilesBaseUrl: '{$block->escapeJs($block->getUrl('*/*/download/', ['filename' => 'entity-name']))}', + productEntityCode: 'catalog_product', + behaviorAppend: '{$behaviorAppend}', + productImageImportModeField: '{$productImageImportModeField}', + productImageDeleteUnusedField: '{$productImageDeleteUnusedField}', + productImageImportModeAdd: '{$productImageImportModeAdd}', + productImageImportModeReplace: '{$productImageImportModeReplace}', + /** * Loaded file last modified * @type {int|null} @@ -113,10 +126,11 @@ require([ * @param {boolean} isShow */ showUploadFile: function(isShow) { + var fieldset = jQuery('#upload_file_fieldset'); if (isShow == true) { - jQuery('#upload_file_fieldset').show(); + fieldset.removeClass('no-display').show(); } else { - jQuery('#upload_file_fieldset').hide(); + fieldset.addClass('no-display').hide(); } }, @@ -154,6 +168,64 @@ require([ this.handleImportBehaviorSelector(); }, + showProductImageImportMode: function() { + var entity = jQuery('#entity').val(), + requiredBehavior = entity ? this.entityBehaviors[entity] : null, + behaviorInput = requiredBehavior ? jQuery('#' + requiredBehavior) : null, + behavior = null, + field = jQuery('#' + this.productImageImportModeField), + fieldRow = jQuery('.field-' + this.productImageImportModeField), + deleteUnusedField = jQuery('#' + this.productImageDeleteUnusedField), + deleteUnusedFieldRow = jQuery('.field-' + this.productImageDeleteUnusedField), + isVisible, + isDeleteUnusedVisible; + if (behaviorInput && behaviorInput.length) { + behavior = behaviorInput.val(); + if (!behavior) { + behaviorInput.prop('selectedIndex', 0); + behavior = behaviorInput.val() || this.behaviorAppend; + } + } + if (!fieldRow.length && field.length) { + fieldRow = field.closest('div.admin__field, .field'); + } + if (!deleteUnusedFieldRow.length && deleteUnusedField.length) { + deleteUnusedFieldRow = deleteUnusedField.closest('div.admin__field, .field'); + } + isVisible = productImageImportModeHelpers.shouldShowProductImageImportMode( + entity, + behavior, + this.productEntityCode, + this.behaviorAppend + ); + + productImageImportModeHelpers.applyProductImageImportModeVisibility( + field, + fieldRow, + isVisible, + this.productImageImportModeAdd + ); + + isDeleteUnusedVisible = productImageImportModeHelpers.shouldShowProductImageDeleteUnused( + entity, + behavior, + field.length ? field.val() : null, + this.productEntityCode, + this.behaviorAppend, + this.productImageImportModeReplace + ); + productImageImportModeHelpers.applyProductImageDeleteUnusedVisibility( + deleteUnusedField, + deleteUnusedFieldRow, + isDeleteUnusedVisible + ); + + if (field.length && !field.data('deleteUnusedBound')) { + field.data('deleteUnusedBound', true); + field.on('change', jQuery.proxy(this.showProductImageImportMode, this)); + } + }, + /** * Handle value change in behavior selector */ @@ -170,6 +242,7 @@ require([ jQuery('#' + requiredBehavior + '-note').html(''); } } + this.showProductImageImportMode(); }, /** diff --git a/app/code/Magento/ImportExport/view/adminhtml/web/js/form/product-image-import-mode.js b/app/code/Magento/ImportExport/view/adminhtml/web/js/form/product-image-import-mode.js new file mode 100644 index 0000000000000..8c79415407c75 --- /dev/null +++ b/app/code/Magento/ImportExport/view/adminhtml/web/js/form/product-image-import-mode.js @@ -0,0 +1,67 @@ +/** + * Copyright 2026 Adobe + * All Rights Reserved. + */ +define([], function () { + 'use strict'; + + return { + shouldShowProductImageImportMode: function (entity, behavior, productEntityCode, behaviorAppend) { + return entity === productEntityCode && behavior === behaviorAppend; + }, + + shouldShowProductImageDeleteUnused: function ( + entity, + behavior, + imageMode, + productEntityCode, + behaviorAppend, + replaceMode + ) { + return this.shouldShowProductImageImportMode(entity, behavior, productEntityCode, behaviorAppend) + && imageMode === replaceMode; + }, + + applyProductImageImportModeVisibility: function ($field, $fieldRow, isVisible, defaultMode) { + if (!$field || !$field.length) { + return; + } + + if (isVisible) { + $field.prop('disabled', false).show(); + + if ($fieldRow && $fieldRow.length) { + $fieldRow.removeClass('no-display').show(); + } + } else { + $field.val(defaultMode); + $field.prop('disabled', true).hide(); + + if ($fieldRow && $fieldRow.length) { + $fieldRow.addClass('no-display').hide(); + } + } + }, + + applyProductImageDeleteUnusedVisibility: function ($field, $fieldRow, isVisible) { + if (!$field || !$field.length) { + return; + } + + if (isVisible) { + $field.prop('disabled', false).show(); + + if ($fieldRow && $fieldRow.length) { + $fieldRow.removeClass('no-display').show(); + } + } else { + $field.prop('checked', false); + $field.prop('disabled', true).hide(); + + if ($fieldRow && $fieldRow.length) { + $fieldRow.addClass('no-display').hide(); + } + } + } + }; +}); diff --git a/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/ProductTest/ProductImagesTest.php b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/ProductTest/ProductImagesTest.php index b5390f8886981..42e3c5af0f752 100644 --- a/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/ProductTest/ProductImagesTest.php +++ b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/ProductTest/ProductImagesTest.php @@ -187,6 +187,455 @@ public function testSaveMediaImageDuplicateImages(): void $this->assertCount($imagesCount, $this->getProductBySku('simple_new')->getMediaGalleryImages()->getItems()); } + /** + * @magentoDataFixture mediaImportImageFixture + * @return void + */ + public function testReplaceImageImportModeRemovesUnlistedAdditionalImages(): void + { + $this->importDataForMediaTest('import_media_replace_setup.csv'); + $product = $this->getProductBySku('simple_new'); + $this->assertCount(5, $product->getMediaGalleryImages()->getItems()); + + $this->importDataForMediaTest( + 'import_media_replace_additional_images.csv', + 0, + \Magento\ImportExport\Model\Import::PRODUCT_IMAGE_IMPORT_MODE_REPLACE + ); + + $product = $this->getProductBySku('simple_new'); + $files = array_map( + static function (\Magento\Framework\DataObject $item) { + return $item->getFile(); + }, + array_values($product->getMediaGalleryImages()->getItems()) + ); + + $this->assertContains('/m/a/magento_image.jpg', $files); + $this->assertContains('/m/a/magento_small_image.jpg', $files); + $this->assertContains('/m/a/magento_thumbnail.jpg', $files); + $this->assertContains('/r/e/repro_replace_additional_a.jpg', $files); + $this->assertNotContains('/r/e/repro_replace_additional_b.jpg', $files); + $this->assertCount(4, $files); + + $this->assertEquals('/m/a/magento_image.jpg', $product->getData('image')); + $this->assertEquals('/m/a/magento_small_image.jpg', $product->getData('small_image')); + $this->assertEquals('/m/a/magento_thumbnail.jpg', $product->getData('thumbnail')); + + $this->importDataForMediaTest('import_media_replace_append.csv'); + $product = $this->getProductBySku('simple_new'); + $filesAfterAppend = array_map( + static function (\Magento\Framework\DataObject $item) { + return $item->getFile(); + }, + array_values($product->getMediaGalleryImages()->getItems()) + ); + $this->assertContains('/r/e/repro_replace_additional_a.jpg', $filesAfterAppend); + $this->assertContains('/r/e/repro_replace_additional_c.jpg', $filesAfterAppend); + $this->assertNotContains('/r/e/repro_replace_additional_b.jpg', $filesAfterAppend); + $this->assertGreaterThanOrEqual(5, count($filesAfterAppend)); + $this->assertEquals('/m/a/magento_image.jpg', $product->getData('image')); + $this->assertEquals('/m/a/magento_small_image.jpg', $product->getData('small_image')); + $this->assertEquals('/m/a/magento_thumbnail.jpg', $product->getData('thumbnail')); + } + + /** + * @magentoDataFixture mediaImportImageFixture + * @return void + */ + public function testReplaceImageImportModeWithEmptyAdditionalImagesKeepsRoles(): void + { + $this->importDataForMediaTest('import_media_replace_setup.csv'); + $this->assertCount(5, $this->getProductBySku('simple_new')->getMediaGalleryImages()->getItems()); + + $this->importDataForMediaTest( + 'import_media_replace_empty_additional.csv', + 0, + \Magento\ImportExport\Model\Import::PRODUCT_IMAGE_IMPORT_MODE_REPLACE + ); + + $product = $this->getProductBySku('simple_new'); + $files = array_map( + static function (\Magento\Framework\DataObject $item) { + return $item->getFile(); + }, + array_values($product->getMediaGalleryImages()->getItems()) + ); + + $this->assertContains('/m/a/magento_image.jpg', $files); + $this->assertContains('/m/a/magento_small_image.jpg', $files); + $this->assertContains('/m/a/magento_thumbnail.jpg', $files); + $this->assertNotContains('/r/e/repro_replace_additional_a.jpg', $files); + $this->assertNotContains('/r/e/repro_replace_additional_b.jpg', $files); + $this->assertCount(3, $files); + $this->assertEquals('/m/a/magento_image.jpg', $product->getData('image')); + $this->assertEquals('/m/a/magento_small_image.jpg', $product->getData('small_image')); + $this->assertEquals('/m/a/magento_thumbnail.jpg', $product->getData('thumbnail')); + $this->assertEquals('/m/a/magento_image.jpg', $product->getData('swatch_image')); + } + + /** + * @magentoDataFixture mediaImportImageFixture + * @return void + */ + public function testReplaceSkipsUnlinkWhenImageUploadFails(): void + { + $this->importDataForMediaTest('import_media_replace_setup.csv'); + $filesBefore = $this->getGalleryFiles('simple_new'); + $this->assertContains('/r/e/repro_replace_additional_a.jpg', $filesBefore); + $this->assertContains('/r/e/repro_replace_additional_b.jpg', $filesBefore); + + $this->importDataForMediaTest( + 'import_media_replace_with_missing_image.csv', + 2, + Import::PRODUCT_IMAGE_IMPORT_MODE_REPLACE + ); + + $files = $this->getGalleryFiles('simple_new'); + $this->assertContains('/r/e/repro_replace_additional_a.jpg', $files); + $this->assertContains('/r/e/repro_replace_additional_b.jpg', $files); + $this->assertContains('/m/a/magento_image.jpg', $files); + $this->assertContains('/m/a/magento_small_image.jpg', $files); + $this->assertContains('/m/a/magento_thumbnail.jpg', $files); + } + + /** + * @magentoDataFixture mediaImportImageFixture + * @return void + */ + public function testReplacePromotesAdditionalToThumbnailAndDropsOldThumbnail(): void + { + $this->importDataForMediaTest('import_media_replace_setup.csv'); + // Point thumbnail at former additional A; B remains a pure additional image. + $this->importDataForMediaTest('import_media_replace_thumbnail_to_additional_a.csv'); + + $product = $this->getProductBySku('simple_new'); + $this->assertEquals('/r/e/repro_replace_additional_a.jpg', $product->getData('thumbnail')); + $filesBefore = $this->getGalleryFiles('simple_new'); + $this->assertContains('/r/e/repro_replace_additional_a.jpg', $filesBefore); + $this->assertContains('/r/e/repro_replace_additional_b.jpg', $filesBefore); + + $this->importDataForMediaTest( + 'import_media_replace_thumbnail_from_additional_empty.csv', + 0, + Import::PRODUCT_IMAGE_IMPORT_MODE_REPLACE + ); + + $product = $this->getProductBySku('simple_new'); + $files = $this->getGalleryFiles('simple_new'); + + $this->assertEquals('/r/e/repro_replace_additional_b.jpg', $product->getData('thumbnail')); + $this->assertEquals('/m/a/magento_image.jpg', $product->getData('image')); + $this->assertEquals('/m/a/magento_small_image.jpg', $product->getData('small_image')); + $this->assertEquals('/m/a/magento_image.jpg', $product->getData('swatch_image')); + + $this->assertContains('/r/e/repro_replace_additional_b.jpg', $files); + $this->assertContains('/m/a/magento_image.jpg', $files); + $this->assertContains('/m/a/magento_small_image.jpg', $files); + // Old thumbnail A is no longer a role and not in additional_images → removed. + $this->assertNotContains('/r/e/repro_replace_additional_a.jpg', $files); + // B was only an additional image before; now it is the thumbnail (kept). + // Pure extras not re-listed are removed; former B is kept via thumbnail keepPath. + } + + /** + * @magentoDataFixture mediaImportImageFixture + * @return void + */ + public function testReplaceWithoutAdditionalImagesColumnDoesNotDropGallery(): void + { + $this->importDataForMediaTest('import_media_replace_setup.csv'); + $this->assertCount(5, $this->getProductBySku('simple_new')->getMediaGalleryImages()->getItems()); + + $this->importDataForMediaTest( + 'import_media_replace_roles_only.csv', + 0, + \Magento\ImportExport\Model\Import::PRODUCT_IMAGE_IMPORT_MODE_REPLACE + ); + + $product = $this->getProductBySku('simple_new'); + $files = array_map( + static function (\Magento\Framework\DataObject $item) { + return $item->getFile(); + }, + array_values($product->getMediaGalleryImages()->getItems()) + ); + + $this->assertContains('/r/e/repro_replace_additional_a.jpg', $files); + $this->assertContains('/r/e/repro_replace_additional_b.jpg', $files); + $this->assertCount(5, $files); + } + + /** + + * @magentoDataFixture mediaImportImageFixture + * @return void + */ + public function testReplaceKeepsUnionOfAdditionalImagesFromMultipleRows(): void + { + $this->importDataForMediaTest('import_media_replace_setup.csv'); + $this->assertCount(5, $this->getGalleryFiles('simple_new')); + + $this->importDataForMediaTest( + 'import_media_replace_union_rows.csv', + 0, + Import::PRODUCT_IMAGE_IMPORT_MODE_REPLACE + ); + + $files = $this->getGalleryFiles('simple_new'); + $this->assertContains('/r/e/repro_replace_additional_a.jpg', $files); + $this->assertContains('/r/e/repro_replace_additional_c.jpg', $files); + $this->assertNotContains('/r/e/repro_replace_additional_b.jpg', $files); + $this->assertContains('/m/a/magento_image.jpg', $files); + $this->assertContains('/m/a/magento_small_image.jpg', $files); + $this->assertContains('/m/a/magento_thumbnail.jpg', $files); + } + + /** + + * @magentoDataFixture mediaImportImageFixture + * @magentoDataFixture Magento/Store/_files/core_fixturestore.php + * @return void + */ + public function testReplaceAcrossBunchesWithStoreRoleReassignment(): void + { + $this->importDataForMediaTest('import_media_replace_setup.csv'); + // Make store-scoped base use additional B so replace must not drop it until reassigned. + $this->importDataForMediaTest('import_media_assign_store_base.csv'); + $this->assertEquals( + '/r/e/repro_replace_additional_b.jpg', + $this->getProductBySku('simple_new', 'fixturestore')->getData('image') + ); + + // bunch_size=1 forces default and store rows into different bunches. + $this->importDataForMediaTest( + 'import_media_replace_cross_bunch.csv', + 0, + Import::PRODUCT_IMAGE_IMPORT_MODE_REPLACE, + 1 + ); + + $files = $this->getGalleryFiles('simple_new'); + $this->assertContains('/r/e/repro_replace_additional_a.jpg', $files); + $this->assertNotContains('/r/e/repro_replace_additional_b.jpg', $files); + $this->assertContains('/m/a/magento_image.jpg', $files); + $this->assertContains('/m/a/magento_small_image.jpg', $files); + $this->assertContains('/m/a/magento_thumbnail.jpg', $files); + + $this->assertEquals( + '/m/a/magento_image.jpg', + $this->getProductBySku('simple_new', 'fixturestore')->getData('image') + ); + $this->assertEquals('/m/a/magento_image.jpg', $this->getProductBySku('simple_new')->getData('image')); + } + + /** + + * @magentoDataFixture mediaImportImageFixture + * @magentoDataFixture Magento/Store/_files/core_fixturestore.php + * @return void + */ + public function testStoreAdditionalImagesImportedWithoutDefaultReplaceRow(): void + { + $this->importDataForMediaTest('import_media_replace_setup.csv'); + $this->assertCount(5, $this->getGalleryFiles('simple_new')); + + $this->importDataForMediaTest( + 'import_media_replace_store_additional_only.csv', + 0, + Import::PRODUCT_IMAGE_IMPORT_MODE_REPLACE + ); + + $files = $this->getGalleryFiles('simple_new'); + $this->assertContains('/r/e/repro_replace_additional_a.jpg', $files); + $this->assertContains('/r/e/repro_replace_additional_b.jpg', $files); + $this->assertContains('/r/e/repro_replace_additional_c.jpg', $files); + $this->assertCount(6, $files); + } + + /** + * @magentoDataFixture mediaImportImageFixture + * @return void + */ + public function testReplaceDoesNotRemoveExternalVideoEntries(): void + { + $this->importDataForMediaTest('import_media_replace_setup.csv'); + $this->insertExternalVideoGalleryRow('simple_new'); + + $this->assertSame(1, $this->countExternalVideoEntries('simple_new')); + + $this->importDataForMediaTest( + 'import_media_replace_additional_images.csv', + 0, + Import::PRODUCT_IMAGE_IMPORT_MODE_REPLACE + ); + + $files = $this->getGalleryFiles('simple_new'); + $this->assertContains('/r/e/repro_replace_additional_a.jpg', $files); + $this->assertNotContains('/r/e/repro_replace_additional_b.jpg', $files); + $this->assertSame(1, $this->countExternalVideoEntries('simple_new')); + } + + /** + * @magentoDataFixture mediaImportImageFixture + * @return void + */ + public function testReplaceWithoutDeleteUnusedKeepsPhysicalFiles(): void + { + $this->importDataForMediaTest('import_media_replace_setup.csv'); + $removedPath = '/r/e/repro_replace_additional_b.jpg'; + $this->assertTrue($this->mediaFileExists($removedPath)); + $this->assertGreaterThan(0, $this->countMediaGalleryRowsForPath($removedPath)); + + $this->importDataForMediaTest( + 'import_media_replace_additional_images.csv', + 0, + Import::PRODUCT_IMAGE_IMPORT_MODE_REPLACE + ); + + $this->assertNotContains($removedPath, $this->getGalleryFiles('simple_new')); + $this->assertSame(0, $this->countMediaGalleryRowsForPath($removedPath)); + $this->assertTrue($this->mediaFileExists($removedPath)); + } + + /** + * @magentoDataFixture mediaImportImageFixture + * @return void + */ + public function testReplaceWithDeleteUnusedRemovesPhysicalFiles(): void + { + $this->importDataForMediaTest('import_media_replace_setup.csv'); + $removedPath = '/r/e/repro_replace_additional_b.jpg'; + $keptPath = '/r/e/repro_replace_additional_a.jpg'; + $this->assertTrue($this->mediaFileExists($removedPath)); + $this->assertTrue($this->mediaFileExists($keptPath)); + + $this->importDataForMediaTest( + 'import_media_replace_additional_images.csv', + 0, + Import::PRODUCT_IMAGE_IMPORT_MODE_REPLACE, + null, + true + ); + + $files = $this->getGalleryFiles('simple_new'); + $this->assertContains($keptPath, $files); + $this->assertNotContains($removedPath, $files); + $this->assertFalse($this->mediaFileExists($removedPath)); + $this->assertTrue($this->mediaFileExists($keptPath)); + } + + /** + * @param string $galleryFile + * @return bool + */ + private function mediaFileExists(string $galleryFile): bool + { + $filesystem = $this->objectManager->get(Filesystem::class); + $mediaDirectory = $filesystem->getDirectoryRead(DirectoryList::MEDIA); + $relative = 'catalog/product/' . ltrim($galleryFile, '/'); + + return $mediaDirectory->isFile($relative); + } + + /** + * @param string $galleryFile + * @return int + */ + private function countMediaGalleryRowsForPath(string $galleryFile): int + { + $connection = $this->objectManager->get(\Magento\Framework\App\ResourceConnection::class) + ->getConnection(); + $table = $connection->getTableName('catalog_product_entity_media_gallery'); + $variants = array_unique([ + $galleryFile, + '/' . ltrim($galleryFile, '/'), + ltrim($galleryFile, '/'), + ]); + + return (int)$connection->fetchOne( + $connection->select() + ->from($table, ['cnt' => new \Zend_Db_Expr('COUNT(value_id)')]) + ->where('value IN (?)', $variants) + ); + } + + /** + * @param string $sku + * @return string[] + */ + private function getGalleryFiles(string $sku): array + { + return array_map( + static function (\Magento\Framework\DataObject $item) { + return $item->getFile(); + }, + array_values($this->getProductBySku($sku)->getMediaGalleryImages()->getItems()) + ); + } + + /** + * @param string $sku + * @return int + */ + private function countExternalVideoEntries(string $sku): int + { + $count = 0; + foreach ($this->getProductBySku($sku)->getMediaGalleryEntries() as $entry) { + if ($entry->getMediaType() === 'external-video') { + $count++; + } + } + return $count; + } + + /** + * Insert a gallery value with media_type=external-video without GalleryManagement save quirks. + * + * @param string $sku + * @return void + */ + private function insertExternalVideoGalleryRow(string $sku): void + { + $connection = $this->objectManager->get(\Magento\Framework\App\ResourceConnection::class) + ->getConnection(); + $productResource = $this->objectManager->get(\Magento\Catalog\Model\ResourceModel\Product::class); + $productId = (int)$productResource->getIdBySku($sku); + $linkField = $this->objectManager->get(\Magento\Framework\EntityManager\MetadataPool::class) + ->getMetadata(\Magento\Catalog\Api\Data\ProductInterface::class) + ->getLinkField(); + $linkValue = (int)$connection->fetchOne( + $connection->select() + ->from($productResource->getEntityTable(), $linkField) + ->where('entity_id = ?', $productId) + ); + + $attributeId = (int)$productResource->getAttribute('media_gallery')->getAttributeId(); + $galleryTable = $productResource->getTable('catalog_product_entity_media_gallery'); + $valueToEntity = $productResource->getTable('catalog_product_entity_media_gallery_value_to_entity'); + $valueTable = $productResource->getTable('catalog_product_entity_media_gallery_value'); + + $connection->insert($galleryTable, [ + 'attribute_id' => $attributeId, + 'value' => '/r/e/replace_test_video.jpg', + 'media_type' => 'external-video', + 'disabled' => 0, + ]); + $valueId = (int)$connection->lastInsertId($galleryTable); + $connection->insert($valueToEntity, [ + 'value_id' => $valueId, + $linkField => $linkValue, + ]); + $connection->insert($valueTable, [ + 'value_id' => $valueId, + 'store_id' => 0, + $linkField => $linkValue, + 'label' => 'Replace Test Video', + 'position' => 99, + 'disabled' => 0, + ]); + } + /** * Test that errors occurred during importing images are logged. * diff --git a/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/ProductTestBase.php b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/ProductTestBase.php index 04d9c305fcfed..e459f86453285 100644 --- a/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/ProductTestBase.php +++ b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/ProductTestBase.php @@ -204,6 +204,18 @@ public static function mediaImportImageFixture() 'source' => __DIR__ . '/_files/magento_additional_image_four.jpg', 'dest' => $dirPath . '/magento_additional_image_four.jpg', ], + [ + 'source' => __DIR__ . '/_files/repro_replace_additional_a.jpg', + 'dest' => $dirPath . '/repro_replace_additional_a.jpg', + ], + [ + 'source' => __DIR__ . '/_files/repro_replace_additional_b.jpg', + 'dest' => $dirPath . '/repro_replace_additional_b.jpg', + ], + [ + 'source' => __DIR__ . '/_files/repro_replace_additional_c.jpg', + 'dest' => $dirPath . '/repro_replace_additional_c.jpg', + ], ]; foreach ($items as $item) { @@ -284,11 +296,35 @@ protected function csvToArray($content, $entityId = null) * * @param string $fileName * @param int $expectedErrors + * @param string $imageImportMode + * @param int|null $bunchSize When set, forces import bunch size (for multi-bunch tests) + * @param bool $deleteUnusedImageFiles * @return void */ - protected function importDataForMediaTest(string $fileName, int $expectedErrors = 0) - { - $this->createNewModel(); + protected function importDataForMediaTest( + string $fileName, + int $expectedErrors = 0, + string $imageImportMode = Import::PRODUCT_IMAGE_IMPORT_MODE_ADD, + ?int $bunchSize = null, + bool $deleteUnusedImageFiles = false + ) { + if ($bunchSize !== null) { + $importExportData = $this->getMockBuilder(Data::class) + ->disableOriginalConstructor() + ->getMock(); + $importExportData->method('getBunchSize')->willReturn($bunchSize); + $this->_model = $this->objectManager->create( + ImportProduct::class, + [ + 'logger' => $this->logger, + 'importExportData' => $importExportData, + ] + ); + } else { + $this->createNewModel(); + } + // StoreResolver is a shared service and caches store codes for the process lifetime. + $this->clearImportStoreResolverCache(); $filesystem = $this->objectManager->get(Filesystem::class); $directory = $filesystem->getDirectoryWrite(DirectoryList::ROOT); $source = $this->objectManager->create( @@ -304,19 +340,31 @@ protected function importDataForMediaTest(string $fileName, int $expectedErrors $tmpDir = $mediaDirPath . DIRECTORY_SEPARATOR . 'import' . DIRECTORY_SEPARATOR . 'images'; $mediaDirectory->create('catalog' . DIRECTORY_SEPARATOR . 'product'); $mediaDirectory->create('import' . DIRECTORY_SEPARATOR . 'images'); - $this->_model->setParameters( - [ - 'behavior' => \Magento\ImportExport\Model\Import::BEHAVIOR_APPEND, - 'entity' => 'catalog_product', - Import::FIELD_NAME_IMG_FILE_DIR => $mediaDirPath . '/import' - ] - ); + $parameters = [ + 'behavior' => \Magento\ImportExport\Model\Import::BEHAVIOR_APPEND, + 'entity' => 'catalog_product', + Import::FIELD_NAME_IMG_FILE_DIR => $mediaDirPath . '/import', + Import::FIELD_NAME_PRODUCT_IMAGE_IMPORT_MODE => $imageImportMode, + ]; + if ($deleteUnusedImageFiles) { + $parameters[Import::FIELD_NAME_PRODUCT_IMAGE_DELETE_UNUSED] = '1'; + } + $this->_model->setParameters($parameters); $uploader = $this->_model->getUploader(); $this->assertTrue($uploader->setDestDir($destDir)); $this->assertTrue($uploader->setTmpDir($tmpDir)); $this->_model->setSource($source); $errors = $this->_model->validateData(); - $this->assertTrue($errors->getErrorsCount() == 0); + $this->assertTrue( + $errors->getErrorsCount() == 0, + array_reduce( + $errors->getAllErrors(), + static function ($output, $error) { + return "$output\n{$error->getErrorMessage()}"; + }, + "Validation failed for {$fileName}:" + ) + ); $this->_model->importData(); $this->assertEquals( $expectedErrors, @@ -331,6 +379,26 @@ function ($output, $error) { ); } + /** + * Reset CatalogImportExport store/website maps so newly created stores are visible. + * + * @return void + */ + protected function clearImportStoreResolverCache(): void + { + $resolver = $this->objectManager->get(Product\StoreResolver::class); + $reflection = new \ReflectionClass($resolver); + foreach (['storeCodeToId', 'storeIdToWebsiteStoreIds', 'websiteCodeToId', 'websiteCodeToStoreIds'] as $property) { + if (!$reflection->hasProperty($property)) { + continue; + } + $prop = $reflection->getProperty($property); + $prop->setAccessible(true); + $prop->setValue($resolver, []); + } + $this->objectManager->get(StoreManagerInterface::class)->reinitStores(); + } + /** * Load product by given product sku * diff --git a/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_assign_store_base.csv b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_assign_store_base.csv new file mode 100644 index 0000000000000..40350bb54ad1c --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_assign_store_base.csv @@ -0,0 +1,2 @@ +sku,store_view_code,base_image +simple_new,fixturestore,repro_replace_additional_b.jpg diff --git a/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_replace_additional_images.csv b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_replace_additional_images.csv new file mode 100644 index 0000000000000..bb99fb891a1c9 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_replace_additional_images.csv @@ -0,0 +1,2 @@ +sku,additional_images +simple_new,repro_replace_additional_a.jpg diff --git a/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_replace_append.csv b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_replace_append.csv new file mode 100644 index 0000000000000..a550850ac7405 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_replace_append.csv @@ -0,0 +1,2 @@ +sku,additional_images +simple_new,repro_replace_additional_c.jpg diff --git a/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_replace_cross_bunch.csv b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_replace_cross_bunch.csv new file mode 100644 index 0000000000000..d61f797424ed7 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_replace_cross_bunch.csv @@ -0,0 +1,3 @@ +sku,store_view_code,additional_images,base_image,small_image,thumbnail_image,swatch_image +simple_new,,repro_replace_additional_a.jpg,magento_image.jpg,magento_small_image.jpg,magento_thumbnail.jpg,magento_image.jpg +simple_new,fixturestore,,magento_image.jpg,magento_small_image.jpg,magento_thumbnail.jpg,magento_image.jpg diff --git a/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_replace_empty_additional.csv b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_replace_empty_additional.csv new file mode 100644 index 0000000000000..5374051c1d627 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_replace_empty_additional.csv @@ -0,0 +1,2 @@ +sku,additional_images +simple_new, diff --git a/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_replace_roles_only.csv b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_replace_roles_only.csv new file mode 100644 index 0000000000000..bfb63fb3fe30d --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_replace_roles_only.csv @@ -0,0 +1,2 @@ +sku,base_image,small_image,thumbnail_image,swatch_image +simple_new,magento_image.jpg,magento_small_image.jpg,magento_thumbnail.jpg,magento_image.jpg diff --git a/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_replace_setup.csv b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_replace_setup.csv new file mode 100644 index 0000000000000..55a1fd4ba9ee6 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_replace_setup.csv @@ -0,0 +1,2 @@ +sku,store_view_code,attribute_set_code,product_type,categories,product_websites,name,description,short_description,weight,product_online,tax_class_name,visibility,price,special_price,special_price_from_date,special_price_to_date,url_key,meta_title,meta_keywords,meta_description,base_image,base_image_label,small_image,small_image_label,thumbnail_image,thumbnail_image_label,swatch_image,swatch_image_label1,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,crosssell_skus,upsell_skus,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,associated_skus +simple_new,,Default,simple,,base,New Product,,,,1,Taxable Goods,"Catalog, Search",10,,,,new-product,New Product,New Product,New Product ,magento_image.jpg,Image Label,magento_small_image.jpg,Small Image Label,magento_thumbnail.jpg,Thumbnail Label,magento_image.jpg,Image Label,10/20/15 07:05,10/20/15 07:05,,,Block after Info Column,,,,,,,,,,,,,"has_options=1,quantity_and_stock_status=In Stock,required_options=1",100,0,1,0,0,1,1,1,10000,1,1,1,1,1,0,1,1,0,0,0,1,,,,"repro_replace_additional_a.jpg,repro_replace_additional_b.jpg","Replace Additional A,Replace Additional B",,,,,,,, diff --git a/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_replace_store_additional_only.csv b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_replace_store_additional_only.csv new file mode 100644 index 0000000000000..c464e22970754 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_replace_store_additional_only.csv @@ -0,0 +1,2 @@ +sku,store_view_code,additional_images +simple_new,fixturestore,repro_replace_additional_c.jpg diff --git a/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_replace_thumbnail_from_additional_empty.csv b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_replace_thumbnail_from_additional_empty.csv new file mode 100644 index 0000000000000..81aed903821bf --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_replace_thumbnail_from_additional_empty.csv @@ -0,0 +1,2 @@ +sku,additional_images,base_image,small_image,thumbnail_image,swatch_image +simple_new,,magento_image.jpg,magento_small_image.jpg,repro_replace_additional_b.jpg,magento_image.jpg diff --git a/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_replace_thumbnail_to_additional_a.csv b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_replace_thumbnail_to_additional_a.csv new file mode 100644 index 0000000000000..12ead683a6d6e --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_replace_thumbnail_to_additional_a.csv @@ -0,0 +1,2 @@ +sku,base_image,small_image,thumbnail_image,swatch_image +simple_new,magento_image.jpg,magento_small_image.jpg,repro_replace_additional_a.jpg,magento_image.jpg diff --git a/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_replace_union_rows.csv b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_replace_union_rows.csv new file mode 100644 index 0000000000000..1f987e4831c03 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_replace_union_rows.csv @@ -0,0 +1,3 @@ +sku,store_view_code,additional_images,base_image,small_image,thumbnail_image,swatch_image +simple_new,,repro_replace_additional_a.jpg,magento_image.jpg,magento_small_image.jpg,magento_thumbnail.jpg,magento_image.jpg +simple_new,,repro_replace_additional_c.jpg,magento_image.jpg,magento_small_image.jpg,magento_thumbnail.jpg,magento_image.jpg diff --git a/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_replace_with_missing_image.csv b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_replace_with_missing_image.csv new file mode 100644 index 0000000000000..7ae9334448479 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_replace_with_missing_image.csv @@ -0,0 +1,2 @@ +sku,additional_images,base_image,small_image,thumbnail_image,swatch_image +simple_new,"repro_replace_additional_a.jpg,does_not_exist_replace_fail.jpg",magento_image.jpg,magento_small_image.jpg,magento_thumbnail.jpg,magento_image.jpg diff --git a/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/repro_replace_additional_a.jpg b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/repro_replace_additional_a.jpg new file mode 100644 index 0000000000000..e54d3f62fc98e Binary files /dev/null and b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/repro_replace_additional_a.jpg differ diff --git a/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/repro_replace_additional_b.jpg b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/repro_replace_additional_b.jpg new file mode 100644 index 0000000000000..fc907b0987898 Binary files /dev/null and b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/repro_replace_additional_b.jpg differ diff --git a/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/repro_replace_additional_c.jpg b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/repro_replace_additional_c.jpg new file mode 100644 index 0000000000000..d27bc5f65b87f Binary files /dev/null and b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/repro_replace_additional_c.jpg differ diff --git a/dev/tests/js/jasmine/tests/app/code/Magento/ImportExport/adminhtml/js/form/product-image-import-mode.test.js b/dev/tests/js/jasmine/tests/app/code/Magento/ImportExport/adminhtml/js/form/product-image-import-mode.test.js new file mode 100644 index 0000000000000..6aad0c7929a50 --- /dev/null +++ b/dev/tests/js/jasmine/tests/app/code/Magento/ImportExport/adminhtml/js/form/product-image-import-mode.test.js @@ -0,0 +1,228 @@ +/** + * Copyright 2026 Adobe + * All Rights Reserved. + */ + +define([ + 'jquery', + 'Magento_ImportExport/js/form/product-image-import-mode' +], function ($, productImageImportMode) { + 'use strict'; + + describe('Magento_ImportExport/js/form/product-image-import-mode', function () { + var productEntityCode = 'catalog_product', + behaviorAppend = 'append', + defaultMode = 'add'; + + describe('shouldShowProductImageImportMode', function () { + it('returns true for catalog_product + append', function () { + expect(productImageImportMode.shouldShowProductImageImportMode( + productEntityCode, + behaviorAppend, + productEntityCode, + behaviorAppend + )).toBe(true); + }); + + it('returns false for catalog_product + replace', function () { + expect(productImageImportMode.shouldShowProductImageImportMode( + productEntityCode, + 'replace', + productEntityCode, + behaviorAppend + )).toBe(false); + }); + + it('returns false for catalog_product + delete', function () { + expect(productImageImportMode.shouldShowProductImageImportMode( + productEntityCode, + 'delete', + productEntityCode, + behaviorAppend + )).toBe(false); + }); + + it('returns false for non-product entity with append', function () { + expect(productImageImportMode.shouldShowProductImageImportMode( + 'customer', + behaviorAppend, + productEntityCode, + behaviorAppend + )).toBe(false); + }); + + it('returns false when entity or behavior is empty', function () { + expect(productImageImportMode.shouldShowProductImageImportMode( + '', + behaviorAppend, + productEntityCode, + behaviorAppend + )).toBe(false); + expect(productImageImportMode.shouldShowProductImageImportMode( + productEntityCode, + null, + productEntityCode, + behaviorAppend + )).toBe(false); + expect(productImageImportMode.shouldShowProductImageImportMode( + null, + null, + productEntityCode, + behaviorAppend + )).toBe(false); + }); + }); + + describe('applyProductImageImportModeVisibility', function () { + var $field, $fieldRow; + + beforeEach(function () { + $field = $('<select id="product_image_import_mode">' + + '<option value="add">Add</option>' + + '<option value="replace">Replace</option>' + + '</select>'); + $fieldRow = $('<div class="field-product_image_import_mode no-display"></div>'); + $field.val('replace'); + $field.prop('disabled', true); + $fieldRow.hide(); + $('body').append($fieldRow.append($field)); + }); + + afterEach(function () { + $fieldRow.remove(); + $field = null; + $fieldRow = null; + }); + + it('enables and shows the field when visible', function () { + productImageImportMode.applyProductImageImportModeVisibility( + $field, + $fieldRow, + true, + defaultMode + ); + + expect($field.prop('disabled')).toBe(false); + expect($fieldRow.hasClass('no-display')).toBe(false); + expect($fieldRow.is(':visible')).toBe(true); + expect($field.val()).toBe('replace'); + }); + + it('disables, hides, and resets value when not visible', function () { + $field.prop('disabled', false); + $fieldRow.removeClass('no-display').show(); + $field.val('replace'); + + productImageImportMode.applyProductImageImportModeVisibility( + $field, + $fieldRow, + false, + defaultMode + ); + + expect($field.prop('disabled')).toBe(true); + expect($field.val()).toBe(defaultMode); + expect($fieldRow.hasClass('no-display')).toBe(true); + expect($fieldRow.is(':visible')).toBe(false); + }); + + it('no-ops when field is missing', function () { + expect(function () { + productImageImportMode.applyProductImageImportModeVisibility( + $(), + $fieldRow, + true, + defaultMode + ); + }).not.toThrow(); + }); + }); + + describe('shouldShowProductImageDeleteUnused', function () { + var replaceMode = 'replace'; + + it('returns true only for catalog_product + append + replace image mode', function () { + expect(productImageImportMode.shouldShowProductImageDeleteUnused( + productEntityCode, + behaviorAppend, + replaceMode, + productEntityCode, + behaviorAppend, + replaceMode + )).toBe(true); + }); + + it('returns false when image mode is add', function () { + expect(productImageImportMode.shouldShowProductImageDeleteUnused( + productEntityCode, + behaviorAppend, + defaultMode, + productEntityCode, + behaviorAppend, + replaceMode + )).toBe(false); + }); + + it('returns false when import behavior is not append', function () { + expect(productImageImportMode.shouldShowProductImageDeleteUnused( + productEntityCode, + 'replace', + replaceMode, + productEntityCode, + behaviorAppend, + replaceMode + )).toBe(false); + }); + + it('returns false for non-product entity', function () { + expect(productImageImportMode.shouldShowProductImageDeleteUnused( + 'customer', + behaviorAppend, + replaceMode, + productEntityCode, + behaviorAppend, + replaceMode + )).toBe(false); + }); + }); + + describe('applyProductImageDeleteUnusedVisibility', function () { + var $field, $fieldRow; + + beforeEach(function () { + $field = $('<input type="checkbox" id="product_image_delete_unused" value="1"/>'); + $fieldRow = $('<div class="field-product_image_delete_unused no-display"></div>'); + $field.prop('checked', true); + $field.prop('disabled', true); + $fieldRow.hide(); + $('body').append($fieldRow.append($field)); + }); + + afterEach(function () { + $fieldRow.remove(); + $field = null; + $fieldRow = null; + }); + + it('enables and shows the checkbox when visible', function () { + productImageImportMode.applyProductImageDeleteUnusedVisibility($field, $fieldRow, true); + + expect($field.prop('disabled')).toBe(false); + expect($fieldRow.hasClass('no-display')).toBe(false); + expect($fieldRow.is(':visible')).toBe(true); + }); + + it('disables, hides, and unchecks when not visible', function () { + $field.prop('disabled', false).prop('checked', true); + $fieldRow.removeClass('no-display').show(); + + productImageImportMode.applyProductImageDeleteUnusedVisibility($field, $fieldRow, false); + + expect($field.prop('disabled')).toBe(true); + expect($field.prop('checked')).toBe(false); + expect($fieldRow.hasClass('no-display')).toBe(true); + expect($fieldRow.is(':visible')).toBe(false); + }); + }); + }); +});