diff --git a/Block/Adminhtml/Queue/Status.php b/Block/Adminhtml/Queue/Status.php
index 10bd9c400..9c877ff13 100644
--- a/Block/Adminhtml/Queue/Status.php
+++ b/Block/Adminhtml/Queue/Status.php
@@ -158,7 +158,7 @@ private function isQueueFast()
{
$averageProcessingTime = $this->queue->getAverageProcessingTime();
- return !is_null($averageProcessingTime) && $averageProcessingTime < self::QUEUE_FAST_LIMIT;
+ return $averageProcessingTime !== null && $averageProcessingTime < self::QUEUE_FAST_LIMIT;
}
/** @return int */
diff --git a/Block/Configuration.php b/Block/Configuration.php
index f788e5473..8d8be8b1d 100755
--- a/Block/Configuration.php
+++ b/Block/Configuration.php
@@ -27,7 +27,7 @@ public function isSearchPage(): bool
if ($this->getConfigHelper()->replaceCategories() && $request->getControllerName() === 'category') {
$category = $this->getCurrentCategory();
- if ($category && $category->getDisplayMode() !== 'PAGE') {
+ if ($category->getId() && $category->getDisplayMode() !== 'PAGE') {
return true;
}
}
@@ -125,7 +125,7 @@ public function getConfiguration()
&& $request->getControllerName() === 'category') {
$category = $this->getCurrentCategory();
- if ($category && $category->getDisplayMode() !== 'PAGE') {
+ if ($category->getId() && $category->getDisplayMode() !== 'PAGE') {
$category->getUrlInstance()->setStore($this->getStoreId());
if (self::IS_CATEGORY_NAVIGATION_ENABLED) {
$childCategories = $this->getChildCategoryUrls($category);
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 55aacba88..d6e565f61 100755
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,13 +1,39 @@
# CHANGE LOG
+## 3.17.0
+
+### Features
+- Added an Algolia indexing cache for storing metadata to prevent extra queries. Large collections that run periodic full indexes can benefit from this cache.
+- Price indexing is now optional. If the price attribute is not present in the product attributes, sorts, facets or custom ranking then that information will not be indexed in the product record.
+- Creating a temporary index while performing a full index is now optional and can be enabled or disabled in the Magento admin.
+- Added Batching optimizer feature which performs a catalog analysis and provides recommendation regarding optimal batching size for indexing.
+- Added Clear queue CLI command which handles indexing queue clearing from the console.
+
+### Updates
+- Fixed Indexing Queue merging mechanism, it should now have way better performances with delta indexing (updates) jobs.
+- Updated CLI command organization, queue related commands now have a different prefix compared to the indexing ones.
+- Updated default "Maximum number of records sent per indexing request" to 1000 (previously 300).
+- Updated `ConfigHelper` class, it now has more methods deprecated and ported to separate helper classes.
+- Updated Unit and Integration tests.
+- Removed all `is_null` occurrences
+
+### Bug fixes
+- Fixed indexing queue templates escaping.
+- Fixed 3.17 setup:upgrade on PHP 8.4
+- Performed code sanitization for Codacy compliance
+- Fixed issue where missing pricing keys were not handled gracefully in the Autocomplete product template
+- Fixed issue where category was not properly checked in the configuration block - thank you @benjamin-volle
+- Fixed issue on duplicated product save
+
## 3.16.1
### Updates
- Add checks on configuration migration processed on data patch
- `EventProcessor` now calculates decimal precision on currency based on `Magento\Framework\Locale\FormatInterface`
+- Local InstantSearch widgets can now override facet sorting behavior in merchandising rules
- Updated various unit/integration tests
-### Bug fixes
+### Bug fixes
- Fixed Indexing Queue display in backend templates.
- Fixed Indexing Queue merging mechanism, it should now have way better performances with delta indexing (updates) jobs.
- Fixed implicit nullable types for PHP 8.4
@@ -15,6 +41,7 @@
- Apply rounding to insight events revenue values to avoid floating point precision errors - thank you @PromInc
- Fix issue where double conversion occurred during price indexing in case of multi currency stores - thank you @natedawg92
- Fix issue where non-clickable links where rendered on InstantSearch - thank you @PromInc
+- Fixed issue where replica setting forwarding can abort if async operation fails to complete in time
- Fixed some Codacy issues
## 3.16.0
diff --git a/Console/Command/AbstractStoreCommand.php b/Console/Command/AbstractStoreCommand.php
index f6481f9c8..92b94c194 100644
--- a/Console/Command/AbstractStoreCommand.php
+++ b/Console/Command/AbstractStoreCommand.php
@@ -127,10 +127,10 @@ protected function decorateOperationAnnouncementMessage(string $msg, array $stor
: "
- Depending on your configuration set on "Advanced > Maximum number of records processed per indexing job" and if the jobs can be merged into batches, you can expect higher performances.
+ Depending on your configuration set on "Advanced > Maximum number of records sent per indexing request" and if the jobs can be merged into batches, you can expect higher performances.
Find out more about Indexing Queue in documentation.';
}
@@ -191,8 +192,8 @@ protected function getClickAnalyticsNotice()
];
}
- protected function getCookieConfigurationNotice()
- {
+ protected function getCookieConfigurationNotice()
+ {
$noticeContent = '';
$selector = '';
$method = 'after';
@@ -365,4 +366,33 @@ protected function getMultiApplicationIDsNotice(): void
'message' => $this->formatNotice($noticeTitle, $noticeContent),
];
}
+
+
+ /**
+ * This notice serves as a warning when user removes the price attribute from the attributes list but it's still present either in the sortings or in the facets
+ * @return void
+ */
+ protected function getPriceIndexingNotice(): void
+ {
+ $attributesToIndex = $this->configHelper->getProductAdditionalAttributes();
+ $attributesList = $this->configHelper->getProductAttributesList();
+
+ // we want to display the warning only if price is not present in the attribute list but is present somewhere else
+ if (!($this->configHelper->isAttributeInList($attributesToIndex, 'price')
+ && !$this->configHelper->isAttributeInList($attributesList, 'price'))
+ ) {
+ return;
+ }
+
+ $noticeTitle = 'Price attribute indexing';
+ $noticeContent = '
Price attribute has been removed from the product attributes list but is still present in the facets, sortings or custom rankings lists.
+If you want to remove prices from the product records, you need to remove them from those lists as well.
+If you want the prices to be included in the product records, you need to add the price attribute in the product attributes list in the "Products" section of the configuration.
'; + + $this->notices[] = [ + 'selector' => '.entry-edit', + 'method' => 'before', + 'message' => $this->formatNotice($noticeTitle, $noticeContent), + ]; + } } diff --git a/Helper/Configuration/QueueHelper.php b/Helper/Configuration/QueueHelper.php new file mode 100644 index 000000000..a7b09b3fa --- /dev/null +++ b/Helper/Configuration/QueueHelper.php @@ -0,0 +1,67 @@ +configInterface->isSetFlag(self::IS_ACTIVE, ScopeInterface::SCOPE_STORE, $storeId); + } + + /** + * @param $storeId + * @return bool + */ + public function useBuiltInCron($storeId = null) + { + return $this->configInterface->isSetFlag(self::USE_BUILT_IN_CRON, ScopeInterface::SCOPE_STORE, $storeId); + } + + /** + * @param $storeId + * @return mixed + */ + public function getNumberOfJobToRun($storeId = null) + { + $nbJobs = (int)$this->configInterface->getValue(self::NUMBER_OF_JOB_TO_RUN, ScopeInterface::SCOPE_STORE, $storeId); + + return max($nbJobs, 1); + } + + /** + * @param $storeId + * @return int + */ + public function getRetryLimit($storeId = null) + { + return (int)$this->configInterface->getValue(self::RETRY_LIMIT, ScopeInterface::SCOPE_STORE, $storeId); + } + + /** + * @param $storeId + * @return bool + */ + public function useTmpIndex($storeId = null) + { + return $this->isQueueActive($storeId) && + $this->configInterface->isSetFlag(self::USE_TMP_INDEX, ScopeInterface::SCOPE_STORE, $storeId); + } +} diff --git a/Helper/Data.php b/Helper/Data.php index bf1d9fa16..4193b16aa 100755 --- a/Helper/Data.php +++ b/Helper/Data.php @@ -239,9 +239,9 @@ public function getIndexDataByStoreIds(): array */ protected function buildIndexData(?StoreInterface $store = null): array { - $storeId = !is_null($store) ? $store->getStoreId() : null; - $currencyCode = !is_null($store) ? - $store->getCurrentCurrencyCode($storeId) : + $storeId = $store?->getStoreId(); + $currencyCode = + $store?->getCurrentCurrencyCode($storeId) ?? $this->configHelper->getCurrencyCode(); return [ @@ -250,7 +250,7 @@ protected function buildIndexData(?StoreInterface $store = null): array 'indexName' => $this->getBaseIndexName($storeId), 'priceKey' => '.' . $currencyCode . '.default', 'facets' => $this->configHelper->getFacets($storeId), - 'currencyCode' => $this->configHelper->getCurrencyCode($storeId), + 'currencyCode' => $currencyCode, 'maxValuesPerFacet' => (int) $this->configHelper->getMaxValuesPerFacet($storeId), 'categorySeparator' => $this->configHelper->getCategorySeparator($storeId), ]; diff --git a/Helper/Entity/Product/CacheHelper.php b/Helper/Entity/Product/CacheHelper.php new file mode 100644 index 000000000..1e30a4897 --- /dev/null +++ b/Helper/Entity/Product/CacheHelper.php @@ -0,0 +1,25 @@ +logger->info(sprintf("Clearing product index collection cache on store ID %d for attributes: %s", $storeId, join(',', array_keys($attributes)))); + $this->cache->clear($storeId ?: null); + } + } +} diff --git a/Helper/Entity/Product/PriceManager/ProductWithChildren.php b/Helper/Entity/Product/PriceManager/ProductWithChildren.php index b929edd97..300d908de 100755 --- a/Helper/Entity/Product/PriceManager/ProductWithChildren.php +++ b/Helper/Entity/Product/PriceManager/ProductWithChildren.php @@ -69,6 +69,11 @@ protected function getMinMaxPrices(Product $product, $withTax, $subProducts, $cu $price = $minPrice ?? $this->getTaxPrice($product, $finalPrice, $withTax); $basePrice = $this->getTaxPrice($product, $basePrice, $withTax); + + if ($this->configHelper->isFptEnabled($subProduct->getStoreId())) { + $basePrice += $this->weeeTax->getWeeeAmount($subProduct); + } + $min = min($min, $price); $original = min($original, $basePrice); $max = max($max, $price); diff --git a/Helper/Entity/Product/PriceManager/ProductWithoutChildren.php b/Helper/Entity/Product/PriceManager/ProductWithoutChildren.php index 4c5883b1c..3432a57ad 100755 --- a/Helper/Entity/Product/PriceManager/ProductWithoutChildren.php +++ b/Helper/Entity/Product/PriceManager/ProductWithoutChildren.php @@ -16,6 +16,7 @@ use Magento\Customer\Model\ResourceModel\Group\CollectionFactory; use Magento\Framework\Pricing\PriceCurrencyInterface; use Magento\Tax\Helper\Data as TaxHelper; +use Magento\Weee\Model\Tax as WeeeTax; use Magento\Tax\Model\Config as TaxConfig; abstract class ProductWithoutChildren @@ -40,6 +41,10 @@ abstract class ProductWithoutChildren * @var TaxHelper */ protected $taxHelper; + /** + * @var WeeeTax + */ + protected $weeeTax; /** * @var Rule */ @@ -77,6 +82,7 @@ abstract class ProductWithoutChildren * @param PriceCurrencyInterface $priceCurrency * @param CatalogHelper $catalogHelper * @param TaxHelper $taxHelper + * @param WeeeTax $weeeTax * @param Rule $rule * @param ProductFactory $productloader * @param ScopedProductTierPriceManagementInterface $productTierPrice @@ -89,6 +95,7 @@ public function __construct( PriceCurrencyInterface $priceCurrency, CatalogHelper $catalogHelper, TaxHelper $taxHelper, + WeeeTax $weeeTax, Rule $rule, ProductFactory $productloader, ScopedProductTierPriceManagementInterface $productTierPrice, @@ -100,6 +107,7 @@ public function __construct( $this->priceCurrency = $priceCurrency; $this->catalogHelper = $catalogHelper; $this->taxHelper = $taxHelper; + $this->weeeTax = $weeeTax; $this->rule = $rule; $this->productloader = $productloader; $this->productTierPrice = $productTierPrice; @@ -146,6 +154,9 @@ public function addPriceData($customData, Product $product, $subProducts): array foreach ($currencies as $currencyCode) { $this->customData[$field][$currencyCode] = []; $price = $product->getPrice(); + if ($this->configHelper->isFptEnabled($product->getStoreId())) { + $price += $this->weeeTax->getWeeeAmount($product); + } if ($currencyCode !== $this->baseCurrencyCode) { $price = $this->convertPrice($price, $currencyCode); } @@ -257,7 +268,11 @@ protected function getSpecialPrice(Product $product, $currencyCode, $withTax, $s $specialPrices[$groupId] = []; $specialPrices[$groupId][] = $this->getRulePrice($groupId, $product, $subProducts); // The price with applied catalog rules - $specialPrices[$groupId][] = $product->getFinalPrice(); // The product's special price + $finalPrice = $product->getFinalPrice(); // The product's special price + if ($this->configHelper->isFptEnabled($product->getStoreId())) { + $finalPrice += $this->weeeTax->getWeeeAmount($product); + } + $specialPrices[$groupId][] = $finalPrice; $specialPrices[$groupId] = array_filter($specialPrices[$groupId], fn($price) => $price > 0); $specialPrice[$groupId] = false; if ($specialPrices[$groupId] && $specialPrices[$groupId] !== []) { diff --git a/Helper/MathHelper.php b/Helper/MathHelper.php new file mode 100644 index 000000000..a38f3faca --- /dev/null +++ b/Helper/MathHelper.php @@ -0,0 +1,39 @@ +isCacheAvailable()) { + return self::NOT_FOUND; + } + + /** @var string|false $data */ + $data = $this->cache->load($this->getCacheKey($storeId)); + if ($data === false) { + return self::NOT_FOUND; + } + + return (int) $data; + } + + public function set(int $storeId, int $value, ?int $ttl = null): void + { + if ($this->isCacheAvailable()) { + $this->cache->save($value, $this->getCacheKey($storeId), [Indexer::CACHE_TAG], $ttl); + } + } + + protected function remove(int $storeId): void + { + $this->cache->remove($this->getCacheKey($storeId)); + } + + public function isCacheAvailable(): bool + { + return $this->state->isEnabled(Indexer::TYPE_IDENTIFIER) + && !array_key_exists(Indexer::TYPE_IDENTIFIER, $this->typeList->getInvalidated()); + } + + protected function getCacheKey(int $storeId): string + { + return sprintf('%s_%s_%d', Indexer::TYPE_IDENTIFIER, 'product', $storeId); + } + + public function clear(?int $storeId = null): void + { + if ($storeId === null) { + $this->typeList->invalidate(Indexer::TYPE_IDENTIFIER); + $this->typeList->cleanType(Indexer::TYPE_IDENTIFIER); + } + else { + $this->remove($storeId); + } + } +} diff --git a/Model/Cache/Type/Indexer.php b/Model/Cache/Type/Indexer.php new file mode 100644 index 000000000..dc434a66a --- /dev/null +++ b/Model/Cache/Type/Indexer.php @@ -0,0 +1,19 @@ +get(self::TYPE_IDENTIFIER), + self::CACHE_TAG + ); + } +} diff --git a/Model/Indexer/Product.php b/Model/Indexer/Product.php index 860c8947e..fb3155fb8 100755 --- a/Model/Indexer/Product.php +++ b/Model/Indexer/Product.php @@ -2,9 +2,8 @@ namespace Algolia\AlgoliaSearch\Model\Indexer; +use Algolia\AlgoliaSearch\Api\Processor\BatchQueueProcessorInterface; use Algolia\AlgoliaSearch\Helper\ConfigHelper; -use Algolia\AlgoliaSearch\Service\Product\BatchQueueProcessor as ProductBatchQueueProcessor; -use Magento\Framework\Exception\NoSuchEntityException; use Magento\Store\Model\StoreManagerInterface; /** @@ -16,34 +15,43 @@ class Product implements \Magento\Framework\Indexer\ActionInterface, \Magento\Fr public function __construct( protected StoreManagerInterface $storeManager, protected ConfigHelper $configHelper, - protected ProductBatchQueueProcessor $productBatchQueueProcessor + protected BatchQueueProcessorInterface $productBatchQueueProcessor ) {} /** - * @throws NoSuchEntityException + * {@inheritdoc} */ - public function execute($productIds) + public function execute($ids): void { foreach (array_keys($this->storeManager->getStores()) as $storeId) { - $this->productBatchQueueProcessor->processBatch($storeId, $productIds); + $this->productBatchQueueProcessor->processBatch($storeId, $ids); } } - public function executeFull() + /** + * {@inheritdoc} + */ + public function executeFull(): void { if (!$this->configHelper->isProductsIndexerEnabled()) { return; } - $this->execute(null); + $this->execute([]); } - public function executeList(array $ids) + /** + * {@inheritdoc} + */ + public function executeList(array $ids): void { $this->execute($ids); } - public function executeRow($id) + /** + * {@inheritdoc} + */ + public function executeRow($id): void { $this->execute([$id]); } diff --git a/Model/Job.php b/Model/Job.php index 8661dc56f..69205ab38 100644 --- a/Model/Job.php +++ b/Model/Job.php @@ -183,7 +183,7 @@ public function getStatus(): string { $status = JobInterface::STATUS_PROCESSING; - if (is_null($this->getPid())) { + if ($this->getPid() === null) { $status = JobInterface::STATUS_NEW; } diff --git a/Model/Observer/CatalogPermissions/CategoryPermissions.php b/Model/Observer/CatalogPermissions/CategoryPermissions.php index 01f1b3c82..78629b579 100644 --- a/Model/Observer/CatalogPermissions/CategoryPermissions.php +++ b/Model/Observer/CatalogPermissions/CategoryPermissions.php @@ -39,9 +39,9 @@ public function execute(Observer $observer) $customerGroupId = $customerGroup->getCustomerGroupId(); $isVisible = (int) $this->permissionsFactory->getCatalogPermissionsHelper()->isAllowedCategoryView($storeId, $customerGroupId); - if (!is_null($category->getData('shared_catalog_permission_' . $customerGroupId))) { + if ($category->getData('shared_catalog_permission_' . $customerGroupId) !== null) { $isVisible = (int) $category->getData('shared_catalog_permission_' . $customerGroupId); - } elseif (!is_null($category->getData('customer_group_permission_' . $customerGroupId))) { + } elseif ($category->getData('customer_group_permission_' . $customerGroupId) !== null) { $isVisible = (int) $category->getData('customer_group_permission_' . $customerGroupId); } diff --git a/Model/Observer/CatalogPermissions/ProductPermissions.php b/Model/Observer/CatalogPermissions/ProductPermissions.php index 799218ff6..40f27f56f 100644 --- a/Model/Observer/CatalogPermissions/ProductPermissions.php +++ b/Model/Observer/CatalogPermissions/ProductPermissions.php @@ -39,9 +39,9 @@ public function execute(Observer $observer) $customerGroupId = $customerGroup->getCustomerGroupId(); $isVisible = (int) $this->permissionsFactory->getCatalogPermissionsHelper()->isAllowedCategoryView($storeId, $customerGroupId); - if (!is_null($product->getData('shared_catalog_permission_' . $customerGroupId))) { + if ($product->getData('shared_catalog_permission_' . $customerGroupId) !== null) { $isVisible = (int) $product->getData('shared_catalog_permission_' . $customerGroupId); - } elseif (!is_null($product->getData('customer_group_permission_' . $customerGroupId))) { + } elseif ($product->getData('customer_group_permission_' . $customerGroupId) !== null) { $isVisible = (int) $product->getData('customer_group_permission_' . $customerGroupId); } diff --git a/Model/ResourceModel/LandingPage.php b/Model/ResourceModel/LandingPage.php index aba5e357c..898557bcb 100644 --- a/Model/ResourceModel/LandingPage.php +++ b/Model/ResourceModel/LandingPage.php @@ -136,12 +136,12 @@ public function checkUrlRewriteTable($urlKey, $storeId = null, $landingPageId = ->where('request_path = ?', $urlKey); // Only check a particular store if specified - if (!is_null($storeId) && $storeId != 0) { + if ($storeId) { $select->where('store_id = ?', $storeId); } // Handle the already existing url rewrite for the landing page - if (!is_null($landingPageId) && $landingPageId != 0) { + if ($landingPageId) { $select->where('!(entity_type = "landing-page" AND entity_id = ?)', $landingPageId); } diff --git a/Model/ResourceModel/Query.php b/Model/ResourceModel/Query.php index dee152967..b63a3f6e9 100644 --- a/Model/ResourceModel/Query.php +++ b/Model/ResourceModel/Query.php @@ -29,12 +29,12 @@ public function checkQueryUnicity($queryText, $storeId = null, $queryId = null) ->where('query_text = ?', $queryText); // Only check a particular store if specified - if (!is_null($storeId) && $storeId != 0) { + if ($storeId !== null && $storeId != 0) { $select->where('store_id = ?', $storeId); } // Handle the already existing query text for the query - if (!is_null($queryId) && $queryId != 0) { + if ($queryId !== null && $queryId != 0) { $select->where('query_id != ?', $queryId); } diff --git a/Plugin/Cache/CacheCleanBulkAttributePlugin.php b/Plugin/Cache/CacheCleanBulkAttributePlugin.php new file mode 100644 index 000000000..322f67b78 --- /dev/null +++ b/Plugin/Cache/CacheCleanBulkAttributePlugin.php @@ -0,0 +1,33 @@ +cacheHelper->handleBulkAttributeChange( + $this->attributeHelper->getProductIds(), + $subject->getRequest()->getParam('attributes', []), + $this->attributeHelper->getSelectedStoreId() + ); + + return $result; + } +} diff --git a/Plugin/Cache/CacheCleanProductPlugin.php b/Plugin/Cache/CacheCleanProductPlugin.php new file mode 100644 index 000000000..c57a9a8b3 --- /dev/null +++ b/Plugin/Cache/CacheCleanProductPlugin.php @@ -0,0 +1,138 @@ +originalData[$product->getSku()] = $product->getOrigData(); + } + + public function afterSave(ProductResource $subject, ProductResource $result, Product $product): ProductResource + { + $original = $this->originalData[$product->getSku()] ?? []; + + // In case of a product duplication + if (empty($original)) { + return $result; + } + + $storeId = $product->getStoreId(); + + $shouldClearCache = + $this->isEligibleNewProduct($product) + || $this->hasEnablementChanged($original, $product->getData()) + || $this->hasVisibilityChanged($original, $product->getData(), $storeId) + || $this->hasStockChanged($original, $product->getData(), $storeId); + + if ($shouldClearCache) { + $this->cache->clear($storeId ?: null); + } + + return $result; + } + + public function afterDelete(ProductResource $subject, ProductResource $result): ProductResource + { + $this->cache->clear(); + return $result; + } + + /** + * Called on mass action "Change Status" + * Called on "Update attributes" if `product_action_attribute.update` consumer is running + */ + public function afterUpdateAttributes(Action $subject, Action $result, array $productIds, array $attributes, int $storeId): Action + { + $this->cacheHelper->handleBulkAttributeChange($productIds, $attributes, $storeId); + return $result; + } + + protected function isEligibleNewProduct(Product $product): bool + { + $storeId = $product->getStoreId(); + return $product->isObjectNew() + && $product->getStatus() === Status::STATUS_ENABLED + && $this->configHelper->includeNonVisibleProductsInIndex($storeId) + || $product->isVisibleInSiteVisibility() + && $this->configHelper->getShowOutOfStock($storeId) + || $product->isInStock(); + } + + protected function hasEnablementChanged(array $orig, array $new): bool + { + $key = 'status'; + return $orig[$key] !== $new[$key]; + } + + protected function hasVisibilityChanged(array $orig, array $new, ?int $storeId = null): bool + { + if ($this->configHelper->includeNonVisibleProductsInIndex($storeId)) { + return false; + } + + $key = 'visibility'; + return $this->isVisible($orig[$key]) !== $this->isVisible($new[$key]); + } + + /** + * Do not rely on this data point only + * TODO revaluate with MSI support + */ + protected function hasStockChanged(array $orig, array $new, int $storeId): bool + { + if ($this->configHelper->getShowOutOfStock($storeId)) { + return false; + } + + $key = 'quantity_and_stock_status'; + $oldStock = $orig[$key]; + $newStock = $new[$key]; + + // In case of a product duplication on second save (for some reason, Magento returns a different data structure in that case). + if (!is_array($newStock)) { + $newStock = ['is_in_stock' => $newStock]; + } + + return $this->canCompareValues($oldStock, $newStock, 'is_in_stock') + && (bool) $oldStock['is_in_stock'] !== (bool) $newStock['is_in_stock'] + || $this->canCompareValues($oldStock, $newStock, 'qty') + && $this->hasStock($oldStock['qty']) !== $this->hasStock($newStock['qty']); + } + + protected function canCompareValues(array $orig, array $new, string $key): bool + { + return array_key_exists($key, $orig) && array_key_exists($key, $new); + } + + protected function isVisible(int $visibility): bool + { + return $visibility !== Visibility::VISIBILITY_NOT_VISIBLE; + } + + /* + * Reduce numeric to comparable boolean + */ + protected function hasStock(int $qty): bool + { + return $qty > 0; + } +} diff --git a/README.md b/README.md index c5bf56c57..eaead7ab1 100755 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ Algolia Search & Discovery extension for Magento 2 ================================================== - +   diff --git a/Service/AlgoliaConnector.php b/Service/AlgoliaConnector.php index cce5c48cc..5b5e46c3c 100755 --- a/Service/AlgoliaConnector.php +++ b/Service/AlgoliaConnector.php @@ -135,7 +135,7 @@ protected function addAlgoliaUserAgent(int $storeId = self::ALGOLIA_DEFAULT_SCOP */ public function getClient(?int $storeId = self::ALGOLIA_DEFAULT_SCOPE): SearchClient { - if (is_null($storeId)) { + if ($storeId === null) { $storeId = self::ALGOLIA_DEFAULT_SCOPE; } @@ -477,7 +477,7 @@ protected function setLastOperationInfo(IndexOptionsInterface $indexOptions, arr $this->lastUsedIndexName = $indexName; $this->lastTaskId = $response[self::ALGOLIA_API_TASK_ID] ?? null; - if (!is_null($storeId)) { + if ($storeId !== null) { $this->lastTaskInfoByStore[$storeId] = [ 'indexName' => $indexName, 'taskId' => $response[self::ALGOLIA_API_TASK_ID] ?? null @@ -642,16 +642,16 @@ public function clearIndex(IndexOptionsInterface $indexOptions): void */ public function waitLastTask(?int $storeId = null, ?string $lastUsedIndexName = null, ?int $lastTaskId = null): void { - if (is_null($lastUsedIndexName)) { - if (!is_null($storeId) && isset($this->lastTaskInfoByStore[$storeId])) { + if ($lastUsedIndexName === null) { + if ($storeId !== null && isset($this->lastTaskInfoByStore[$storeId])) { $lastUsedIndexName = $this->lastTaskInfoByStore[$storeId]['indexName']; } elseif (isset($this->lastUsedIndexName)){ $lastUsedIndexName = $this->lastUsedIndexName; } } - if (is_null($lastTaskId)) { - if (!is_null($storeId) && isset($this->lastTaskInfoByStore[$storeId])) { + if ($lastTaskId === null) { + if ($storeId !== null && isset($this->lastTaskInfoByStore[$storeId])) { $lastTaskId = $this->lastTaskInfoByStore[$storeId]['taskId']; } elseif (isset($this->lastTaskId)){ $lastTaskId = $this->lastTaskId; @@ -662,7 +662,7 @@ public function waitLastTask(?int $storeId = null, ?string $lastUsedIndexName = return; } - if (is_null($storeId)) { + if ($storeId === null) { $storeId = self::ALGOLIA_DEFAULT_SCOPE; } @@ -894,7 +894,7 @@ public function getLastTaskId(?int $storeId = null): int|null { $lastTaskId = null; - if (!is_null($storeId) && isset($this->lastTaskInfoByStore[$storeId])) { + if ($storeId !== null && isset($this->lastTaskInfoByStore[$storeId])) { $lastTaskId = $this->lastTaskInfoByStore[$storeId]['taskId']; } elseif (isset($this->lastTaskId)){ $lastTaskId = $this->lastTaskId; diff --git a/Service/Category/IndexBuilder.php b/Service/Category/IndexBuilder.php index 66c8a5bd3..444342379 100644 --- a/Service/Category/IndexBuilder.php +++ b/Service/Category/IndexBuilder.php @@ -81,7 +81,7 @@ public function buildIndex(int $storeId, ?array $entityIds, ?array $options): vo return; } - if (!is_null($entityIds)) { + if ($entityIds !== null) { $this->rebuildEntityIds($storeId, $entityIds); return; } diff --git a/Service/IndexOptionsBuilder.php b/Service/IndexOptionsBuilder.php index 93c145807..06ccbd784 100644 --- a/Service/IndexOptionsBuilder.php +++ b/Service/IndexOptionsBuilder.php @@ -82,11 +82,11 @@ protected function build(IndexOptionsInterface $indexOptions): IndexOptionsInter */ protected function computeIndexName(IndexOptionsInterface $indexOptions): ?string { - if (!is_null($indexOptions->getIndexName())) { + if ($indexOptions->getIndexName() !== null) { return $indexOptions->getIndexName(); } - if (is_null($indexOptions->getIndexSuffix())) { + if ($indexOptions->getIndexSuffix() === null) { throw new AlgoliaException('Index suffix is mandatory in case no enforced index name is specified.'); } diff --git a/Service/Product/BatchQueueProcessor.php b/Service/Product/BatchQueueProcessor.php index 8092608f2..c26ebeb8a 100644 --- a/Service/Product/BatchQueueProcessor.php +++ b/Service/Product/BatchQueueProcessor.php @@ -6,28 +6,31 @@ use Algolia\AlgoliaSearch\Exception\DiagnosticsException; use Algolia\AlgoliaSearch\Exceptions\AlgoliaException; use Algolia\AlgoliaSearch\Helper\ConfigHelper; +use Algolia\AlgoliaSearch\Helper\Configuration\QueueHelper; use Algolia\AlgoliaSearch\Helper\Data; use Algolia\AlgoliaSearch\Helper\Entity\ProductHelper; use Algolia\AlgoliaSearch\Logger\DiagnosticsLogger; +use Algolia\AlgoliaSearch\Model\Cache\Product\IndexCollectionSize; use Algolia\AlgoliaSearch\Model\IndexMover; use Algolia\AlgoliaSearch\Model\IndicesConfigurator; use Algolia\AlgoliaSearch\Model\Queue; use Algolia\AlgoliaSearch\Service\AlgoliaCredentialsManager; use Algolia\AlgoliaSearch\Service\Product\IndexBuilder as ProductIndexBuilder; +use Magento\Catalog\Model\ResourceModel\Product\Collection; use Magento\Framework\Exception\NoSuchEntityException; class BatchQueueProcessor implements BatchQueueProcessorInterface { - protected bool $areParentsLoaded = false; - public function __construct( protected Data $dataHelper, protected ConfigHelper $configHelper, protected ProductHelper $productHelper, + protected QueueHelper $queueHelper, protected Queue $queue, protected DiagnosticsLogger $diag, protected AlgoliaCredentialsManager $algoliaCredentialsManager, - protected ProductIndexBuilder $productIndexBuilder + protected ProductIndexBuilder $productIndexBuilder, + protected IndexCollectionSize $indexCollectionSizeCache ){} /** @@ -39,60 +42,104 @@ public function __construct( */ public function processBatch(int $storeId, ?array $entityIds = null): void { - if ($this->dataHelper->isIndexingEnabled($storeId) === false) { + if (!$this->dataHelper->isIndexingEnabled($storeId)) { return; } if (!$this->algoliaCredentialsManager->checkCredentialsWithSearchOnlyAPIKey($storeId)) { $this->algoliaCredentialsManager->displayErrorMessage(self::class, $storeId); - return; } - if ($entityIds && !$this->areParentsLoaded) { - $entityIds = array_unique(array_merge($entityIds, $this->productHelper->getParentProductIds($entityIds))); - $this->areParentsLoaded = true; - } - $productsPerPage = $this->configHelper->getNumberOfElementByPage($storeId); - if (is_array($entityIds) && count($entityIds) > 0) { - foreach (array_chunk($entityIds, $productsPerPage) as $chunk) { - /** @uses ProductIndexBuilder::buildIndexList() */ - $this->queue->addToQueue( - ProductIndexBuilder::class, - 'buildIndexList', - ['storeId' => $storeId, 'entityIds' => $chunk], - count($chunk) - ); - } - + if (!empty($entityIds)) { + $this->handleDeltaIndex($entityIds, $storeId, $productsPerPage); return; } - $useTmpIndex = $this->configHelper->isQueueActive($storeId); - $onlyVisible = !$this->configHelper->includeNonVisibleProductsInIndex(); - $collection = $this->productHelper->getProductCollectionQuery($storeId, $entityIds, $onlyVisible); + $useTmpIndex = $this->queueHelper->useTmpIndex($storeId); + $this->syncAlgoliaSettings($storeId, $useTmpIndex); + + $this->handleFullIndex($storeId, $productsPerPage, $useTmpIndex); - $timerName = __METHOD__ . ' (Get product collection size)'; - $this->diag->startProfiling($timerName); - $size = $collection->getSize(); - $this->diag->stopProfiling($timerName); + if ($useTmpIndex) { + $this->moveTempIndex($storeId); + } + } - $pages = ceil($size / $productsPerPage); + /** + * @throws DiagnosticsException + */ + protected function getCollectionSize(int $storeId, Collection $collection): int + { + $this->diag->startProfiling(__METHOD__); + $size = $this->indexCollectionSizeCache->get($storeId); + if ($size === IndexCollectionSize::NOT_FOUND) { + $size = $collection->getSize(); + $this->indexCollectionSizeCache->set($storeId, $size); + } + $this->diag->stopProfiling(__METHOD__); + return $size; + } + protected function syncAlgoliaSettings(int $storeId, bool $useTmpIndex): void + { /** @uses IndicesConfigurator::saveConfigurationToAlgolia() */ $this->queue->addToQueue(IndicesConfigurator::class, 'saveConfigurationToAlgolia', [ 'storeId' => $storeId, 'useTmpIndex' => $useTmpIndex, ], 1, true); + } + + /** + * @throws NoSuchEntityException + */ + protected function moveTempIndex(int $storeId): void { + /** @uses IndexMover::moveIndexWithSetSettings() */ + $this->queue->addToQueue(IndexMover::class, 'moveIndexWithSetSettings', [ + 'tmpIndexName' => $this->productHelper->getTempIndexName($storeId), + 'indexName' => $this->productHelper->getIndexName($storeId), + 'storeId' => $storeId, + ], 1, true); + } + + protected function handleDeltaIndex(array $entityIds, int $storeId, int $productsPerPage): void + { + $entityIds = array_unique(array_merge($entityIds, $this->productHelper->getParentProductIds($entityIds))); + + foreach (array_chunk($entityIds, $productsPerPage) as $i => $chunk) { + /** @uses ProductIndexBuilder::buildIndexList() */ + $this->queue->addToQueue( + ProductIndexBuilder::class, + 'buildIndexList', + [ + 'storeId' => $storeId, + 'entityIds' => $chunk, + 'options' => [ + 'page' => $i + 1, + 'pageSize' => $productsPerPage, + ] + ], + count($chunk) + ); + } + } + + /** + * @throws DiagnosticsException + */ + protected function handleFullIndex(int $storeId, int $productsPerPage, bool $useTmpIndex): void + { + $onlyVisible = !$this->configHelper->includeNonVisibleProductsInIndex(); + $collection = $this->productHelper->getProductCollectionQuery($storeId, [], $onlyVisible); + $pages = ceil($this->getCollectionSize($storeId, $collection) / $productsPerPage); for ($i = 1; $i <= $pages; $i++) { $data = [ 'storeId' => $storeId, 'options' => [ - 'entityIds' => $entityIds, - 'page' => $i, - 'pageSize' => $productsPerPage, + 'page' => $i, + 'pageSize' => $productsPerPage, 'useTmpIndex' => $useTmpIndex, ] ]; @@ -106,15 +153,6 @@ public function processBatch(int $storeId, ?array $entityIds = null): void true ); } - - if ($useTmpIndex) { - /** @uses IndexMover::moveIndexWithSetSettings() */ - $this->queue->addToQueue(IndexMover::class, 'moveIndexWithSetSettings', [ - 'tmpIndexName' => $this->productHelper->getTempIndexName($storeId), - 'indexName' => $this->productHelper->getIndexName($storeId), - 'storeId' => $storeId, - ], 1, true); - } } /** diff --git a/Service/Product/IndexBuilder.php b/Service/Product/IndexBuilder.php index 231f79459..a4950b3ba 100644 --- a/Service/Product/IndexBuilder.php +++ b/Service/Product/IndexBuilder.php @@ -3,6 +3,7 @@ namespace Algolia\AlgoliaSearch\Service\Product; use Algolia\AlgoliaSearch\Api\Builder\UpdatableIndexBuilderInterface; +use Algolia\AlgoliaSearch\Exception\DiagnosticsException; use Algolia\AlgoliaSearch\Exception\ProductReindexingException; use Algolia\AlgoliaSearch\Exceptions\AlgoliaException; use Algolia\AlgoliaSearch\Helper\ConfigHelper; @@ -86,71 +87,24 @@ public function buildIndexList(int $storeId, ?array $entityIds = null, ?array $o */ public function buildIndex(int $storeId, ?array $entityIds, ?array $options): void { - if ($this->isIndexingEnabled($storeId) === false) { + if (!$this->isIndexingEnabled($storeId)) { return; } - if (!is_null($entityIds)) { - $this->rebuildEntityIds($storeId, $entityIds); - return; - } + $this->startEmulation($storeId); $onlyVisible = !$this->configHelper->includeNonVisibleProductsInIndex($storeId); - $collection = $this->productHelper->getProductCollectionQuery($storeId, null, $onlyVisible); + $collection = $this->productHelper->getProductCollectionQuery($storeId, $entityIds, $onlyVisible); + $this->buildIndexPage( $storeId, $collection, - $options['page'], - $options['pageSize'], - null, - $options['entityIds'], - $options['useTmpIndex'] + $options['page'] ?? 1, + $options['pageSize'] ?? $this->configHelper->getNumberOfElementByPage($storeId), + $entityIds, + $options['useTmpIndex'] ?? false ); - } - /** - * @param int $storeId - * @param string[] $productIds - * @return void - * @throws \Exception - */ - protected function rebuildEntityIds(int $storeId, array $productIds): void - { - $this->startEmulation($storeId); - $this->logger->start('Indexing'); - try { - $onlyVisible = !$this->configHelper->includeNonVisibleProductsInIndex($storeId); - $collection = $this->productHelper->getProductCollectionQuery($storeId, $productIds, $onlyVisible); - $timerName = __METHOD__ . ' (Get product collection size)'; - $this->logger->startProfiling($timerName); - $size = $collection->getSize(); - $this->logger->stopProfiling($timerName); - - if (!empty($productIds)) { - $size = max(count($productIds), $size); - } - $this->logger->log('Store ' . $this->logger->getStoreName($storeId) . ' collection size : ' . $size); - if ($size > 0) { - $pages = ceil($size / $this->configHelper->getNumberOfElementByPage()); - $collection->clear(); - $page = 1; - while ($page <= $pages) { - $this->buildIndexPage( - $storeId, - $collection, - $page, - $this->configHelper->getNumberOfElementByPage(), - null, - $productIds - ); - $page++; - } - } - } catch (\Exception $e) { - $this->stopEmulation(); - throw $e; - } - $this->logger->stop('Indexing'); $this->stopEmulation(); } @@ -186,42 +140,41 @@ public function deleteInactiveProducts($storeId): void } /** - * @param $storeId - * @param $collectionDefault - * @param $page - * @param $pageSize - * @param $emulationInfo - * @param $productIds - * @param $useTmpIndex + * @param int $storeId + * @param Collection $collection - collection to be paged + * @param int $page + * @param int $pageSize + * @param array|null $productIds - pre-batched product ids - if specified no paging will be applied + * @param bool|null $useTmpIndex * @return void - * @throws \Exception + * @throws AlgoliaException + * @throws DiagnosticsException + * @throws NoSuchEntityException */ protected function buildIndexPage( - $storeId, - $collectionDefault, - $page, - $pageSize, - $emulationInfo = null, - $productIds = null, - $useTmpIndex = false + int $storeId, + Collection $collection, + int $page, + int $pageSize, + ?array $productIds = null, + ?bool $useTmpIndex = false ): void { if ($this->isIndexingEnabled($storeId) === false) { return; } - $wrapperLogMessage = 'rebuildStoreProductIndexPage: ' . $this->logger->getStoreName($storeId) . ', + $wrapperLogMessage = 'Build products index page: ' . $this->logger->getStoreName($storeId) . ', page ' . $page . ', pageSize ' . $pageSize; $this->logger->start($wrapperLogMessage, true); - if ($emulationInfo === null) { - $this->startEmulation($storeId); - } + $additionalAttributes = $this->configHelper->getProductAdditionalAttributes($storeId); - /** @var Collection $collection */ - $collection = clone $collectionDefault; - $collection->setCurPage($page)->setPageSize($pageSize); + if (empty($productIds)) { + $collection->setCurPage($page)->setPageSize($pageSize); + } + $collection->addCategoryIds(); $collection->addUrlRewrite(); @@ -248,7 +201,7 @@ protected function buildIndexPage( collection page: ' . $page . ', pageSize: ' . $pageSize; $this->logger->start($logMessage); - $collection->load(); + $collection->load(); // eliminate extra query to obtain count $this->logger->log('Loaded ' . count($collection) . ' products'); $this->logger->stop($logMessage); $indexOptions = $this->indexOptionsBuilder->buildEntityIndexOptions($storeId, $useTmpIndex); @@ -269,14 +222,6 @@ protected function buildIndexPage( $this->logger->stop('REMOVE FROM ALGOLIA'); } } - unset($indexData); - $collection->walk('clearInstance'); - $collection->clear(); - unset($collection); - if ($emulationInfo === null) { - $this->stopEmulation(); - } - $this->logger->stop($wrapperLogMessage, true); } diff --git a/Service/Product/RecordBuilder.php b/Service/Product/RecordBuilder.php index 5c4a59e92..f0a6a6831 100644 --- a/Service/Product/RecordBuilder.php +++ b/Service/Product/RecordBuilder.php @@ -123,7 +123,11 @@ public function buildRecord(DataObject $entity): array } $subProducts = $this->getSubProducts($product); $customData = $this->addAdditionalAttributes($customData, $additionalAttributes, $product, $subProducts); - $customData = $this->priceManager->addPriceDataByProductType($customData, $product, $subProducts); + + if ($this->isPriceIndexingEnabled($additionalAttributes)) { + $customData = $this->priceManager->addPriceDataByProductType($customData, $product, $subProducts); + } + $transport = new DataObject($customData); $this->eventManager->dispatch( 'algolia_subproducts_index', @@ -186,13 +190,16 @@ protected function addAttribute($attribute, $defaultData, $customData, $addition */ public function isAttributeEnabled($additionalAttributes, $attributeName): bool { - foreach ($additionalAttributes as $attr) { - if ($attr['attribute'] === $attributeName) { - return true; - } - } + return $this->configHelper->isAttributeInList($additionalAttributes, $attributeName); + } - return false; + /** + * @param array $additionalAttributes + * @return bool + */ + protected function isPriceIndexingEnabled(array $additionalAttributes): bool + { + return $this->configHelper->isAttributeInList($additionalAttributes, 'price'); } /** @@ -816,3 +823,4 @@ public function productIsInStock($product, $storeId): bool return $product->isSaleable() && $stockItem->getIsInStock(); } } + diff --git a/Service/Product/ReplicaManager.php b/Service/Product/ReplicaManager.php index 6121de3b5..e5ddb9ae1 100644 --- a/Service/Product/ReplicaManager.php +++ b/Service/Product/ReplicaManager.php @@ -126,7 +126,7 @@ protected function getReplicaConfigurationFromAlgolia( protected function clearAlgoliaReplicaSettingCache($primaryIndexName = null): void { - if (is_null($primaryIndexName)) { + if ($primaryIndexName === null) { $this->_algoliaReplicaConfig = []; } else { unset($this->_algoliaReplicaConfig[$primaryIndexName]); @@ -565,7 +565,7 @@ public function getUnusedReplicaIndices(int $storeId): array protected function clearUnusedReplicaIndicesCache(?int $storeId = null): void { - if (is_null($storeId)) { + if ($storeId === null) { $this->_unusedReplicaIndices = []; } else { unset($this->_unusedReplicaIndices[$storeId]); diff --git a/Service/Product/SortingTransformer.php b/Service/Product/SortingTransformer.php index 746f1dba7..718f44633 100644 --- a/Service/Product/SortingTransformer.php +++ b/Service/Product/SortingTransformer.php @@ -51,7 +51,7 @@ public function getSortingIndices( ): array { // Selectively cache this result - only cache manipulation of saved settings per store - $useCache = is_null($currentCustomerGroupId) && is_null($attrs); + $useCache = $currentCustomerGroupId === null && $attrs === null; if ($clearCache) { unset($this->_sortingIndices[$storeId]); @@ -78,7 +78,7 @@ public function getSortingIndices( if ($this->configHelper->isCustomerGroupsEnabled($storeId) && $attr[ReplicaManagerInterface::SORT_KEY_ATTRIBUTE_NAME] === ReplicaManagerInterface::SORT_ATTRIBUTE_PRICE) { $websiteId = $this->storeManager->getStore($storeId)->getWebsiteId(); $groupCollection = $this->groupCollection; - if (!is_null($currentCustomerGroupId)) { + if ($currentCustomerGroupId !== null) { $groupCollection->addFilter('customer_group_id', $currentCustomerGroupId); } foreach ($groupCollection as $group) { diff --git a/Setup/Patch/Data/MigrateBatchSizeConfigPatch.php b/Setup/Patch/Data/MigrateBatchSizeConfigPatch.php new file mode 100644 index 000000000..3e28b2eaf --- /dev/null +++ b/Setup/Patch/Data/MigrateBatchSizeConfigPatch.php @@ -0,0 +1,61 @@ +moduleDataSetup->getConnection()->startSetup(); + + $this->moveIndexingSettings(); + + $this->moduleDataSetup->getConnection()->endSetup(); + + return $this; + } + + /** + * Migrate old Indexing configurations + * @return void + */ + protected function moveIndexingSettings(): void + { + $movedConfig = [ + 'algoliasearch_advanced/queue/number_of_element_by_page' => ConfigHelper::NUMBER_OF_ELEMENT_BY_PAGE, + ]; + + $this->migrateConfig($movedConfig); + } + + /** + * @inheritDoc + */ + public static function getDependencies(): array + { + return []; + } + + /** + * @inheritDoc + */ + public function getAliases(): array + { + return []; + } +} diff --git a/Setup/Patch/Data/MigrateLegacyConfigPatch.php b/Setup/Patch/Data/MigrateLegacyConfigPatch.php new file mode 100644 index 000000000..41e30d563 --- /dev/null +++ b/Setup/Patch/Data/MigrateLegacyConfigPatch.php @@ -0,0 +1,64 @@ +moduleDataSetup->getConnection()->startSetup(); + + $this->moveIndexingSettings(); + + $this->moduleDataSetup->getConnection()->endSetup(); + + return $this; + } + + /** + * Migrate legacy configurations + * @return void + */ + protected function moveIndexingSettings(): void + { + $movedConfig = [ + 'algoliasearch_credentials/credentials/use_adaptive_image' => 'algoliasearch_products/products/use_adaptive_image', + 'algoliasearch_products/products/number_product_results' => 'algoliasearch_instant/instant/number_product_results', + 'algoliasearch_products/products/show_suggestions_on_no_result_page' => 'algoliasearch_instant/instant/show_suggestions_on_no_result_page', + 'algoliasearch_credentials/credentials/is_popup_enabled' => 'algoliasearch_autocomplete/autocomplete/is_popup_enabled', + 'algoliasearch_credentials/credentials/is_instant_enabled' => 'algoliasearch_instant/instant/is_instant_enabled', + ]; + + $this->migrateConfig($movedConfig); + } + + /** + * @inheritDoc + */ + public static function getDependencies(): array + { + return []; + } + + /** + * @inheritDoc + */ + public function getAliases(): array + { + return []; + } +} diff --git a/Setup/Patch/Schema/ConfigPatch.php b/Test/Integration/Config/DefaultConfigProvider.php similarity index 71% rename from Setup/Patch/Schema/ConfigPatch.php rename to Test/Integration/Config/DefaultConfigProvider.php index 564b3c6bc..6b6c1316b 100644 --- a/Setup/Patch/Schema/ConfigPatch.php +++ b/Test/Integration/Config/DefaultConfigProvider.php @@ -1,31 +1,9 @@ '0', 'algoliasearch_queue/queue/number_of_job_to_run' => '5', 'algoliasearch_queue/queue/number_of_retries' => '3', + 'algoliasearch_queue/queue/use_tmp_index' => '1', 'algoliasearch_cc_analytics/cc_analytics_group/enable' => '0', 'algoliasearch_cc_analytics/cc_analytics_group/is_selector' => '.ais-Hits-item a.result, .ais-InfiniteHits-item a.result', @@ -80,7 +59,7 @@ class ConfigPatch implements SchemaPatchInterface 'algoliasearch_synonyms/synonyms_group/enable_synonyms' => '0', - 'algoliasearch_advanced/queue/number_of_element_by_page' => '300', + 'algoliasearch_advanced/advanced/number_of_element_by_page' => '300', 'algoliasearch_advanced/advanced/remove_words_if_no_result' => 'allOptional', 'algoliasearch_advanced/advanced/partial_update' => '0', 'algoliasearch_advanced/advanced/customer_groups_enable' => '0', @@ -256,83 +235,16 @@ class ConfigPatch implements SchemaPatchInterface ], ]; - /** - * @param ConfigInterface $config - * @param ProductMetadataInterface $productMetadata - * @param ModuleDataSetupInterface $moduleDataSetup Magento\Framework\App\ResourceConnection - */ - public function __construct( - ConfigInterface $config, - ProductMetadataInterface $productMetadata, - ModuleDataSetupInterface $moduleDataSetup - ) { - $this->config = $config; - $this->productMetadata = $productMetadata; - $this->moduleDataSetup = $moduleDataSetup; - + public function __construct() + { $this->serializeDefaultArrayConfigData(); $this->mergeDefaultDataWithArrayData(); } - /** - * @return array|string[] - */ - public static function getDependencies() - { - return []; - } - - /** - * @return array|string[] - */ - public function getAliases() - { - return []; - } - - /** - * @return ConfigPatch|void - */ - public function apply() - { - $movedConfigDirectives = [ - 'algoliasearch_credentials/credentials/use_adaptive_image' => 'algoliasearch_products/products/use_adaptive_image', - 'algoliasearch_products/products/number_product_results' => 'algoliasearch_instant/instant/number_product_results', - 'algoliasearch_products/products/show_suggestions_on_no_result_page' => 'algoliasearch_instant/instant/show_suggestions_on_no_result_page', - 'algoliasearch_credentials/credentials/is_popup_enabled' => 'algoliasearch_autocomplete/autocomplete/is_popup_enabled', - 'algoliasearch_credentials/credentials/is_instant_enabled' => 'algoliasearch_instant/instant/is_instant_enabled', - ]; - - $this->moduleDataSetup->getConnection()->startSetup(); - $connection = $this->moduleDataSetup->getConnection(); - $table = $this->moduleDataSetup->getTable('core_config_data'); - foreach ($movedConfigDirectives as $from => $to) { - try { - $connection->query('UPDATE ' . $table . ' SET path = "' . $to . '" WHERE path = "' . $from . '"'); - } catch (\Magento\Framework\DB\Adapter\DuplicateException) { - // Skip - } - } - - /* SET DEFAULT CONFIG DATA */ - $alreadyInserted = $connection->getConnection() - ->query('SELECT path, value FROM ' . $table . ' WHERE path LIKE "algoliasearch_%"') - ->fetchAll(\PDO::FETCH_KEY_PAIR); - - foreach ($this->defaultConfigData as $path => $value) { - if (isset($alreadyInserted[$path])) { - continue; - } - $this->config->saveConfig($path, $value, 'default', 0); - } - - $this->moduleDataSetup->getConnection()->endSetup(); - } - /** * @return string[] */ - public function getDefaultConfigData() + public function getDefaultConfigData(): array { return $this->defaultConfigData; } @@ -340,24 +252,17 @@ public function getDefaultConfigData() /** * @return void */ - protected function serializeDefaultArrayConfigData() + protected function serializeDefaultArrayConfigData(): void { - $serializeMethod = 'serialize'; - - $magentoVersion = $this->productMetadata->getVersion(); - if (version_compare($magentoVersion, '2.2.0-dev', '>=') === true) { - $serializeMethod = 'json_encode'; - } - foreach ($this->defaultArrayConfigData as $path => $array) { - $this->defaultArrayConfigData[$path] = $serializeMethod($array); + $this->defaultArrayConfigData[$path] = json_encode($array); } } /** * @return void */ - protected function mergeDefaultDataWithArrayData() + protected function mergeDefaultDataWithArrayData(): void { $this->defaultConfigData = array_merge($this->defaultConfigData, $this->defaultArrayConfigData); } diff --git a/Test/Integration/Indexing/Config/ConfigTest.php b/Test/Integration/Indexing/Config/ConfigTest.php index 2f6af8375..3cc32e90e 100644 --- a/Test/Integration/Indexing/Config/ConfigTest.php +++ b/Test/Integration/Indexing/Config/ConfigTest.php @@ -32,7 +32,13 @@ public function testRenderingContent() { $this->setConfig('algoliasearch_instant/instant_facets/enable_dynamic_facets', '1'); - $this->syncSettingsToAlgolia(); + try { + $this->syncSettingsToAlgolia(); + } catch (AlgoliaException $e) { + // Skip this test if the renderingContent feature isn't enabled on the application + $this->setConfig('algoliasearch_instant/instant_facets/enable_dynamic_facets', '0'); + $this->markTestSkipped($e->getMessage()); + } $indexOptions = $this->indexOptionsBuilder->buildWithEnforcedIndex($this->indexPrefix . 'default_products'); $indexSettings = $this->algoliaConnector->getSettings($indexOptions); diff --git a/Test/Integration/Indexing/Product/MultiStoreReplicaTest.php b/Test/Integration/Indexing/Product/MultiStoreReplicaTest.php index e04a75e51..32e766249 100644 --- a/Test/Integration/Indexing/Product/MultiStoreReplicaTest.php +++ b/Test/Integration/Indexing/Product/MultiStoreReplicaTest.php @@ -3,8 +3,8 @@ namespace Algolia\AlgoliaSearch\Test\Integration\Indexing\Product; use Algolia\AlgoliaSearch\Api\Product\ReplicaManagerInterface; -use Algolia\AlgoliaSearch\Console\Command\ReplicaRebuildCommand; -use Algolia\AlgoliaSearch\Console\Command\ReplicaSyncCommand; +use Algolia\AlgoliaSearch\Console\Command\Replica\ReplicaRebuildCommand; +use Algolia\AlgoliaSearch\Console\Command\Replica\ReplicaSyncCommand; use Algolia\AlgoliaSearch\Helper\ConfigHelper; use Algolia\AlgoliaSearch\Service\Product\IndexOptionsBuilder as ProductIndexOptionsBuilder; use Algolia\AlgoliaSearch\Service\Product\SortingTransformer; diff --git a/Test/Integration/Indexing/Product/ProductsIndexingTest.php b/Test/Integration/Indexing/Product/ProductsIndexingTest.php index 2fe18435a..577d2daa0 100644 --- a/Test/Integration/Indexing/Product/ProductsIndexingTest.php +++ b/Test/Integration/Indexing/Product/ProductsIndexingTest.php @@ -70,7 +70,7 @@ public function testDefaultIndexableAttributes() 'thumbnail_url', 'image_url', 'in_stock', - 'price', + //'price', since version 3.17.0, the price attribute is not mandatory if it's not present in any attributes list 'type_id', 'algoliaLastUpdateAtCET', 'categoryIds', @@ -85,6 +85,8 @@ public function testDefaultIndexableAttributes() unset($hit[$attribute]); } + $this->assertArrayNotHasKey('price', $hit, 'Record has a price attribute but it should not'); + $extraAttributes = implode(', ', array_keys($hit)); $this->assertEmpty($hit, 'Extra products attributes (' . $extraAttributes . ') are indexed and should not be.'); } diff --git a/Test/Integration/Indexing/Product/ReplicaIndexingTest.php b/Test/Integration/Indexing/Product/ReplicaIndexingTest.php index af071f6bd..8094bb135 100644 --- a/Test/Integration/Indexing/Product/ReplicaIndexingTest.php +++ b/Test/Integration/Indexing/Product/ReplicaIndexingTest.php @@ -2,7 +2,6 @@ namespace Algolia\AlgoliaSearch\Test\Integration\Indexing\Product; -use Algolia\AlgoliaSearch\Algolia; use Algolia\AlgoliaSearch\Api\LoggerInterface; use Algolia\AlgoliaSearch\Api\Product\ReplicaManagerInterface; use Algolia\AlgoliaSearch\Exceptions\AlgoliaException; @@ -11,15 +10,14 @@ use Algolia\AlgoliaSearch\Helper\Entity\ProductHelper; use Algolia\AlgoliaSearch\Logger\DiagnosticsLogger; use Algolia\AlgoliaSearch\Model\IndicesConfigurator; -use Algolia\AlgoliaSearch\Service\AlgoliaCredentialsManager; -use Algolia\AlgoliaSearch\Service\Product\IndexOptionsBuilder; -use Algolia\AlgoliaSearch\Test\Integration\Indexing\Product\Traits\ReplicaAssertionsTrait; use Algolia\AlgoliaSearch\Registry\ReplicaState; -use Algolia\AlgoliaSearch\Service\AlgoliaConnector; +use Algolia\AlgoliaSearch\Service\AlgoliaCredentialsManager; use Algolia\AlgoliaSearch\Service\IndexNameFetcher; +use Algolia\AlgoliaSearch\Service\Product\IndexOptionsBuilder; use Algolia\AlgoliaSearch\Service\Product\ReplicaManager; use Algolia\AlgoliaSearch\Service\Product\SortingTransformer; use Algolia\AlgoliaSearch\Service\StoreNameFetcher; +use Algolia\AlgoliaSearch\Test\Integration\Indexing\Product\Traits\ReplicaAssertionsTrait; use Algolia\AlgoliaSearch\Test\Integration\TestCase; use Algolia\AlgoliaSearch\Validator\VirtualReplicaValidatorFactory; use Magento\Framework\App\State as AppState; @@ -157,7 +155,7 @@ public function testReplicaRebuild(): void $sorting = $this->populateReplicas(1); - $rebuildCmd = $this->objectManager->get(\Algolia\AlgoliaSearch\Console\Command\ReplicaRebuildCommand::class); + $rebuildCmd = $this->objectManager->get(\Algolia\AlgoliaSearch\Console\Command\Replica\ReplicaRebuildCommand::class); $this->invokeMethod( $rebuildCmd, 'execute', @@ -410,7 +408,7 @@ protected function populateReplicas(int $storeId): array { $sorting = $this->objectManager->get(\Algolia\AlgoliaSearch\Service\Product\SortingTransformer::class)->getSortingIndices($storeId, null, null, true); - $cmd = $this->objectManager->get(\Algolia\AlgoliaSearch\Console\Command\ReplicaSyncCommand::class); + $cmd = $this->objectManager->get(\Algolia\AlgoliaSearch\Console\Command\Replica\ReplicaSyncCommand::class); $this->mockProperty($cmd, 'output', \Symfony\Component\Console\Output\OutputInterface::class); diff --git a/Test/Integration/Indexing/Product/Traits/ReplicaAssertionsTrait.php b/Test/Integration/Indexing/Product/Traits/ReplicaAssertionsTrait.php index 3d4dcd1fe..85f5d6faa 100644 --- a/Test/Integration/Indexing/Product/Traits/ReplicaAssertionsTrait.php +++ b/Test/Integration/Indexing/Product/Traits/ReplicaAssertionsTrait.php @@ -130,7 +130,7 @@ protected function assertNoSortingAttribute($sortAttr, $sortDir): void */ protected function mockSortUpdate(string $sortAttr, string $sortDir, array $attr, ?StoreInterface $store = null): void { - $sorting = $this->configHelper->getSorting(!is_null($store) ? $store->getId() : null); + $sorting = $this->configHelper->getSorting($store?->getId()); $existing = array_filter($sorting, fn($item) => $item['attribute'] === $sortAttr && $item['sort'] === $sortDir); if ($existing) { @@ -150,7 +150,7 @@ protected function mockSortUpdate(string $sortAttr, string $sortDir, array $attr $this->setConfig( ConfigHelper::SORTING_INDICES, json_encode($sorting), - !is_null($store) ? $store->getCode() : 'default' + $store?->getCode() ?? 'default' ); } } diff --git a/Test/Integration/Indexing/Queue/QueueTest.php b/Test/Integration/Indexing/Queue/QueueTest.php index 85b8a22f5..9eb931127 100644 --- a/Test/Integration/Indexing/Queue/QueueTest.php +++ b/Test/Integration/Indexing/Queue/QueueTest.php @@ -3,6 +3,7 @@ namespace Algolia\AlgoliaSearch\Test\Integration\Indexing\Queue; use Algolia\AlgoliaSearch\Helper\ConfigHelper; +use Algolia\AlgoliaSearch\Helper\Configuration\QueueHelper; use Algolia\AlgoliaSearch\Model\Indexer\QueueRunner; use Algolia\AlgoliaSearch\Model\IndicesConfigurator; use Algolia\AlgoliaSearch\Model\Job; @@ -44,11 +45,11 @@ protected function setUp(): void public function testFill() { $this->resetConfigs([ - ConfigHelper::NUMBER_OF_JOB_TO_RUN, + QueueHelper::NUMBER_OF_JOB_TO_RUN, ConfigHelper::NUMBER_OF_ELEMENT_BY_PAGE, ]); - $this->setConfig(ConfigHelper::IS_ACTIVE, '1'); + $this->setConfig(QueueHelper::IS_ACTIVE, '1'); $this->connection->query('TRUNCATE TABLE algoliasearch_queue'); $productBatchQueueProcessor = $this->objectManager->get(ProductBatchQueueProcessor::class); @@ -84,7 +85,7 @@ public function testFill() public function testExecute() { - $this->setConfig(ConfigHelper::IS_ACTIVE, '1'); + $this->setConfig(QueueHelper::IS_ACTIVE, '1'); $this->connection->query('TRUNCATE TABLE algoliasearch_queue'); $productBatchQueueProcessor = $this->objectManager->get(ProductBatchQueueProcessor::class); @@ -136,16 +137,58 @@ public function testExecute() $this->assertEquals(0, count($rows)); } + public function testTmpIndexConfig() + { + $this->setConfig(QueueHelper::IS_ACTIVE, '1'); + // Setting "Use a temporary index for full products reindex" configuration to "No" + $this->setConfig(QueueHelper::USE_TMP_INDEX, '0'); + $this->connection->query('TRUNCATE TABLE algoliasearch_queue'); + + $productBatchQueueProcessor = $this->objectManager->get(ProductBatchQueueProcessor::class); + $productBatchQueueProcessor->processBatch(1); + + $rows = $this->connection->query('SELECT * FROM algoliasearch_queue')->fetchAll(); + // Without temporary index enabled, there are only 2 jobs (saveConfigurationToAlgolia and buildIndexFull) + $this->assertEquals(2, count($rows)); + + $indexingJob = $rows[1]; + $jobData = json_decode($indexingJob['data'], true); + + $this->assertEquals('buildIndexFull', $indexingJob['method']); + $this->assertFalse($jobData['options']['useTmpIndex']); + + /** @var Queue $queue */ + $queue = $this->objectManager->get(Queue::class); + + // Run the first job (saveSettings) + $queue->runCron(1, true); + + $this->algoliaConnector->waitLastTask(); + + $indices = $this->algoliaConnector->listIndexes(); + + $existsDefaultTmpIndex = false; + foreach ($indices['items'] as $index) { + if ($index['name'] === $this->indexPrefix . 'default_products_tmp') { + $existsDefaultTmpIndex = true; + } + } + // Checking if the temporary index hasn't been created + $this->assertFalse($existsDefaultTmpIndex, 'Temporary index exists and it should not'); + } + public function testSettings() { $this->resetConfigs([ - ConfigHelper::NUMBER_OF_JOB_TO_RUN, + QueueHelper::NUMBER_OF_JOB_TO_RUN, ConfigHelper::NUMBER_OF_ELEMENT_BY_PAGE, ConfigHelper::FACETS, + QueueHelper::USE_TMP_INDEX, ConfigHelper::PRODUCT_ATTRIBUTES ]); - $this->setConfig(ConfigHelper::IS_ACTIVE, '1'); + $this->setConfig(QueueHelper::IS_ACTIVE, '1'); + $this->setConfig(ConfigHelper::ENABLE_INDEXER_QUEUE, '1'); $this->connection->query('DELETE FROM algoliasearch_queue'); @@ -178,8 +221,9 @@ public function testSettings() public function testMergeSettings() { - $this->setConfig(ConfigHelper::IS_ACTIVE, '1'); - $this->setConfig(ConfigHelper::NUMBER_OF_JOB_TO_RUN, 1); + $this->setConfig(QueueHelper::IS_ACTIVE, '1'); + $this->setConfig(ConfigHelper::ENABLE_INDEXER_QUEUE, '1'); + $this->setConfig(QueueHelper::NUMBER_OF_JOB_TO_RUN, 1); $this->setConfig(ConfigHelper::NUMBER_OF_ELEMENT_BY_PAGE, 300); $this->connection->query('DELETE FROM algoliasearch_queue'); @@ -791,7 +835,7 @@ public function testGetJobs() public function testHugeJob() { // Default value - maxBatchSize = 1000 - $this->setConfig(ConfigHelper::NUMBER_OF_JOB_TO_RUN, 10); + $this->setConfig(QueueHelper::NUMBER_OF_JOB_TO_RUN, 10); $this->setConfig(ConfigHelper::NUMBER_OF_ELEMENT_BY_PAGE, 100); $productIds = range(1, 5000); @@ -829,7 +873,7 @@ public function testHugeJob() public function testMaxSingleJobSize() { // Default value - maxBatchSize = 1000 - $this->setConfig(ConfigHelper::NUMBER_OF_JOB_TO_RUN, 10); + $this->setConfig(QueueHelper::NUMBER_OF_JOB_TO_RUN, 10); $this->setConfig(ConfigHelper::NUMBER_OF_ELEMENT_BY_PAGE, 100); $productIds = range(1, 99); @@ -870,13 +914,13 @@ public function testMaxSingleJobSize() public function testMaxSingleJobsSizeOnProductReindex() { $this->resetConfigs([ - ConfigHelper::NUMBER_OF_JOB_TO_RUN, + QueueHelper::NUMBER_OF_JOB_TO_RUN, ConfigHelper::NUMBER_OF_ELEMENT_BY_PAGE, ]); - $this->setConfig(ConfigHelper::IS_ACTIVE, '1'); + $this->setConfig(QueueHelper::IS_ACTIVE, '1'); - $this->setConfig(ConfigHelper::NUMBER_OF_JOB_TO_RUN, 10); + $this->setConfig(QueueHelper::NUMBER_OF_JOB_TO_RUN, 10); $this->setConfig(ConfigHelper::NUMBER_OF_ELEMENT_BY_PAGE, 100); $this->connection->query('TRUNCATE TABLE algoliasearch_queue'); diff --git a/Test/Integration/TestCase.php b/Test/Integration/TestCase.php index 7129d1981..8d1378067 100644 --- a/Test/Integration/TestCase.php +++ b/Test/Integration/TestCase.php @@ -8,18 +8,18 @@ use Algolia\AlgoliaSearch\Service\AlgoliaConnector; use Algolia\AlgoliaSearch\Service\IndexNameFetcher; use Algolia\AlgoliaSearch\Service\IndexOptionsBuilder; -use Algolia\AlgoliaSearch\Setup\Patch\Schema\ConfigPatch; use Algolia\AlgoliaSearch\Test\Integration\AssertValues\Magento246CE; use Algolia\AlgoliaSearch\Test\Integration\AssertValues\Magento246EE; use Algolia\AlgoliaSearch\Test\Integration\AssertValues\Magento247CE; use Algolia\AlgoliaSearch\Test\Integration\AssertValues\Magento247EE; +use Algolia\AlgoliaSearch\Test\Integration\Config\DefaultConfigProvider; use Magento\Framework\App\Config\ScopeConfigInterface; use Magento\Framework\App\ProductMetadataInterface; use Magento\Framework\ObjectManagerInterface; use Magento\Store\Model\ScopeInterface; use Magento\TestFramework\Helper\Bootstrap; -abstract class TestCase extends \PHPUnit\Framework\TestCase +abstract class TestCase extends \Algolia\AlgoliaSearch\Test\TestCase { /** * @var ObjectManagerInterface @@ -70,9 +70,9 @@ protected function getIndexName(string $storeIndexPart): string protected function resetConfigs($configs = []) { - /** @var ConfigPatch $installClass */ - $installClass = $this->getObjectManager()->get(ConfigPatch::class); - $defaultConfigData = $installClass->getDefaultConfigData(); + /** @var DefaultConfigProvider $defaultConfigProvider */ + $defaultConfigProvider = $this->getObjectManager()->get(DefaultConfigProvider::class); + $defaultConfigData = $defaultConfigProvider->getDefaultConfigData(); foreach ($configs as $config) { $value = (string) $defaultConfigData[$config]; @@ -230,35 +230,6 @@ private function bootstrap() $this->boostrapped = true; } - - /** - * @throws \ReflectionException - */ - protected function mockProperty(object $object, string $propertyName, string $propertyClass): void - { - $mock = $this->createMock($propertyClass); - $reflection = new \ReflectionClass($object); - $property = $reflection->getProperty($propertyName); - $property->setValue($object, $mock); - } - - /** - * Call protected/private method of a class. - * - * @param object $object instantiated object that we will run method on - * @param string $methodName Method name to call - * @param array $parameters array of parameters to pass into method - * - * @throws \ReflectionException - * - * @return mixed method return - */ - protected function invokeMethod(object $object, string $methodName, array $parameters = []) - { - $reflection = new \ReflectionClass($object::class); - return $reflection->getMethod($methodName)->invokeArgs($object, $parameters); - } - private function getMagentoVersion() { return $this->productMetadata->getVersion(); diff --git a/Test/TestCase.php b/Test/TestCase.php new file mode 100644 index 000000000..9a490b135 --- /dev/null +++ b/Test/TestCase.php @@ -0,0 +1,60 @@ +getMethod($methodName)->invokeArgs($object, $parameters); + } + + /** + * Set a private property of a class + * + * @param object $obj The object to set the property for + * @param string $prop The name of the property to set + * @param mixed $value The value to set the property to + * + * @throws \ReflectionException + */ + protected function setPrivateProperty(object $obj, string $prop, mixed $value): void + { + $ref = new \ReflectionClass($obj); + $p = $ref->getProperty($prop); + $p->setValue($obj, $value); + } + + /** + * Mock a private property of a class + * + * @param object $object The object to mock the property for + * @param string $propertyName The name of the property to mock + * @param string $propertyClass The class of the property to mock + * + * @throws \ReflectionException + */ + protected function mockProperty(object $object, string $propertyName, string $propertyClass): void + { + $mock = $this->createMock($propertyClass); + $reflection = new \ReflectionClass($object); + $property = $reflection->getProperty($propertyName); + $property->setValue($object, $mock); + } +} diff --git a/Test/Unit/Block/ConfigurationTest.php b/Test/Unit/Block/ConfigurationTest.php new file mode 100644 index 000000000..7d6a057e5 --- /dev/null +++ b/Test/Unit/Block/ConfigurationTest.php @@ -0,0 +1,172 @@ +config = $this->createMock(ConfigHelper::class); + $this->autocompleteConfig = $this->createMock(AutocompleteHelper::class); + $this->instantSearchConfig = $this->createMock(InstantSearchHelper::class); + $this->personalizationHelper = $this->createMock(PersonalizationHelper::class); + $this->catalogSearchHelper = $this->createMock(CatalogSearchHelper::class); + $this->productHelper = $this->createMock(ProductHelper::class); + $this->currency = $this->createMock(Currency::class); + $this->format = $this->createMock(Format::class); + $this->currentProduct = $this->createMock(CurrentProduct::class); + $this->algoliaConnector = $this->createMock(AlgoliaConnector::class); + $this->urlHelper = $this->createMock(UrlHelper::class); + $this->formKey = $this->createMock(FormKey::class); + $this->httpContext = $this->createMock(HttpContext::class); + $this->coreHelper = $this->createMock(CoreHelper::class); + $this->categoryHelper = $this->createMock(CategoryHelper::class); + $this->suggestionHelper = $this->createMock(SuggestionHelper::class); + $this->landingPageHelper = $this->createMock(LandingPageHelper::class); + $this->checkoutSession = $this->createMock(CheckoutSession::class); + $this->date = $this->createMock(DateTime::class); + $this->currentCategory = $this->createMock(CurrentCategory::class); + $this->sortingTransformer = $this->createMock(SortingTransformer::class); + $this->context = $this->createMock(Context::class); + + $this->request = $this->createMock(Http::class); + $this->context->method('getRequest')->willReturn($this->request); + + $this->configurationBlock = new ConfigurationBlock( + $this->config, + $this->autocompleteConfig , + $this->instantSearchConfig , + $this->personalizationHelper , + $this->catalogSearchHelper , + $this->productHelper, + $this->currency , + $this->format , + $this->currentProduct , + $this->algoliaConnector , + $this->urlHelper , + $this->formKey , + $this->httpContext , + $this->coreHelper , + $this->categoryHelper, + $this->suggestionHelper , + $this->landingPageHelper , + $this->checkoutSession, + $this->date , + $this->currentCategory , + $this->sortingTransformer , + $this->context, + ); + } + + /** + * @dataProvider searchPageDataProvider + */ + public function testIsSearchPage($action, $categoryId, $categoryDisplayMode, $expectedResult): void + { + $this->config->method('isInstantEnabled')->willReturn(true); + $this->request->method('getFullActionName')->willReturn($action); + + $controller = explode('_', $action); + $controller = $controller[1]; + + $this->request->method('getControllerName')->willReturn($controller); + $this->config->method('replaceCategories')->willReturn(true); + + $category = $this->createMock(Category::class); + $category->method('getId')->willReturn($categoryId); + $category->method('getDisplayMode')->willReturn($categoryDisplayMode); + $this->currentCategory->method('get')->willReturn($category); + + $this->assertEquals($expectedResult, $this->configurationBlock->isSearchPage()); + } + + public static function searchPageDataProvider(): array + { + return [ + [ // true if category has an ID + 'action' => 'catalog_category_view', + 'categoryId' => 1, + 'categoryDisplayMode' => 'PRODUCT', + 'expectedResult' => true + ], + [ // false if category has no ID + 'action' => 'catalog_category_view', + 'categoryId' => null, + 'categoryDisplayMode' => 'PRODUCT', + 'expectedResult' => false + ], + [ // false if category has a PAGE as display mode + 'action' => 'catalog_category_view', + 'categoryId' => 1, + 'categoryDisplayMode' => 'PAGE', + 'expectedResult' => false + ], + [ // true if catalogsearch + 'action' => 'catalogsearch_result_index', + 'categoryId' => null, + 'categoryDisplayMode' => 'FOO', + 'expectedResult' => true + ], + [ // true if landing page + 'action' => 'algolia_landingpage_view', + 'categoryId' => null, + 'categoryDisplayMode' => 'FOO', + 'expectedResult' => true + ] + ]; + } +} diff --git a/Test/Unit/Console/Command/Queue/ClearQueueCommandTest.php b/Test/Unit/Console/Command/Queue/ClearQueueCommandTest.php new file mode 100644 index 000000000..56e589a03 --- /dev/null +++ b/Test/Unit/Console/Command/Queue/ClearQueueCommandTest.php @@ -0,0 +1,406 @@ +state = $this->createMock(State::class); + $this->storeNameFetcher = $this->createMock(StoreNameFetcher::class); + $this->storeManager = $this->createMock(StoreManagerInterface::class); + $this->jobResourceModel = $this->createMock(JobResourceModel::class); + } + + /** + * Test that the command returns success when the user cancels the operation + * @throws \ReflectionException + */ + public function testExecuteReturnsSuccessWhenUserCancels(): void + { + $cmd = $this->makePartial(['setAreaCode', 'confirmOperation', 'getStoreIds', 'decorateOperationAnnouncementMessage', 'clearQueue']); + + $cmd->expects($this->once())->method('setAreaCode'); + $cmd->expects($this->once())->method('confirmOperation')->willReturn(false); + $cmd->expects($this->never())->method('getStoreIds'); + $cmd->expects($this->never())->method('clearQueue'); + + $input = new ArrayInput([]); + $output = $this->bufOut(); + + $code = $this->invokeExecute($cmd, $input, $output); + $this->assertSame(Cli::RETURN_SUCCESS, $code); + } + + /** + * Test that the command clears the indexing queue for the provided store IDs + * @throws \ReflectionException + */ + public function testExecuteClearsProvidedStoreIds(): void + { + $cmd = $this->makePartial(['setAreaCode', 'confirmOperation', 'getStoreIds', 'decorateOperationAnnouncementMessage', 'clearQueue']); + + $cmd->method('setAreaCode'); + $cmd->method('confirmOperation')->willReturn(true); + $cmd->method('getStoreIds')->willReturn([1, 2]); + + $msg = 'Clearing indexing queue for stores 1, 2'; + $cmd->method('decorateOperationAnnouncementMessage')->willReturn($msg); + + $cmd->expects($this->once()) + ->method('clearQueue') + ->with([1,2]); + + $input = new ArrayInput([]); + $output = $this->bufOut(); + + $code = $this->invokeExecute($cmd, $input, $output); + $this->assertSame(Cli::RETURN_SUCCESS, $code); + + $this->assertStringContainsString($msg, $output->fetch()); + } + + /** + * Test that the command returns failure when the clear operation fails + * + * @throws \ReflectionException + */ + public function testExecuteReturnsFailureOnClearException(): void + { + $cmd = $this->makePartial(['setAreaCode', 'confirmOperation', 'getStoreIds', 'decorateOperationAnnouncementMessage', 'clearQueue']); + + $cmd->method('setAreaCode'); + $cmd->method('confirmOperation')->willReturn(true); + $cmd->method('getStoreIds')->willReturn([]); + $cmd->method('decorateOperationAnnouncementMessage')->willReturn('Clearing indexing queue for all stores'); + + $errMsg = "Error encountered while attempting to clear queue."; + $cmd->method('clearQueue')->willThrowException(new \Exception($errMsg)); + + $input = new ArrayInput([]); + $output = $this->bufOut(); + + $code = $this->invokeExecute($cmd, $input, $output); + $this->assertSame(Cli::RETURN_FAILURE, $code); + $this->assertStringContainsString($errMsg, $output->fetch()); + } + + /** + * Test that the command calls the clearQueueForStore method for each store ID + * + * @throws \ReflectionException + */ + public function testClearQueueCallsPerStore(): void + { + $cmd = $this->makePartial(['clearQueueForStore', 'clearQueueForAllStores']); + + $expectedStoreIds = [1, 2]; + $callIndex = 0; + $cmd->expects($this->exactly(2)) + ->method('clearQueueForStore') + ->willReturnCallback(function($storeId) use (&$callIndex, $expectedStoreIds) { + $this->assertSame( + $expectedStoreIds[$callIndex], + $storeId, + "clearQueueForStore called with unexpected storeId at call $callIndex" + ); + $callIndex++; + }); + + $cmd->expects($this->never())->method('clearQueueForAllStores'); + + $this->invokeMethod($cmd, 'clearQueue', [[1, 2]]); + } + + /** + * Test that the command calls the clearQueueForAllStores method when no store IDs are provided + * + * @throws \ReflectionException + */ + public function testClearQueueEmptyCallsAllStores(): void + { + $cmd = $this->makePartial(['clearQueueForStore', 'clearQueueForAllStores']); + + $cmd->expects($this->never())->method('clearQueueForStore'); + $cmd->expects($this->once())->method('clearQueueForAllStores'); + + $this->invokeMethod($cmd, 'clearQueue'); + } + + /** + * Test that the command truncates the main table when no store IDs are provided + * + * @throws \ReflectionException + */ + public function testClearQueueForAllStoresTruncatesMainTable(): void + { + $adapter = $this->createMock(AdapterInterface::class); + + $this->jobResourceModel->method('getConnection')->willReturn($adapter); + $this->jobResourceModel->method('getMainTable')->willReturn('algoliasearch_queue'); + + $adapter->expects($this->once())->method('truncateTable')->with('algoliasearch_queue'); + + $cmd = $this->makePartial(); + $this->invokeMethod($cmd, 'clearQueue'); + } + + /** + * @throws \ReflectionException + */ + public function testClearQueueForStoreSuccess(): void + { + $cmd = $this->makePartial(['clearQueueTableForStore']); + $output = $this->bufOut(); + + // Inject output (protected on the parent); easiest is via reflection: + $this->setPrivateProperty($cmd, 'output', $output); + + $this->storeNameFetcher->method('getStoreName')->with(1)->willReturn('Default Store'); + $cmd->expects($this->once())->method('clearQueueTableForStore')->with(1); + + $this->invokeMethod($cmd, 'clearQueueForStore', [1]); + + $text = $output->fetch(); + $this->assertStringContainsString('Clearing indexing queue for Default Store', $text); + $this->assertStringContainsString('Indexing queue cleared for Default Store', $text); + } + + /** + * @throws \ReflectionException + * @throws NoSuchEntityException + */ + public function testClearQueueForStoreErrorPrinted(): void + { + $cmd = $this->makePartial(['clearQueueTableForStore']); + $output = $this->bufOut(); + $this->setPrivateProperty($cmd, 'output', $output); + + $this->storeNameFetcher->method('getStoreName')->with(1)->willReturn('Default Store'); + $errorMsg = 'DB operation failed'; + $cmd->method('clearQueueTableForStore')->willThrowException(new \Exception($errorMsg)); + + $this->invokeMethod($cmd, 'clearQueueForStore', [1]); + + $this->assertStringContainsString("Failed to clear indexing queue for Default Store: $errorMsg", $output->fetch()); + } + + /** + * @throws \ReflectionException + */ + public function testClearQueueForStoreJsonPathDeletesJobs(): void + { + $adapter = $this->getMockSearchAdapter(); + $output = $this->bufOut(); + + $cmd = $this->makePartial(); + + $this->setPrivateProperty($cmd, 'output', $output); + + $this->jobResourceModel->method('getConnection')->willReturn($adapter); + $this->jobResourceModel->method('getMainTable')->willReturn('algoliasearch_queue'); + + $adapter->expects($this->once()) + ->method('fetchCol') + ->willReturn([10, 11]); + + $adapter->expects($this->once()) + ->method('delete') + ->with('algoliasearch_queue', ['job_id IN (?)' => [10,11]]) + ->willReturn(2); + + $this->invokeMethod($cmd, 'clearQueueForStore', [5]); + + $this->assertStringContainsString('Deleted 2 jobs for store ID 5', $output->fetch()); + } + + /** + * @throws NoSuchEntityException + * @throws \ReflectionException + */ + public function testClearQueueForStoreJsonPathNoJobs(): void + { + $adapter = $this->getMockSearchAdapter(); + $output = $this->bufOut(); + + $cmd = $this->makePartial(); + + $this->setPrivateProperty($cmd, 'output', $output); + + $this->jobResourceModel->method('getConnection')->willReturn($adapter); + $this->jobResourceModel->method('getMainTable')->willReturn('algoliasearch_queue'); + + $adapter->method('fetchCol')->willReturn([]); + + $this->invokeMethod($cmd, 'clearQueueForStore', [5]); + + $this->assertStringContainsString('No jobs found for store ID 5', $output->fetch()); + } + + /** + * @throws NoSuchEntityException + * @throws \ReflectionException + */ + public function testClearQueueForStoreFallsBackWhenJsonThrows(): void + { + $cmd = $this->makePartial(['clearQueueTableForStoreFallback']); + $output = $this->bufOut(); + $this->setPrivateProperty($cmd, 'output', $output); + + $adapter = $this->getMockSearchAdapter(); + $this->jobResourceModel->method('getConnection')->willReturn($adapter); + + $adapter->method('select')->willThrowException(new \Exception('No JSON support')); + + $cmd->expects($this->once())->method('clearQueueTableForStoreFallback')->with(5); + + $this->invokeMethod($cmd, 'clearQueueForStore', [5]); + + $this->assertStringContainsString('JSON filtering not supported', $output->fetch()); + } + + /** + * @throws \ReflectionException + */ + public function testClearQueueForStoreFallbackDeletesMatching(): void + { + $cmd = $this->makePartial(); + $output = $this->bufOut(); + $this->setPrivateProperty($cmd, 'output', $output); + + $adapter = $this->getMockSearchAdapter(); + + $this->jobResourceModel->method('getConnection')->willReturn($adapter); + $this->jobResourceModel->method('getMainTable')->willReturn('algoliasearch_queue'); + + $adapter->method('fetchAll')->willReturn([ + ['job_id' => 1, 'data' => '{"storeId":5,"foo":1}'], + ['job_id' => 2, 'data' => '{"storeId":7}'], + ['job_id' => 3, 'data' => '{"storeId":5}'], + ]); + + $adapter->expects($this->once()) + ->method('delete') + ->with('algoliasearch_queue', ['job_id IN (?)' => [1,3]]) + ->willReturn(2); + + $this->invokeMethod($cmd, 'clearQueueTableForStoreFallback', [5]); + + $this->assertStringContainsString('Deleted 2 jobs for store ID 5 (fallback method)', $output->fetch()); + } + + /** + * @throws \ReflectionException + */ + public function testClearQueueForStoreFallbackNoMatch(): void + { + $cmd = $this->makePartial(); + $output = $this->bufOut(); + $this->setPrivateProperty($cmd, 'output', $output); + + $adapter = $this->getMockSearchAdapter(); + $this->jobResourceModel->method('getConnection')->willReturn($adapter); + + $adapter->method('fetchAll')->willReturn([ + ['job_id' => 1, 'data' => '{"storeId":8}'], + ]); + + $this->invokeMethod($cmd, 'clearQueueTableForStoreFallback', [5]); + + $this->assertStringContainsString('No jobs found for store ID 5 (fallback method)', $output->fetch()); + } + + public function testClearQueueForStoreFallbackThrowsWrapped(): void + { + $cmd = $this->makePartial(); + $adapter = $this->getMockSearchAdapter(); + $this->jobResourceModel->method('getConnection')->willReturn($adapter); + + $adapter->method('fetchAll')->willThrowException(new \Exception('db fail')); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Failed to clear queue for store 5: db fail'); + + $this->invokeMethod($cmd, 'clearQueueTableForStoreFallback', [5]); + } + + public function testMetadataStrings(): void + { + $cmd = $this->makePartial(); + $this->assertSame('clear', $this->invokeMethod($cmd, 'getCommandName')); + $this->assertStringContainsString('queue:', $this->invokeMethod($cmd, 'getCommandPrefix')); + $this->assertStringContainsString('Clear the indexing queue', $this->invokeMethod($cmd, 'getCommandDescription')); + $this->assertStringContainsString('algolia:queue:clear', $this->invokeMethod($cmd, 'getStoreArgumentDescription')); + } + + /** + * Create a partial mock of the ClearQueueCommand + * + * @param array $methodsToMock List of methods to mock + * @return ClearQueueCommand&MockObject + */ + private function makePartial(array $methodsToMock = []): ClearQueueCommand&MockObject + { + /** @var ClearQueueCommand&MockObject $cmd */ + $cmd = $this->getMockBuilder(ClearQueueCommand::class) + ->setConstructorArgs([ + $this->state, + $this->storeNameFetcher, + $this->storeManager, + $this->jobResourceModel, + null + ]) + ->onlyMethods($methodsToMock) + ->getMock(); + + return $cmd; + } + + private function getMockSearchAdapter(): AdapterInterface&MockObject + { + $adapter = $this->createMock(AdapterInterface::class); + $select = $this->createMock(Select::class); + + // Build chainable select + $adapter->method('select')->willReturn($select); + $select->method('from')->willReturnSelf(); + $select->method('where')->willReturnSelf(); + + return $adapter; + } + + private function bufOut(): BufferedOutput + { + // Verbosity NORMAL is fine; change if you want debug output + return new BufferedOutput(); + } + + /** + * @throws \ReflectionException + */ + private function invokeExecute(ClearQueueCommand $cmd, $input, $output): int + { + return $this->invokeMethod($cmd, 'execute', [$input, $output]); + } + +} diff --git a/Test/Unit/Helper/ConfigHelperTest.php b/Test/Unit/Helper/ConfigHelperTest.php index 3e51bacbe..c4032556d 100644 --- a/Test/Unit/Helper/ConfigHelperTest.php +++ b/Test/Unit/Helper/ConfigHelperTest.php @@ -2,6 +2,7 @@ namespace Algolia\AlgoliaSearch\Test\Unit\Helper; +use Algolia\AlgoliaSearch\Helper\Configuration\QueueHelper; use Algolia\AlgoliaSearch\Service\Serializer; use Algolia\AlgoliaSearch\Helper\Configuration\AutocompleteHelper; use Algolia\AlgoliaSearch\Helper\Configuration\InstantSearchHelper; @@ -16,8 +17,8 @@ use Magento\Framework\Event\ManagerInterface; use Magento\Framework\Locale\Currency; use Magento\Framework\Module\ResourceInterface; - use Magento\Store\Model\StoreManagerInterface; +use Magento\Weee\Helper\Data as WeeeHelper; use PHPUnit\Framework\TestCase; class ConfigHelperTest extends TestCase @@ -38,6 +39,9 @@ class ConfigHelperTest extends TestCase protected ?CookieHelper $cookieHelper; protected ?AutocompleteHelper $autocompleteHelper; protected ?InstantSearchHelper $instantSearchHelper; + protected ?QueueHelper $queueHelper; + + protected ?WeeeHelper $weeeHelper; protected function setUp(): void { @@ -56,6 +60,8 @@ protected function setUp(): void $this->cookieHelper = $this->createMock(CookieHelper::class); $this->autocompleteHelper = $this->createMock(AutocompleteHelper::class); $this->instantSearchHelper = $this->createMock(InstantSearchHelper::class); + $this->queueHelper = $this->createMock(QueueHelper::class); + $this->weeeHelper = $this->createMock(WeeeHelper::class); $this->configHelper = new ConfigHelperTestable( $this->configInterface, @@ -72,7 +78,9 @@ protected function setUp(): void $this->groupExcludedWebsiteRepository, $this->cookieHelper, $this->autocompleteHelper, - $this->instantSearchHelper + $this->instantSearchHelper, + $this->queueHelper, + $this->weeeHelper ); } diff --git a/Test/Unit/Helper/MathHelperTest.php b/Test/Unit/Helper/MathHelperTest.php new file mode 100644 index 000000000..ad0845ed1 --- /dev/null +++ b/Test/Unit/Helper/MathHelperTest.php @@ -0,0 +1,58 @@ +assertEquals($expectedResult, MathHelper::getAverage($values)); + } + + /** + * @dataProvider standardDeviationProvider + */ + public function testStandardDeviation($values, $expectedResult) + { + $this->assertEquals($expectedResult, MathHelper::getSampleStandardDeviation($values)); + } + + public static function averageProvider(): array + { + /** Tested with https://www.calculator.net/average-calculator.html */ + return [ + ['values' => [], 'expectedResult' => 0], + ['values' => [1, 3], 'expectedResult' => 2], + ['values' => ['foo' => 1, 'bar' => 3], 'expectedResult' => 2], + ['values' => [1, 9], 'expectedResult' => 5], + ['values' => [1, 2], 'expectedResult' => 1.5], + ['values' => [1, 2, 3], 'expectedResult' => 2], + ['values' => [1, 2, 4], 'expectedResult' => 2.33], + ['values' => [11253, 10025, 9521, 13250], 'expectedResult' => 11012.25], + ['values' => [10, 12, 23, 23, 16, 23, 21, 16], 'expectedResult' => 18], + ]; + } + + public static function standardDeviationProvider(): array + { + /** Tested with https://www.calculator.net/standard-deviation-calculator.html */ + return [ + ['values' => [], 'expectedResult' => 0.0], + ['values' => [1], 'expectedResult' => 0.0], + ['values' => [1, 1], 'expectedResult' => 0.0], + ['values' => [1, 3], 'expectedResult' => 1.41], + ['values' => [1, 4, 12], 'expectedResult' => 5.69], + ['values' => [3, 4, 6], 'expectedResult' => 1.53], + ['values' => [3, 4, 6, 8, 7, 11], 'expectedResult' => 2.88], + ['values' => [11253, 10025, 9521, 13250], 'expectedResult' => 1659.72], + ['values' => [10, 12, 23, 23, 16, 23, 21, 16], 'expectedResult' => 5.24], + ]; + } +} diff --git a/Test/Unit/Service/Product/BatchQueueProcessorTest.php b/Test/Unit/Service/Product/BatchQueueProcessorTest.php new file mode 100644 index 000000000..e1fb67d82 --- /dev/null +++ b/Test/Unit/Service/Product/BatchQueueProcessorTest.php @@ -0,0 +1,293 @@ +dataHelper = $this->createMock(Data::class); + $this->configHelper = $this->createMock(ConfigHelper::class); + $this->productHelper = $this->createMock(ProductHelper::class); + $this->queue = $this->createMock(Queue::class); + $this->diag = $this->createMock(DiagnosticsLogger::class); + $this->algoliaCredentialsManager = $this->createMock(AlgoliaCredentialsManager::class); + $this->indexBuilder = $this->createMock(IndexBuilder::class); + $this->indexCollectionSizeCache = $this->createMock(IndexCollectionSize::class); + $this->queueHelper = $this->createMock(QueueHelper::class); + + $this->processor = new BatchQueueProcessor( + $this->dataHelper, + $this->configHelper, + $this->productHelper, + $this->queueHelper, + $this->queue, + $this->diag, + $this->algoliaCredentialsManager, + $this->indexBuilder, + $this->indexCollectionSizeCache + ); + } + + /** + * @throws DiagnosticsException + * @throws NoSuchEntityException + */ + public function testProcessBatchSkipsWhenIndexingDisabled() + { + $this->dataHelper->method('isIndexingEnabled')->willReturn(false); + + $this->algoliaCredentialsManager->expects($this->never())->method('checkCredentialsWithSearchOnlyAPIKey'); + + $this->processor->processBatch(1); + } + + /** + * @throws DiagnosticsException + * @throws NoSuchEntityException + */ + public function testProcessBatchSkipsWhenCredentialsInvalid() + { + $this->dataHelper->method('isIndexingEnabled')->willReturn(true); + $this->algoliaCredentialsManager->method('checkCredentialsWithSearchOnlyAPIKey')->willReturn(false); + + $this->algoliaCredentialsManager->expects($this->once()) + ->method('displayErrorMessage') + ->with(BatchQueueProcessor::class, 1); + + $this->processor->processBatch(1); + } + + /** + * @throws NoSuchEntityException + * @throws DiagnosticsException + */ + public function testProcessBatchHandlesDeltaIndexing() + { + $this->setupBasicIndexingConfig(10); + $this->productHelper->method('getParentProductIds')->willReturn([]); + + $this->queue->expects($this->once()) + ->method('addToQueue') + ->with( + IndexBuilder::class, + 'buildIndexList', + $this->arrayHasKey('entityIds') + ); + + $this->processor->processBatch(1, range(1,5)); + } + + public function testProcessBatchHandlesDeltaIndexingPaged() + { + $pageSize = 10; + $this->setupBasicIndexingConfig($pageSize); + $this->productHelper->method('getParentProductIds')->willReturn([]); + + $invocationCount = 0; + $this->queue->expects($this->exactly(5)) + ->method('addToQueue') + ->with( + IndexBuilder::class, + 'buildIndexList', + $this->callback(function(array $arg) use (&$invocationCount, $pageSize) { + $invocationCount++; + return array_key_exists('storeId', $arg) + && array_key_exists('entityIds', $arg) + && array_key_exists('options', $arg) + && $arg['options']['pageSize'] === $pageSize + && $arg['options']['page'] === $invocationCount; + }) + ); + + $this->processor->processBatch(1, range(1,50)); + } + + /** + * @throws DiagnosticsException + * @throws NoSuchEntityException + */ + public function testProcessBatchHandlesFullIndexing() + { + $this->setupBasicIndexingConfig(10); + $this->configHelper->method('isQueueActive')->willReturn(false); + $this->indexCollectionSizeCache->expects($this->once())->method('get')->willReturn(10); + $this->productHelper->method('getProductCollectionQuery')->willReturn($this->getMockCollection()); + $this->queueHelper->method('useTmpIndex')->willReturn(false); + + $invocationCount = 0; + $this->queue->expects($this->exactly(2)) + ->method('addToQueue') + ->willReturnCallback( + function( + string $className, + string $method, + array $data, + int $dataSize, + bool $isFullReindex) + use (&$invocationCount) { + $invocationCount++; + switch ($invocationCount) { + case 1: + $this->assertEquals(IndicesConfigurator::class, $className); + $this->assertEquals('saveConfigurationToAlgolia', $method); + $this->assertArrayHasKey('storeId', $data); + break; + case 2: + $this->assertEquals(IndexBuilder::class, $className); + $this->assertEquals('buildIndexFull', $method); + $this->assertArrayHasKey('storeId', $data); + break; + } + } + ); + + $this->processor->processBatch(1); + } + + /** + * @throws DiagnosticsException + * @throws NoSuchEntityException + */ + public function testProcessBatchFullIndexingWithNoCache() + { + $this->setupBasicIndexingConfig(10); + $this->configHelper->method('isQueueActive')->willReturn(false); + $this->indexCollectionSizeCache->expects($this->once())->method('get')->willReturn(IndexCollectionSize::NOT_FOUND); + $this->productHelper->method('getProductCollectionQuery')->willReturn($this->getMockCollection(10, 1)); + $this->queueHelper->method('useTmpIndex')->willReturn(false); + + $this->queue->expects($this->exactly(2)) + ->method('addToQueue'); + + $this->processor->processBatch(1); + } + + /** + * @throws DiagnosticsException + * @throws NoSuchEntityException + */ + public function testProcessBatchHandlesFullIndexingPaged() + { + $pageSize = 10; + $this->setupBasicIndexingConfig($pageSize); + $this->configHelper->method('isQueueActive')->willReturn(false); + $this->indexCollectionSizeCache->expects($this->once())->method('get')->willReturn(50); + $this->productHelper->method('getProductCollectionQuery')->willReturn($this->getMockCollection()); + $this->queueHelper->method('useTmpIndex')->willReturn(false); + + $invocationCount = 0; + $this->queue->expects($this->exactly(6)) + ->method('addToQueue') + ->willReturnCallback( + function( + string $className, + string $method, + array $data, + int $dataSize, + bool $isFullReindex) + use (&$invocationCount, $pageSize) { + $invocationCount++; + switch ($invocationCount) { + case 1: + $this->assertEquals(IndicesConfigurator::class, $className); + $this->assertEquals('saveConfigurationToAlgolia', $method); + break; + default: + $this->assertEquals(IndexBuilder::class, $className); + $this->assertEquals('buildIndexFull', $method); + $this->assertArrayHasKey('options', $data); + $this->assertEquals($pageSize, $data['options']['pageSize']); + $this->assertEquals($invocationCount - 1, $data['options']['page']); + break; + } + } + ); + + $this->processor->processBatch(1); + } + + /** + * @throws DiagnosticsException + * @throws NoSuchEntityException + */ + public function testProcessBatchMovesTempIndexIfQueueActive() + { + $this->setupBasicIndexingConfig(10); + $this->configHelper->method('isQueueActive')->willReturn(true); + $this->indexCollectionSizeCache->expects($this->once())->method('get')->willReturn(10); + + $this->productHelper->method('getProductCollectionQuery')->willReturn($this->getMockCollection()); + $this->productHelper->method('getTempIndexName')->willReturn('tmp_index'); + $this->productHelper->method('getIndexName')->willReturn('main_index'); + + $this->queueHelper->method('useTmpIndex')->willReturn(true); + + $invocationCount = 0; + $this->queue->expects($this->exactly(3)) + ->method('addToQueue') + ->willReturnCallback( + function( + string $className, + string $method, + array $data, + int $dataSize, + bool $isFullReindex) + use (&$invocationCount) { + $invocationCount++; + if ($invocationCount === 3) { + $this->assertEquals(IndexMover::class, $className); + $this->assertEquals('moveIndexWithSetSettings', $method); + $this->assertArrayHasKey('tmpIndexName', $data); + $this->assertArrayHasKey('indexName', $data); + $this->assertArrayHasKey('storeId', $data); + } + } + ); + + $this->processor->processBatch(1); + } + + protected function setupBasicIndexingConfig(int $elementsPerPage): void + { + $this->dataHelper->method('isIndexingEnabled')->willReturn(true); + $this->algoliaCredentialsManager->method('checkCredentialsWithSearchOnlyAPIKey')->willReturn(true); + $this->configHelper->method('getNumberOfElementByPage')->willReturn($elementsPerPage); + $this->configHelper->method('includeNonVisibleProductsInIndex')->willReturn(false); + } + + protected function getMockCollection(int $size = 10, int $expectedSizeCalls = 0): Collection + { + $mockCollection = $this->createMock(Collection::class); + $mockCollection->expects($this->exactly($expectedSizeCalls))->method('getSize')->willReturn($size); + return $mockCollection; + } +} diff --git a/Ui/Component/Listing/Column/Data.php b/Ui/Component/Listing/Column/Data.php index 2466a1b96..f6ffcd4da 100644 --- a/Ui/Component/Listing/Column/Data.php +++ b/Ui/Component/Listing/Column/Data.php @@ -53,7 +53,9 @@ protected function formatData(array $data, $depth = 0): string continue; } - + if (is_bool($value)) { + $value = $value ? 'Yes' : 'No'; + } $formattedData .= str_repeat(' ', $depth ) . $stringKey . ' : ' . $value . '