diff --git a/Block/Algolia.php b/Block/Algolia.php index 2ee70c0ee..528626603 100755 --- a/Block/Algolia.php +++ b/Block/Algolia.php @@ -10,6 +10,7 @@ use Algolia\AlgoliaSearch\Helper\Entity\ProductHelper; use Algolia\AlgoliaSearch\Helper\Entity\SuggestionHelper; use Algolia\AlgoliaSearch\Helper\LandingPageHelper; +use Algolia\AlgoliaSearch\Model\LandingPage as LandingPageModel; use Algolia\AlgoliaSearch\Registry\CurrentCategory; use Algolia\AlgoliaSearch\Registry\CurrentProduct; use Algolia\AlgoliaSearch\Service\Product\SortingTransformer; @@ -215,7 +216,7 @@ protected function getAddToCartUrl($additional = []): string return $this->_urlBuilder->getUrl('checkout/cart/add', $routeParams); } - protected function getCurrentLandingPage(): LandingPage|null|false + protected function getCurrentLandingPage(): LandingPageModel|null|false { $landingPageId = $this->getRequest()->getParam('landing_page_id'); if (!$landingPageId) { diff --git a/CHANGELOG.md b/CHANGELOG.md index 57cb4edfc..79b24ce5f 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # CHANGE LOG +## 3.14.3 + +### Updates +- Updated PHP client version in the composer file +- Tests: added new integration scenarios including multi-stores ones. + +### Bug Fixes +- Fixed landing page typing error +- Improved query method for alternate root categories - Thank you @igorfigueiredogen +- Fixed an error where a missing prefix can throw errors with strong types added to ConfigHelper +- Fixed an error where stale cache data was preventing ranking applied on replica +- Fixed reindexing issue with NeuralSearch +- Tests : Fixed current integration tests + ## 3.14.2 ### Updates diff --git a/Console/Command/ReplicaSyncCommand.php b/Console/Command/ReplicaSyncCommand.php index edff1333a..dfc5b8ab3 100644 --- a/Console/Command/ReplicaSyncCommand.php +++ b/Console/Command/ReplicaSyncCommand.php @@ -11,7 +11,7 @@ use Algolia\AlgoliaSearch\Exceptions\ExceededRetriesException; use Algolia\AlgoliaSearch\Helper\Entity\ProductHelper; use Algolia\AlgoliaSearch\Service\StoreNameFetcher; -use Magento\Framework\App\State; +use Magento\Framework\App\State as AppState; use Magento\Framework\Console\Cli; use Magento\Framework\Exception\LocalizedException; use Magento\Framework\Exception\NoSuchEntityException; @@ -27,12 +27,12 @@ public function __construct( protected ReplicaManagerInterface $replicaManager, protected ProductHelper $productHelper, protected StoreManagerInterface $storeManager, - State $state, + AppState $appState, StoreNameFetcher $storeNameFetcher, ?string $name = null ) { - parent::__construct($state, $storeNameFetcher, $name); + parent::__construct($appState, $storeNameFetcher, $name); } protected function getReplicaCommandName(): string diff --git a/Helper/AlgoliaHelper.php b/Helper/AlgoliaHelper.php index 593bb96ff..52fb59fa3 100755 --- a/Helper/AlgoliaHelper.php +++ b/Helper/AlgoliaHelper.php @@ -6,6 +6,7 @@ use Algolia\AlgoliaSearch\Configuration\SearchConfig; use Algolia\AlgoliaSearch\Exceptions\AlgoliaException; use Algolia\AlgoliaSearch\Exceptions\ExceededRetriesException; +use Algolia\AlgoliaSearch\Model\Search\SearchRulesResponse; use Algolia\AlgoliaSearch\Response\AbstractResponse; use Algolia\AlgoliaSearch\Response\BatchIndexingResponse; use Algolia\AlgoliaSearch\Response\MultiResponse; @@ -299,7 +300,7 @@ public function moveIndex(string $fromIndexName, string $toIndexName): void 'destination' => $toIndexName ] ); - self::setLastOperationInfo($fromIndexName, $response); + self::setLastOperationInfo($toIndexName, $response); } /** @@ -360,6 +361,10 @@ public function mergeSettings($indexName, $settings, $mergeSettingsFrom = '') $removes = ['slaves', 'replicas', 'decompoundedAttributes']; + if (isset($onlineSettings['mode']) && $onlineSettings['mode'] == 'neuralSearch') { + $removes[] = 'mode'; + } + if (isset($settings['attributesToIndex'])) { $settings['searchableAttributes'] = $settings['attributesToIndex']; unset($settings['attributesToIndex']); @@ -453,6 +458,19 @@ public function saveRule(array $rule, string $indexName, bool $forwardToReplicas self::setLastOperationInfo($indexName, $res); } + /** + * @param string $indexName + * @param array $rules + * @param bool $forwardToReplicas + * @return void + */ + public function saveRules(string $indexName, array $rules, bool $forwardToReplicas = false): void + { + $res = $this->client->saveRules($indexName, $rules, $forwardToReplicas); + + self::setLastOperationInfo($indexName, $res); + } + /** * @param string $indexName @@ -521,6 +539,21 @@ public function copyQueryRules(string $fromIndexName, string $toIndexName): void self::setLastOperationInfo($fromIndexName, $response); } + /** + * @param string $indexName + * @param array|null $searchRulesParams + * + * @return SearchRulesResponse|mixed[] + * + * @throws AlgoliaException + */ + public function searchRules(string $indexName, array$searchRulesParams = null) + { + $this->checkClient(__FUNCTION__); + + return $this->client->searchRules($indexName, $searchRulesParams); + } + /** * @param $methodName * @return void diff --git a/Helper/ConfigHelper.php b/Helper/ConfigHelper.php index f13192d98..80ea53baf 100755 --- a/Helper/ConfigHelper.php +++ b/Helper/ConfigHelper.php @@ -446,7 +446,7 @@ public function getAutocompleteSections($storeId = null) } protected function serialize(array $value): string { - return $this->serializer->serialize($value); + return $this->serializer->serialize($value) ?: ''; } /** @@ -1157,7 +1157,7 @@ public function getRawSortingValue(?int $storeId = null): string * @param int|null $scopeId * @return void */ - public function setSorting(array $sorting, ?string $scope = null, ?int $scopeId = null): void + public function setSorting(array $sorting, string $scope = Magento\Framework\App\Config\ScopeConfigInterface::SCOPE_TYPE_DEFAULT, ?int $scopeId = null): void { $this->configWriter->save( self::SORTING_INDICES, @@ -1242,7 +1242,7 @@ public function getSearchOnlyAPIKey($storeId = null) */ public function getIndexPrefix(int $storeId = null): string { - return $this->configInterface->getValue(self::INDEX_PREFIX, ScopeInterface::SCOPE_STORE, $storeId); + return (string) $this->configInterface->getValue(self::INDEX_PREFIX, ScopeInterface::SCOPE_STORE, $storeId); } /** diff --git a/Helper/Entity/CategoryHelper.php b/Helper/Entity/CategoryHelper.php index e0b88854e..fc4b6463a 100755 --- a/Helper/Entity/CategoryHelper.php +++ b/Helper/Entity/CategoryHelper.php @@ -125,7 +125,7 @@ public function getCategoryCollectionQuery($storeId, $categoryIds = null) { /** @var \Magento\Store\Model\Store $store */ $store = $this->storeManager->getStore($storeId); - $storeRootCategoryPath = sprintf('%d/%d', $this->getRootCategoryId(), $store->getRootCategoryId()); + $storeRootCategoryPath = sprintf('%d/%d/', $this->getRootCategoryId(), $store->getRootCategoryId()); $unserializedCategorysAttrs = $this->getAdditionalAttributes($storeId); $additionalAttr = array_column($unserializedCategorysAttrs, 'attribute'); diff --git a/Helper/Entity/PageHelper.php b/Helper/Entity/PageHelper.php index a0a642bba..e70f11cdf 100755 --- a/Helper/Entity/PageHelper.php +++ b/Helper/Entity/PageHelper.php @@ -59,7 +59,7 @@ public function getPages($storeId, array $pageIds = null) $magentoPages->addFieldToFilter('page_id', ['in' => $pageIds]); } - $excludedPages = $this->getExcludedPageIds(); + $excludedPages = $this->getExcludedPageIds($storeId); if (count($excludedPages)) { $magentoPages->addFieldToFilter('identifier', ['nin' => $excludedPages]); } @@ -116,9 +116,9 @@ public function getPages($storeId, array $pageIds = null) return $pages; } - public function getExcludedPageIds() + public function getExcludedPageIds($storeId = null) { - $excludedPages = array_values($this->configHelper->getExcludedPages()); + $excludedPages = array_values($this->configHelper->getExcludedPages($storeId)); foreach ($excludedPages as &$excludedPage) { $excludedPage = $excludedPage['attribute']; } diff --git a/Helper/Entity/ProductHelper.php b/Helper/Entity/ProductHelper.php index 48bed478a..250d3cb63 100755 --- a/Helper/Entity/ProductHelper.php +++ b/Helper/Entity/ProductHelper.php @@ -359,9 +359,12 @@ public function setSettings(string $indexName, string $indexNameTmp, int $storeI $this->logger->log('Pushing the same settings to TMP index as well'); } - $this->setFacetsQueryRules($indexName); + $this->setFacetsQueryRules($indexName, $storeId); + $this->algoliaHelper->waitLastTask(); + if ($saveToTmpIndicesToo) { - $this->setFacetsQueryRules($indexNameTmp); + $this->setFacetsQueryRules($indexNameTmp, $storeId); + $this->algoliaHelper->waitLastTask(); } $this->replicaManager->syncReplicasToAlgolia($storeId, $indexSettings); @@ -1204,17 +1207,16 @@ protected function getAttributesForFaceting($storeId) /** * @param $indexName + * @param $storeId * @return void * @throws AlgoliaException */ - protected function setFacetsQueryRules($indexName) + protected function setFacetsQueryRules($indexName, $storeId = null) { - $client = $this->algoliaHelper->getClient(); - $this->clearFacetsQueryRules($indexName); $rules = []; - $facets = $this->configHelper->getFacets(); + $facets = $this->configHelper->getFacets($storeId); foreach ($facets as $facet) { if (!array_key_exists('create_rule', $facet) || $facet['create_rule'] !== '1') { continue; @@ -1245,7 +1247,7 @@ protected function setFacetsQueryRules($indexName) if ($rules) { $this->logger->log('Setting facets query rules to "' . $indexName . '" index: ' . json_encode($rules)); - $client->saveRules($indexName, $rules, true); + $this->algoliaHelper->saveRules($indexName, $rules, true); } } @@ -1260,8 +1262,7 @@ protected function clearFacetsQueryRules($indexName): void $hitsPerPage = 100; $page = 0; do { - $client = $this->algoliaHelper->getClient(); - $fetchedQueryRules = $client->searchRules($indexName, [ + $fetchedQueryRules = $this->algoliaHelper->searchRules($indexName, [ 'context' => 'magento_filters', 'page' => $page, 'hitsPerPage' => $hitsPerPage, @@ -1273,7 +1274,7 @@ protected function clearFacetsQueryRules($indexName): void } foreach ($fetchedQueryRules['hits'] as $hit) { - $client->deleteRule($indexName, $hit[AlgoliaHelper::ALGOLIA_API_OBJECT_ID], true); + $this->algoliaHelper->deleteRule($indexName, $hit[AlgoliaHelper::ALGOLIA_API_OBJECT_ID], true); } $page++; diff --git a/Helper/LandingPageHelper.php b/Helper/LandingPageHelper.php index e26810c37..954298e85 100644 --- a/Helper/LandingPageHelper.php +++ b/Helper/LandingPageHelper.php @@ -59,7 +59,7 @@ public function __construct( parent::__construct($context); } - public function getLandingPage($pageId) + public function getLandingPage($pageId): LandingPage|null|false { if ($pageId !== null && $pageId !== $this->landingPage->getId()) { $this->landingPage->setStoreId($this->storeManager->getStore()->getId()); diff --git a/Helper/Logger.php b/Helper/Logger.php index a50b9a739..e639f8cc2 100755 --- a/Helper/Logger.php +++ b/Helper/Logger.php @@ -35,7 +35,7 @@ public function isEnable() public function getStoreName($storeId) { - if ($storeId === null) { + if ($storeId === null || !isset($this->stores[$storeId])) { return 'undefined store'; } diff --git a/Model/IndicesConfigurator.php b/Model/IndicesConfigurator.php index 8d30aeb90..8179adfca 100644 --- a/Model/IndicesConfigurator.php +++ b/Model/IndicesConfigurator.php @@ -104,9 +104,12 @@ public function saveConfigurationToAlgolia(int $storeId, bool $useTmpIndex = fal } $this->setCategoriesSettings($storeId); + $this->algoliaHelper->waitLastTask(); + /* heck if we want to index CMS pages */ if ($this->configHelper->isPagesIndexEnabled($storeId)) { $this->setPagesSettings($storeId); + $this->algoliaHelper->waitLastTask(); } else { $this->logger->log('CMS Page Indexing is not enabled for the store.'); } @@ -114,14 +117,19 @@ public function saveConfigurationToAlgolia(int $storeId, bool $useTmpIndex = fal //Check if we want to index Query Suggestions if ($this->configHelper->isQuerySuggestionsIndexEnabled($storeId)) { $this->setQuerySuggestionsSettings($storeId); + $this->algoliaHelper->waitLastTask(); } else { $this->logger->log('Query Suggestions Indexing is not enabled for the store.'); } $this->setAdditionalSectionsSettings($storeId); + $this->algoliaHelper->waitLastTask(); + $this->setProductsSettings($storeId, $useTmpIndex); + $this->algoliaHelper->waitLastTask(); $this->setExtraSettings($storeId, $useTmpIndex); + $this->algoliaHelper->waitLastTask(); } /** diff --git a/README.md b/README.md index 141da2392..ea899c708 100755 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ Algolia Search & Discovery extension for Magento 2 ================================================== -![Latest version](https://img.shields.io/badge/latest-3.14.2-green) +![Latest version](https://img.shields.io/badge/latest-3.14.3-green) ![Magento 2](https://img.shields.io/badge/Magento-2.4.x-orange) ![PHP](https://img.shields.io/badge/PHP-8.1%2C8.2%2C8.3-blue) diff --git a/Service/Product/ReplicaManager.php b/Service/Product/ReplicaManager.php index 0efa73433..9191383b0 100644 --- a/Service/Product/ReplicaManager.php +++ b/Service/Product/ReplicaManager.php @@ -133,12 +133,14 @@ protected function clearAlgoliaReplicaSettingCache($primaryIndexName = null): vo * relevant to the Magento integration * * @param string $primaryIndexName + * @param bool $refreshCache * @return string[] Array of replica index names * @throws LocalizedException + * @throws NoSuchEntityException */ - protected function getMagentoReplicaConfigurationFromAlgolia(string $primaryIndexName): array + protected function getMagentoReplicaConfigurationFromAlgolia(string $primaryIndexName, bool $refreshCache = false): array { - $algoliaReplicas = $this->getReplicaConfigurationFromAlgolia($primaryIndexName); + $algoliaReplicas = $this->getReplicaConfigurationFromAlgolia($primaryIndexName, $refreshCache); $magentoReplicas = $this->getMagentoReplicaSettings($primaryIndexName, $algoliaReplicas); return array_values(array_intersect($magentoReplicas, $algoliaReplicas)); } @@ -241,7 +243,7 @@ protected function setReplicasOnPrimaryIndex(int $storeId): array $indexName = $this->indexNameFetcher->getProductIndexName($storeId); $sortingIndices = $this->sortingTransformer->getSortingIndices($storeId); $newMagentoReplicasSetting = $this->sortingTransformer->transformSortingIndicesToReplicaSetting($sortingIndices); - $oldMagentoReplicasSetting = $this->getMagentoReplicaConfigurationFromAlgolia($indexName); + $oldMagentoReplicasSetting = $this->getMagentoReplicaConfigurationFromAlgolia($indexName, true); $nonMagentoReplicasSetting = $this->getNonMagentoReplicaConfigurationFromAlgolia($indexName); $oldMagentoReplicaIndices = $this->getBareIndexNamesFromReplicaSetting($oldMagentoReplicasSetting); $newMagentoReplicaIndices = $this->getBareIndexNamesFromReplicaSetting($newMagentoReplicasSetting); diff --git a/Setup/Patch/Schema/ConfigPatch.php b/Setup/Patch/Schema/ConfigPatch.php index a28b9681d..6450af8f7 100644 --- a/Setup/Patch/Schema/ConfigPatch.php +++ b/Setup/Patch/Schema/ConfigPatch.php @@ -112,7 +112,7 @@ class ConfigPatch implements SchemaPatchInterface ], ], - 'algoliasearch_instant/instant/facets' => [ + 'algoliasearch_instant/instant_facets/facets' => [ [ 'attribute' => 'price', 'type' => 'slider', @@ -135,7 +135,7 @@ class ConfigPatch implements SchemaPatchInterface 'create_rule' => '2', ], ], - 'algoliasearch_instant/instant/sorts' => [ + 'algoliasearch_instant/instant_sorts/sorts' => [ [ 'attribute' => 'price', 'sort' => 'asc', diff --git a/Test/Integration/AssertValues/Magento23.php b/Test/Integration/AssertValues/Magento23.php deleted file mode 100644 index 1f262575f..000000000 --- a/Test/Integration/AssertValues/Magento23.php +++ /dev/null @@ -1,15 +0,0 @@ -categoriesIndexer = $this->objectManager->get(Category::class); + $this->categoryRepository = $this->objectManager->get(CategoryRepositoryInterface::class); + $this->categoryCollectionFactory = $this->objectManager->get(CollectionFactory::class); + + + $this->categoriesIndexer->executeFull(); + $this->algoliaHelper->waitLastTask(); + } + + /** + * @throws CouldNotSaveException + * @throws ExceededRetriesException + * @throws AlgoliaException + * @throws NoSuchEntityException + */ + public function testMultiStoreCategoryIndices() + { + // Check that every store has the right number of categories + foreach ($this->storeManager->getStores() as $store) { + $this->assertNbOfRecordsPerStore($store->getCode(), 'categories', $this->assertValues->expectedCategory); + } + + $defaultStore = $this->storeRepository->get('default'); + $fixtureSecondStore = $this->storeRepository->get('fixture_second_store'); + + $bagsCategory = $this->loadCategory(self::BAGS_CATEGORY_ID, $defaultStore->getId()); + + $this->assertEquals(self::BAGS_CATEGORY_NAME, $bagsCategory->getName()); + + // Change a category name at store level + $bagsCategoryAlt = $this->updateCategory( + self::BAGS_CATEGORY_ID, + $fixtureSecondStore->getId(), + ['name' => self::BAGS_CATEGORY_NAME_ALT] + ); + + $this->assertEquals(self::BAGS_CATEGORY_NAME, $bagsCategory->getName()); + $this->assertEquals(self::BAGS_CATEGORY_NAME_ALT, $bagsCategoryAlt->getName()); + + $this->categoriesIndexer->execute([self::BAGS_CATEGORY_ID]); + $this->algoliaHelper->waitLastTask(); + + $this->assertAlgoliaRecordValues( + $this->indexPrefix . 'default_categories', + (string) self::BAGS_CATEGORY_ID, + ['name' => self::BAGS_CATEGORY_NAME] + ); + + $this->assertAlgoliaRecordValues( + $this->indexPrefix . 'fixture_second_store_categories', + (string) self::BAGS_CATEGORY_ID, + ['name' => self::BAGS_CATEGORY_NAME_ALT] + ); + + // Disable this category at store level + $bagsCategoryAlt = $this->updateCategory( + self::BAGS_CATEGORY_ID, + $fixtureSecondStore->getId(), + ['is_active' => 0] + ); + + $this->categoriesIndexer->execute([self::BAGS_CATEGORY_ID]); + $this->algoliaHelper->waitLastTask(); + + $this->assertNbOfRecordsPerStore( + $defaultStore->getCode(), + 'categories', + $this->assertValues->expectedCategory + ); + + $this->assertNbOfRecordsPerStore( + $fixtureSecondStore->getCode(), + 'categories', + $this->assertValues->expectedCategory - 1 + ); + } + + /** + * Loads category by name. + * + * @param int $categoryId + * @param int $storeId + * + * @return CategoryInterface + * @throws NoSuchEntityException + */ + private function loadCategory(int $categoryId, int $storeId): CategoryInterface + { + return $this->categoryRepository->get($categoryId, $storeId); + } + + /** + * @param int $categoryId + * @param int $storeId + * @param array $values + * + * @return CategoryInterface + * @throws CouldNotSaveException + * @throws NoSuchEntityException + * + * @see Magento\Catalog\Block\Product\ListProduct\SortingTest + */ + private function updateCategory(int $categoryId, int $storeId, array $values): CategoryInterface + { + $oldStoreId = $this->storeManager->getStore()->getId(); + $this->storeManager->setCurrentStore($storeId); + $category = $this->loadCategory($categoryId, $storeId); + foreach ($values as $attribute => $value) { + $category->setData($attribute, $value); + } + $categoryAlt = $this->categoryRepository->save($category); + $this->storeManager->setCurrentStore($oldStoreId); + + return $categoryAlt; + } + + protected function tearDown(): void + { + $defaultStore = $this->storeRepository->get('default'); + + // Restore category name in case DB is not cleaned up + $this->updateCategory( + self::BAGS_CATEGORY_ID, + $defaultStore->getId(), + [ + 'name' => self::BAGS_CATEGORY_NAME, + 'is_active' => 1 + ] + ); + + parent::tearDown(); + } +} diff --git a/Test/Integration/ConfigTest.php b/Test/Integration/Config/ConfigTest.php similarity index 96% rename from Test/Integration/ConfigTest.php rename to Test/Integration/Config/ConfigTest.php index c68762ec2..0fa769f42 100644 --- a/Test/Integration/ConfigTest.php +++ b/Test/Integration/Config/ConfigTest.php @@ -1,9 +1,10 @@ setConfig('algoliasearch_instant/instant/facets', $this->getSerializer()->serialize($facets)); + $this->setConfig('algoliasearch_instant/instant_facets/facets', $this->getSerializer()->serialize($facets)); // Set don't replace category pages with Algolia - categories attribute shouldn't be included in facets $this->setConfig('algoliasearch_instant/instant/replace_categories', '0'); @@ -179,7 +180,7 @@ private function replicaCreationTest($withCustomerGroups = false) ]; $this->setConfig('algoliasearch_instant/instant/is_instant_enabled', '1'); // Needed to set replicas to Algolia - $this->setConfig('algoliasearch_instant/instant/sorts', $this->getSerializer()->serialize($sortingIndicesData)); + $this->setConfig('algoliasearch_instant/instant_sorts/sorts', $this->getSerializer()->serialize($sortingIndicesData)); $this->setConfig('algoliasearch_advanced/advanced/customer_groups_enable', $enableCustomGroups); $sortingIndicesWithRankingWhichShouldBeCreated = [ @@ -221,13 +222,11 @@ public function testExtraSettings() $indexName = $this->indexPrefix . 'default_' . $section; $this->algoliaHelper->setSettings($indexName, ['exactOnSingleWordQuery' => 'attribute']); + $this->algoliaHelper->waitLastTask(); } - $this->algoliaHelper->waitLastTask(); - foreach ($sections as $section) { $indexName = $this->indexPrefix . 'default_' . $section; - $currentSettings = $this->algoliaHelper->getSettings($indexName); $this->assertArrayHasKey('exactOnSingleWordQuery', $currentSettings); diff --git a/Test/Integration/Config/MultiStoreConfigTest.php b/Test/Integration/Config/MultiStoreConfigTest.php new file mode 100644 index 000000000..76e99147c --- /dev/null +++ b/Test/Integration/Config/MultiStoreConfigTest.php @@ -0,0 +1,119 @@ +storeManager->getWebsites(); + $stores = $this->storeManager->getStores(); + + // Check that stores and websites are properly created + $this->assertEquals(2, count($websites)); + $this->assertEquals(3, count($stores)); + + foreach ($stores as $store) { + $this->setupStore($store, true); + } + + $indicesCreatedByTest = 0; + $indices = $this->algoliaHelper->listIndexes(); + + foreach ($indices['items'] as $index) { + $name = $index['name']; + + if (mb_strpos($name, $this->indexPrefix) === 0) { + $indicesCreatedByTest++; + } + } + + // Check that the configuration created the appropriate number of indices (7 (4 mains + 3 replicas per store => 3*7=21) + $this->assertEquals(21, $indicesCreatedByTest); + + $defaultStore = $this->storeRepository->get('default'); + $fixtureSecondStore = $this->storeRepository->get('fixture_second_store'); + + // Change category configuration at store level (attributes and ranking) + $attributesFromConfig = $this->configHelper->getCategoryAdditionalAttributes($defaultStore->getId()); + $attributesFromConfigAlt = $attributesFromConfig; + $attributesFromConfigAlt[] = [ + "attribute" => self::ADDITIONAL_ATTRIBUTE, + "searchable" => "1", + "order" => "unordered", + "retrievable" => "1", + ]; + + $this->setConfig( + ConfigHelper::CATEGORY_ATTRIBUTES, + json_encode($attributesFromConfigAlt), + $fixtureSecondStore->getCode()) + ; + + $rankingsFromConfig = $this->configHelper->getCategoryCustomRanking($defaultStore->getId()); + $rankingsFromConfigAlt = $rankingsFromConfig; + $rankingsFromConfigAlt[] = [ + "attribute" => self::ADDITIONAL_ATTRIBUTE, + "order" => "desc", + ]; + + $this->setConfig( + ConfigHelper::CATEGORY_CUSTOM_RANKING, + json_encode($rankingsFromConfigAlt), + $fixtureSecondStore->getCode()) + ; + + // Query rules check (activate one QR on the fixture store) + $facetsFromConfig = $this->configHelper->getFacets($defaultStore->getId()); + $facetsFromConfigAlt = $facetsFromConfig; + foreach ($facetsFromConfigAlt as $key => $facet) { + if ($facet['attribute'] === "color") { + $facetsFromConfigAlt[$key]['create_rule'] = "1"; + break; + } + } + + $this->setConfig( + ConfigHelper::FACETS, + json_encode($facetsFromConfigAlt), + $fixtureSecondStore->getCode() + ); + + $this->indicesConfigurator->saveConfigurationToAlgolia($fixtureSecondStore->getId()); + $this->algoliaHelper->waitLastTask(); + + $defaultCategoryIndexSettings = $this->algoliaHelper->getSettings($this->indexPrefix . 'default_categories'); + $fixtureCategoryIndexSettings = $this->algoliaHelper->getSettings($this->indexPrefix . 'fixture_second_store_categories'); + + $attributeFromConfig = 'unordered(' . self::ADDITIONAL_ATTRIBUTE . ')'; + $this->assertNotContains($attributeFromConfig, $defaultCategoryIndexSettings['searchableAttributes']); + $this->assertContains($attributeFromConfig, $fixtureCategoryIndexSettings['searchableAttributes']); + + $rankingFromConfig = 'desc(' . self::ADDITIONAL_ATTRIBUTE . ')'; + $this->assertNotContains($rankingFromConfig, $defaultCategoryIndexSettings['customRanking']); + $this->assertContains($rankingFromConfig, $fixtureCategoryIndexSettings['customRanking']); + + $defaultProductIndexRules = $this->algoliaHelper->searchRules($this->indexPrefix . 'default_products'); + $fixtureProductIndexRules = $this->algoliaHelper->searchRules($this->indexPrefix . 'fixture_second_store_products'); + + // Check that the Rule has only been created for the fixture store + $this->assertEquals(0, $defaultProductIndexRules['nbHits']); + $this->assertEquals(1, $fixtureProductIndexRules['nbHits']); + } + + protected function tearDown(): void + { + parent::tearDown(); + + $this->setConfig(ConfigHelper::IS_INSTANT_ENABLED, 0); + } +} diff --git a/Test/Integration/IndexingTestCase.php b/Test/Integration/IndexingTestCase.php index 69db4f641..d26e9a1c8 100644 --- a/Test/Integration/IndexingTestCase.php +++ b/Test/Integration/IndexingTestCase.php @@ -2,11 +2,12 @@ namespace Algolia\AlgoliaSearch\Test\Integration; +use Algolia\AlgoliaSearch\Exceptions\AlgoliaException; use Magento\Framework\Indexer\ActionInterface; abstract class IndexingTestCase extends TestCase { - public function setUp(): void + protected function setUp(): void { parent::setUp(); @@ -25,4 +26,24 @@ protected function processTest(ActionInterface $indexer, $indexSuffix, $expected $this->assertEquals($expectedNbHits, $resultsDefault['results'][0]['nbHits']); } + + /** + * @param string $indexName + * @param string $recordId + * @param array $expectedValues + * + * @return void + * @throws AlgoliaException + */ + public function assertAlgoliaRecordValues( + string $indexName, + string $recordId, + array $expectedValues + ) : void { + $res = $this->algoliaHelper->getObjects($indexName, [$recordId]); + $record = reset($res['results']); + foreach ($expectedValues as $attribute => $expectedValue) { + $this->assertEquals($expectedValue, $record[$attribute]); + } + } } diff --git a/Test/Integration/MultiStoreTestCase.php b/Test/Integration/MultiStoreTestCase.php new file mode 100644 index 000000000..25ea0f71b --- /dev/null +++ b/Test/Integration/MultiStoreTestCase.php @@ -0,0 +1,97 @@ +storeManager = $this->objectManager->get(StoreManager::class); + + /** @var IndicesConfigurator $indicesConfigurator */ + $this->indicesConfigurator = $this->objectManager->get(IndicesConfigurator::class); + + /** @var StoreRepositoryInterface $storeRepository */ + $this->storeRepository = $this->objectManager->get(StoreRepositoryInterface::class); + + foreach ($this->storeManager->getStores() as $store) { + $this->setupStore($store); + } + } + + /** + * @param string $storeCode + * @param string $entity + * @param int $expectedNumber + * + * @return void + * @throws AlgoliaException + */ + protected function assertNbOfRecordsPerStore(string $storeCode, string $entity, int $expectedNumber): void + { + $resultsDefault = $this->algoliaHelper->query($this->indexPrefix . $storeCode . '_' . $entity, '', []); + + $this->assertEquals($expectedNumber, $resultsDefault['results'][0]['nbHits']); + } + + /** + * @param StoreInterface $store + * @param bool $enableInstantSearch + * + * @return void + * @throws AlgoliaException + * @throws LocalizedException + * @throws NoSuchEntityException + */ + protected function setupStore(StoreInterface $store, bool $enableInstantSearch = false): void + { + $this->setConfig( + 'algoliasearch_credentials/credentials/application_id', + getenv('ALGOLIA_APPLICATION_ID'), + $store->getCode() + ); + $this->setConfig( + 'algoliasearch_credentials/credentials/search_only_api_key', + getenv('ALGOLIA_SEARCH_KEY_1') ?: getenv('ALGOLIA_SEARCH_API_KEY'), + $store->getCode() + ); + $this->setConfig( + 'algoliasearch_credentials/credentials/api_key', + getenv('ALGOLIA_API_KEY'), + $store->getCode() + ); + $this->setConfig( + 'algoliasearch_credentials/credentials/index_prefix', + $this->indexPrefix, + $store->getCode() + ); + + if ($enableInstantSearch) { + $this->setConfig(ConfigHelper::IS_INSTANT_ENABLED, 1, $store->getCode()); + } + + $this->indicesConfigurator->saveConfigurationToAlgolia($store->getId()); + $this->algoliaHelper->waitLastTask(); + } +} diff --git a/Test/Integration/Page/MultiStorePagesTest.php b/Test/Integration/Page/MultiStorePagesTest.php new file mode 100644 index 000000000..84cb1c5a6 --- /dev/null +++ b/Test/Integration/Page/MultiStorePagesTest.php @@ -0,0 +1,148 @@ +pagesIndexer = $this->objectManager->get(Page::class); + $this->pageRepository = $this->objectManager->get(PageRepositoryInterface::class); + $this->pageCollectionFactory = $this->objectManager->get(CollectionFactory::class); + + $this->pagesIndexer->executeFull(); + $this->algoliaHelper->waitLastTask(); + } + + /*** + * @magentoDbIsolation disabled + * + * @throws ExceededRetriesException + * @throws AlgoliaException + */ + public function testMultiStorePageIndices() + { + // Check that every store has the right number of pages + foreach ($this->storeManager->getStores() as $store) { + $this->assertNbOfRecordsPerStore( + $store->getCode(), + 'pages', + $store->getCode() === 'fixture_second_store' ? // we excluded 2 pages on setupStore() + $this->assertValues->expectedExcludePages : + $this->assertValues->expectedPages + ); + } + + $defaultStore = $this->storeRepository->get('default'); + $fixtureSecondStore = $this->storeRepository->get('fixture_second_store'); + + try { + $aboutUsPage = $this->loadPage(self::ABOUT_US_PAGE_ID); + } catch (\Exception $e) { + $this->markTestIncomplete('Page could not be found.'); + } + + // Setting the page only for default store + $aboutUsPage->setStores([$defaultStore->getId()]); + $this->pageRepository->save($aboutUsPage); + + $this->pagesIndexer->execute([self::ABOUT_US_PAGE_ID]); + $this->algoliaHelper->waitLastTask(); + + $this->assertNbOfRecordsPerStore( + $defaultStore->getCode(), + 'pages', + $this->assertValues->expectedPages + ); + + $this->assertNbOfRecordsPerStore( + $fixtureSecondStore->getCode(), + 'pages', + $this->assertValues->expectedExcludePages - 1 + ); + } + + /** + * Loads page by id. + * + * @param int $pageId + * + * @return PageInterface + * @throws LocalizedException + */ + private function loadPage(int $pageId): PageInterface + { + return $this->pageRepository->getById($pageId); + } + + protected function resetPage(PageInterface $page): void + { + $page->setStores([0]); + $this->pageRepository->save($page); + } + + /** + * @param StoreInterface $store + * @param bool $enableInstantSearch + * + * @return void + * @throws AlgoliaException + * @throws LocalizedException + * @throws NoSuchEntityException + */ + protected function setupStore(StoreInterface $store, bool $enableInstantSearch = false): void + { + // Exclude 2 pages on second store + $excludedPages = $store->getCode() === 'fixture_second_store' ? + [['attribute' => 'no-route'], ['attribute' => 'home']]: + []; + + $this->setConfig( + ConfigHelper::EXCLUDED_PAGES, + $this->getSerializer()->serialize($excludedPages), + $store->getCode() + ); + + parent::setupStore($store, $enableInstantSearch); + } + + public function tearDown(): void + { + // Restore page in case DB is not cleaned up + $aboutUsPage = $this->loadPage(self::ABOUT_US_PAGE_ID); + $this->resetPage($aboutUsPage); + + parent::tearDown(); + } +} diff --git a/Test/Integration/PagesIndexingTest.php b/Test/Integration/Page/PagesIndexingTest.php similarity index 97% rename from Test/Integration/PagesIndexingTest.php rename to Test/Integration/Page/PagesIndexingTest.php index 85b366503..97b1351e9 100644 --- a/Test/Integration/PagesIndexingTest.php +++ b/Test/Integration/Page/PagesIndexingTest.php @@ -1,9 +1,10 @@ productsIndexer = $this->objectManager->get(Product::class); + $this->productRepository = $this->objectManager->get(ProductRepositoryInterface::class); + $this->productCollectionFactory = $this->objectManager->get(CollectionFactory::class); + $this->websiteRepository = $this->objectManager->get(WebsiteRepositoryInterface::class); + + $this->indexerRegistry = $this->objectManager->get(IndexerRegistry::class); + $this->productPriceIndexer = $this->indexerRegistry->get('catalog_product_price'); + $this->productPriceIndexer->reindexAll(); + + $this->productsIndexer->executeFull(); + $this->algoliaHelper->waitLastTask(); + } + + public function testMultiStoreProductIndices() + { + // Check that every store has the right number of products + foreach ($this->storeManager->getStores() as $store) { + $this->assertNbOfRecordsPerStore( + $store->getCode(), + 'products', + $store->getCode() === 'default' ? + $this->assertValues->productsCountWithoutGiftcards : + count(self::SKUS) + ); + } + + $defaultStore = $this->storeRepository->get('default'); + $fixtureSecondStore = $this->storeRepository->get('fixture_second_store'); + $fixtureThirdStore = $this->storeRepository->get('fixture_third_store'); + + try { + $voyageYogaBag = $this->loadProduct(self::VOYAGE_YOGA_BAG_ID, $defaultStore->getId()); + } catch (\Exception $e) { + $this->markTestIncomplete('Product could not be found.'); + } + + $this->assertEquals(self::VOYAGE_YOGA_BAG_NAME, $voyageYogaBag->getName()); + + // Change a product name at store level + $voyageYogaBagAlt = $this->updateProduct( + self::VOYAGE_YOGA_BAG_ID, + $fixtureSecondStore->getId(), + ['name' => self::VOYAGE_YOGA_BAG_NAME_ALT] + ); + + $this->assertEquals(self::VOYAGE_YOGA_BAG_NAME, $voyageYogaBag->getName()); + $this->assertEquals(self::VOYAGE_YOGA_BAG_NAME_ALT, $voyageYogaBagAlt->getName()); + + $this->productsIndexer->execute([self::VOYAGE_YOGA_BAG_ID]); + $this->algoliaHelper->waitLastTask(); + + $this->assertAlgoliaRecordValues( + $this->indexPrefix . 'default_products', + (string) self::VOYAGE_YOGA_BAG_ID, + ['name' => self::VOYAGE_YOGA_BAG_NAME] + ); + + $this->assertAlgoliaRecordValues( + $this->indexPrefix . 'fixture_second_store_products', + (string) self::VOYAGE_YOGA_BAG_ID, + ['name' => self::VOYAGE_YOGA_BAG_NAME_ALT] + ); + + // Unassign product from a single website (removed from test website (second and third store)) + $baseWebsite = $this->websiteRepository->get('base'); + + $voyageYogaBag = $this->loadProduct(self::VOYAGE_YOGA_BAG_ID); + + $voyageYogaBag->setWebsiteIds([$baseWebsite->getId()]); + $this->productRepository->save($voyageYogaBag); + $this->productPriceIndexer->reindexRow(self::VOYAGE_YOGA_BAG_ID); + + $this->productsIndexer->execute([self::VOYAGE_YOGA_BAG_ID]); + $this->algoliaHelper->waitLastTask(); + + // default store should have the same number of products + $this->assertNbOfRecordsPerStore( + $defaultStore->getCode(), + 'products', + $this->assertValues->productsCountWithoutGiftcards + ); + + // Stores from test website must have one less product + $this->assertNbOfRecordsPerStore( + $fixtureThirdStore->getCode(), + 'products', + count(self::SKUS) - 1 + ); + + $this->assertNbOfRecordsPerStore( + $fixtureSecondStore->getCode(), + 'products', + count(self::SKUS) - 1 + ); + } + + /** + * Loads product by id. + * + * @param int $productId + * @param int|null $storeId + * + * @return ProductInterface + * @throws NoSuchEntityException + */ + private function loadProduct(int $productId, int $storeId = null): ProductInterface + { + return $this->productRepository->getById($productId, true, $storeId); + } + + /** + * @param int $productId + * @param int $storeId + * @param array $values + * + * @return ProductInterface + * @throws CouldNotSaveException + * @throws NoSuchEntityException + * + */ + private function updateProduct(int $productId, int $storeId, array $values): ProductInterface + { + $oldStoreId = $this->storeManager->getStore()->getId(); + $this->storeManager->setCurrentStore($storeId); + $product = $this->loadProduct($productId, $storeId); + foreach ($values as $attribute => $value) { + $product->setData($attribute, $value); + } + $productAlt = $this->productRepository->save($product); + $this->storeManager->setCurrentStore($oldStoreId); + + return $productAlt; + } + + protected function tearDown(): void + { + $defaultStore = $this->storeRepository->get('default'); + + // Restore product name in case DB is not cleaned up + $this->updateProduct( + self::VOYAGE_YOGA_BAG_ID, + $defaultStore->getId(), + [ + 'name' => self::VOYAGE_YOGA_BAG_NAME, + ] + ); + + parent::tearDown(); + } +} diff --git a/Test/Integration/Product/PricingTest.php b/Test/Integration/Product/PricingTest.php new file mode 100644 index 000000000..2b0142aeb --- /dev/null +++ b/Test/Integration/Product/PricingTest.php @@ -0,0 +1,209 @@ + + */ + protected const ASSERT_PRODUCT_PRICES = [ + self::PRODUCT_ID_SIMPLE_STANDARD_PRICE => 34, + self::PRODUCT_ID_CONFIGURABLE_STANDARD_PRICE => 52, + self::PRODUCT_ID_CONFIGURABLE_CATALOG_PRICE_RULE => 39.2 + ]; + + protected ?string $indexName = null; + + protected function setUp(): void + { + parent::setUp(); + + $this->indexSuffix = 'products'; + $this->indexName = $this->getIndexName('default'); + } + + /** + * @param int|int[] $productIds + * @return void + * @throws NoSuchEntityException + * @throws AlgoliaException + * @throws ExceededRetriesException + */ + protected function indexProducts(int|array $productIds): void + { + if (!is_array($productIds)) { + $productIds = [$productIds]; + } + $this->productIndexer->execute($productIds); + $this->algoliaHelper->waitLastTask(); + } + + protected function getAlgoliaObjectById(int $productId): ?array + { + $res = $this->algoliaHelper->getObjects( + $this->indexName, + [(string) $productId] + ); + return reset($res['results']); + } + + protected function assertAlgoliaPrice(int $productId): void + { + $algoliaProduct = $this->getAlgoliaObjectById($productId); + $this->assertNotNull($algoliaProduct, "Algolia product index was not successful."); + $this->assertEquals(self::ASSERT_PRODUCT_PRICES[$productId], $algoliaProduct['price']['USD']['default']); + } + + /** + * @depends testMagentoProductData + * @throws AlgoliaException + * @throws ExceededRetriesException + * @throws NoSuchEntityException + */ + public function testRegularPriceSimple(): void + { + $productId = self::PRODUCT_ID_SIMPLE_STANDARD_PRICE; + $this->indexProducts($productId); + $this->assertAlgoliaPrice($productId); + } + + /** + * @depends testMagentoProductData + * @throws AlgoliaException + * @throws ExceededRetriesException + * @throws NoSuchEntityException + */ + public function testRegularPriceConfigurable(): void + { + $productId = self::PRODUCT_ID_CONFIGURABLE_STANDARD_PRICE; + $this->indexProducts($productId); + $this->assertAlgoliaPrice($productId); + } + + /** + * @depends testMagentoProductData + * @throws AlgoliaException + * @throws ExceededRetriesException + * @throws NoSuchEntityException + */ + public function testCatalogPriceRule(): void + { + $productId = self::PRODUCT_ID_CONFIGURABLE_CATALOG_PRICE_RULE; + $this->indexProducts($productId); + $this->assertAlgoliaPrice($productId); + } + + /** + * @dataProvider productProvider + */ + public function testMagentoProductData(int $productId, float $expectedPrice): void + { + /** + * @var Product $product + */ + $product = $this->objectManager->get('Magento\Catalog\Model\ProductRepository')->getById($productId); + $this->assertTrue($product->isInStock(), "Product is not in stock"); + $this->assertTrue($product->getIsSalable(), "Product is not salable"); + $actualPrice = $product->getFinalPrice(); + $this->assertEquals($actualPrice, $expectedPrice, "Product price does not match expectation"); + } + + public static function productProvider(): array + { + return array_map( + function ($key, $value) { + return [$key, $value]; + }, + array_keys(self::ASSERT_PRODUCT_PRICES), + self::ASSERT_PRODUCT_PRICES + ); + } + + public function testSpecialPrice(): void + { + $this->productIndexer->execute([self::SPECIAL_PRICE_TEST_PRODUCT_ID]); + $this->algoliaHelper->waitLastTask(); + + $res = $this->algoliaHelper->getObjects( + $this->indexPrefix . + 'default_products', + [(string) self::SPECIAL_PRICE_TEST_PRODUCT_ID] + ); + $algoliaProduct = reset($res['results']); + + if (!$algoliaProduct || !array_key_exists('price', $algoliaProduct)) { + $this->markTestIncomplete('Hit was not returned correctly from Algolia. No Hit to run assetions.'); + } + + $this->assertEquals(32, $algoliaProduct['price']['USD']['default']); + $this->assertEquals('', $algoliaProduct['price']['USD']['special_from_date']); + $this->assertEquals('', $algoliaProduct['price']['USD']['special_to_date']); + + $specialPrice = 29; + $fromDatetime = new \DateTime(); + $toDatetime = new \DateTime(); + $priceFrom = $fromDatetime->modify('-2 day')->format('Y-m-d H:i:s'); + $priceTo = $toDatetime->modify('+2 day')->format('Y-m-d H:i:s'); + + $product = $this->objectManager->create(Product::class); + $product->load(self::SPECIAL_PRICE_TEST_PRODUCT_ID); + + $product->setCustomAttributes([ + 'special_price' => $specialPrice, + 'special_from_date' => date($priceFrom), + 'special_to_date' => date($priceTo), + ]); + $product->save(); + + $this->productIndexer->execute([self::SPECIAL_PRICE_TEST_PRODUCT_ID]); + $this->algoliaHelper->waitLastTask(); + + $res = $this->algoliaHelper->getObjects( + $this->indexPrefix . + 'default_products', + [(string) self::SPECIAL_PRICE_TEST_PRODUCT_ID] + ); + $algoliaProduct = reset($res['results']); + + $this->assertEquals($specialPrice, $algoliaProduct['price']['USD']['default']); + $this->assertEquals("$32.00", $algoliaProduct['price']['USD']['default_original_formated']); + } + + protected function tearDown(): void + { + /** @var Product $product */ + $product = $this->objectManager->create(Product::class); + $product->load(self::SPECIAL_PRICE_TEST_PRODUCT_ID); + + $product->setCustomAttributes([ + 'special_price' => null, + 'special_from_date' => null, + 'special_to_date' => null, + ]); + $product->getResource()->saveAttribute($product, 'special_price'); + $product->save(); + + parent::tearDown(); + } + +} diff --git a/Test/Integration/Product/ProductsIndexingTest.php b/Test/Integration/Product/ProductsIndexingTest.php new file mode 100644 index 000000000..1b42779ea --- /dev/null +++ b/Test/Integration/Product/ProductsIndexingTest.php @@ -0,0 +1,113 @@ +setConfig(ConfigHelper::SHOW_OUT_OF_STOCK, 0); + + $this->updateStockItem(self::OUT_OF_STOCK_PRODUCT_SKU, false); + + $this->processTest($this->productIndexer, 'products', $this->assertValues->productsOnStockCount); + } + + public function testIncludingOutOfStock() + { + $this->setConfig(ConfigHelper::SHOW_OUT_OF_STOCK, 1); + + $this->updateStockItem(self::OUT_OF_STOCK_PRODUCT_SKU, false); + + $this->processTest($this->productIndexer, 'products', $this->assertValues->productsOutOfStockCount); + } + + public function testDefaultIndexableAttributes() + { + $empty = $this->getSerializer()->serialize([]); + + $this->setConfig(ConfigHelper::PRODUCT_ATTRIBUTES, $empty); + $this->setConfig(ConfigHelper::FACETS, $empty); + $this->setConfig(ConfigHelper::SORTING_INDICES, $empty); + $this->setConfig(ConfigHelper::PRODUCT_CUSTOM_RANKING, $empty); + + $this->productIndexer->executeRow($this->getValidTestProduct()); + $this->algoliaHelper->waitLastTask(); + + $results = $this->algoliaHelper->getObjects($this->indexPrefix . 'default_products', [$this->getValidTestProduct()]); + $hit = reset($results['results']); + + $defaultAttributes = [ + 'objectID', + 'name', + 'url', + 'visibility_search', + 'visibility_catalog', + 'categories', + 'categories_without_path', + 'thumbnail_url', + 'image_url', + 'in_stock', + 'price', + 'type_id', + 'algoliaLastUpdateAtCET', + 'categoryIds', + ]; + + if (!$hit) { + $this->markTestIncomplete('Hit was not returned correctly from Algolia. No Hit to run assetions on.'); + } + + foreach ($defaultAttributes as $key => $attribute) { + $this->assertArrayHasKey($attribute, $hit, 'Products attribute "' . $attribute . '" should be indexed but it is not"'); + unset($hit[$attribute]); + } + + $extraAttributes = implode(', ', array_keys($hit)); + $this->assertEmpty($hit, 'Extra products attributes (' . $extraAttributes . ') are indexed and should not be.'); + } + + private function getValidTestProduct() + { + if (!$this->testProductId) { + /** @var Product $product */ + $product = $this->getObjectManager()->get(Product::class); + $this->testProductId = $product->getIdBySku('MSH09'); + } + + return $this->testProductId; + } + + /** + * @throws NoSuchEntityException + * @throws ExceededRetriesException + * @throws AlgoliaException + */ + protected function tearDown(): void + { + parent::tearDown(); + + $this->updateStockItem(self::OUT_OF_STOCK_PRODUCT_SKU, true); + } +} diff --git a/Test/Integration/Product/ProductsIndexingTestCase.php b/Test/Integration/Product/ProductsIndexingTestCase.php new file mode 100644 index 000000000..f712a3b0f --- /dev/null +++ b/Test/Integration/Product/ProductsIndexingTestCase.php @@ -0,0 +1,39 @@ +productIndexer = $this->objectManager->get(ProductIndexer::class); + $this->stockRegistry = $this->objectManager->get(StockRegistry::class); + + $this->objectManager + ->get(IndexerRegistry::class) + ->get('catalog_product_price') + ->reindexAll(); + } + + /** + * @throws NoSuchEntityException + */ + protected function updateStockItem(string $sku, bool $isInStock): void + { + $stockItem = $this->stockRegistry->getStockItemBySku($sku); + $stockItem->setIsInStock($isInStock); + $this->stockRegistry->updateStockItemBySku($sku, $stockItem); + } +} diff --git a/Test/Integration/Product/ReplicaIndexingTest.php b/Test/Integration/Product/ReplicaIndexingTest.php new file mode 100644 index 000000000..c0e27cc53 --- /dev/null +++ b/Test/Integration/Product/ReplicaIndexingTest.php @@ -0,0 +1,320 @@ +productIndexer = $this->objectManager->get(ProductIndexer::class); + $this->replicaManager = $this->objectManager->get(ReplicaManagerInterface::class); + $this->indicesConfigurator = $this->objectManager->get(IndicesConfigurator::class); + $this->indexSuffix = 'products'; + $this->indexName = $this->getIndexName('default'); + } + + public function testReplicaLimits() + { + $this->assertEquals(20, $this->replicaManager->getMaxVirtualReplicasPerIndex()); + } + + /** + * @magentoConfigFixture current_store algoliasearch_instant/instant/is_instant_enabled 1 + */ + public function testStandardReplicaConfig(): void + { + $sortAttr = 'created_at'; + $sortDir = 'desc'; + $this->assertSortingAttribute($sortAttr, $sortDir); + + $this->indicesConfigurator->saveConfigurationToAlgolia(1); + $this->algoliaHelper->waitLastTask(); + + // Assert replica config created + $primaryIndexName = $this->indexName; + $currentSettings = $this->algoliaHelper->getSettings($primaryIndexName); + $this->assertArrayHasKey('replicas', $currentSettings); + + $replicaIndexName = $primaryIndexName . '_' . $sortAttr . '_' . $sortDir; + + $this->assertTrue($this->isStandardReplica($currentSettings['replicas'], $replicaIndexName)); + $this->assertFalse($this->isVirtualReplica($currentSettings['replicas'], $replicaIndexName)); + + $replicaSettings = $this->assertReplicaIndexExists($primaryIndexName, $replicaIndexName); + $this->assertStandardReplicaRanking($replicaSettings, "$sortDir($sortAttr)"); + } + + /** + * This test involves verifying modifications in the database + * so it must be responsible for its own set up and tear down + * @magentoDbIsolation disabled + * @group virtual + */ + public function testVirtualReplicaConfig(): void + { + $primaryIndexName = $this->getIndexName('default'); + $ogSortingState = $this->configHelper->getSorting(); + + $productHelper = $this->objectManager->get(ProductHelper::class); + $sortAttr = 'color'; + $sortDir = 'asc'; + $attributes = $productHelper->getAllAttributes(); + $this->assertArrayHasKey($sortAttr, $attributes); + + $this->assertNoSortingAttribute($sortAttr, $sortDir); + + $sorting = $this->configHelper->getSorting(); + $sorting[] = [ + 'attribute' => $sortAttr, + 'sort' => $sortDir, + 'sortLabel' => $sortAttr, + 'virtualReplica' => 1 + ]; + $this->configHelper->setSorting($sorting); + + $this->assertConfigInDb(ConfigHelper::SORTING_INDICES, json_encode($sorting)); + + $this->refreshConfigFromDb(); + + $this->assertSortingAttribute($sortAttr, $sortDir); + + // Cannot use config fixture because we have disabled db isolation + $this->setConfig(ConfigHelper::IS_INSTANT_ENABLED, 1); + + $this->indicesConfigurator->saveConfigurationToAlgolia(1); + $this->algoliaHelper->waitLastTask(); + + // Assert replica config created + $currentSettings = $this->algoliaHelper->getSettings($primaryIndexName); + $this->assertArrayHasKey('replicas', $currentSettings); + + $replicaIndexName = $primaryIndexName . '_' . $sortAttr . '_' . $sortDir; + + $this->assertTrue($this->isVirtualReplica($currentSettings['replicas'], $replicaIndexName)); + $this->assertFalse($this->isStandardReplica($currentSettings['replicas'], $replicaIndexName)); + + // Assert replica index created + $replicaSettings = $this->assertReplicaIndexExists($primaryIndexName, $replicaIndexName); + $this->assertVirtualReplicaRanking($replicaSettings, "$sortDir($sortAttr)"); + + // Restore prior state (for this test only) + $this->configHelper->setSorting($ogSortingState); + $this->setConfig(ConfigHelper::IS_INSTANT_ENABLED, 0); + } + + /** + * ConfigHelper::setSorting uses WriterInterface which does not update unless DB isolation is disabled + * This provides a workaround to test using MutableScopeConfigInterface with DB isolation enabled + */ + protected function mockSortUpdate(string $sortAttr, string $sortDir, array $attr): void + { + $sorting = $this->configHelper->getSorting(); + $existing = array_filter($sorting, function ($item) use ($sortAttr, $sortDir) { + return $item['attribute'] === $sortAttr && $item['sort'] === $sortDir; + }); + + + if ($existing) { + $idx = array_key_first($existing); + $sorting[$idx] = array_merge($existing[$idx], $attr); + } + else { + $sorting[] = array_merge( + [ + 'attribute' => $sortAttr, + 'sort' => $sortDir, + 'sortLabel' => $sortAttr + ], + $attr + ); + } + $this->setConfig(ConfigHelper::SORTING_INDICES, json_encode($sorting)); + } + + /** + * @depends testReplicaSync + * @magentoConfigFixture current_store algoliasearch_instant/instant/is_instant_enabled 1 + * @throws AlgoliaException + * @throws ExceededRetriesException + * @throws \ReflectionException + */ + public function testReplicaRebuild(): void + { + $primaryIndexName = $this->getIndexName('default'); + + $this->mockSortUpdate('price', 'desc', ['virtualReplica' => 1]); + $sorting = $this->objectManager->get(\Algolia\AlgoliaSearch\Service\Product\SortingTransformer::class)->getSortingIndices(1, null, null, true); + + $syncCmd = $this->objectManager->get(\Algolia\AlgoliaSearch\Console\Command\ReplicaSyncCommand::class); + $this->mockProperty($syncCmd, 'output', \Symfony\Component\Console\Output\OutputInterface::class); + $syncCmd->syncReplicas(); + $this->algoliaHelper->waitLastTask(); + + $rebuildCmd = $this->objectManager->get(\Algolia\AlgoliaSearch\Console\Command\ReplicaRebuildCommand::class); + $this->invokeMethod( + $rebuildCmd, + 'execute', + [ + $this->createMock(\Symfony\Component\Console\Input\InputInterface::class), + $this->createMock(\Symfony\Component\Console\Output\OutputInterface::class) + ] + ); + $this->algoliaHelper->waitLastTask(); + + $currentSettings = $this->algoliaHelper->getSettings($primaryIndexName); + $this->assertArrayHasKey('replicas', $currentSettings); + $replicas = $currentSettings['replicas']; + + $this->assertEquals(count($sorting), count($replicas)); + $this->assertSortToReplicaConfigParity($primaryIndexName, $sorting, $replicas); + } + + /** + * @magentoConfigFixture current_store algoliasearch_instant/instant/is_instant_enabled 1 + * @throws AlgoliaException + * @throws ExceededRetriesException + * @throws \ReflectionException + */ + public function testReplicaSync(): void + { + $primaryIndexName = $this->getIndexName('default'); + $this->mockSortUpdate('created_at', 'desc', ['virtualReplica' => 1]); + + $sorting = $this->objectManager->get(\Algolia\AlgoliaSearch\Service\Product\SortingTransformer::class)->getSortingIndices(1, null, null, true); + + $cmd = $this->objectManager->get(\Algolia\AlgoliaSearch\Console\Command\ReplicaSyncCommand::class); + + $this->mockProperty($cmd, 'output', \Symfony\Component\Console\Output\OutputInterface::class); + + $cmd->syncReplicas(); + $this->algoliaHelper->waitLastTask(); + + $currentSettings = $this->algoliaHelper->getSettings($primaryIndexName); + $this->assertArrayHasKey('replicas', $currentSettings); + $replicas = $currentSettings['replicas']; + + $this->assertEquals(count($sorting), count($replicas)); + $this->assertSortToReplicaConfigParity($primaryIndexName, $sorting, $replicas); + } + + protected function assertSortToReplicaConfigParity(string $primaryIndexName, array $sorting, array $replicas): void + { + foreach ($sorting as $sortAttr) { + $replicaIndexName = $sortAttr['name']; + $isVirtual = array_key_exists('virtualReplica', $sortAttr) && $sortAttr['virtualReplica']; + $needle = $isVirtual + ? "virtual($replicaIndexName)" + : $replicaIndexName; + $this->assertContains($needle, $replicas); + + $replicaSettings = $this->assertReplicaIndexExists($primaryIndexName, $replicaIndexName); + $sort = reset($sortAttr['ranking']); + if ($isVirtual) { + $this->assertVirtualReplicaRanking($replicaSettings, $sort); + } else { + $this->assertStandardReplicaRanking($replicaSettings, $sort); + } + } + } + + protected function assertReplicaIndexExists(string $primaryIndexName, string $replicaIndexName): array + { + $replicaSettings = $this->algoliaHelper->getSettings($replicaIndexName); + $this->assertArrayHasKey('primary', $replicaSettings); + $this->assertEquals($primaryIndexName, $replicaSettings['primary']); + return $replicaSettings; + } + + protected function assertReplicaRanking(array $replicaSettings, string $rankingKey, string $sort) { + $this->assertArrayHasKey($rankingKey, $replicaSettings); + $this->assertEquals($sort, reset($replicaSettings[$rankingKey])); + } + + protected function assertStandardReplicaRanking(array $replicaSettings, string $sort): void + { + $this->assertReplicaRanking($replicaSettings, 'ranking', $sort); + } + + protected function assertVirtualReplicaRanking(array $replicaSettings, string $sort): void + { + $this->assertReplicaRanking($replicaSettings, 'customRanking', $sort); + } + + protected function assertStandardReplicaRankingOld(array $replicaSettings, string $sortAttr, string $sortDir): void + { + $this->assertArrayHasKey('ranking', $replicaSettings); + $this->assertEquals("$sortDir($sortAttr)", array_shift($replicaSettings['ranking'])); + } + + protected function assertVirtualReplicaRankingOld(array $replicaSettings, string $sortAttr, string $sortDir): void + { + $this->assertArrayHasKey('customRanking', $replicaSettings); + $this->assertEquals("$sortDir($sortAttr)", array_shift($replicaSettings['customRanking'])); + } + + /** + * @param string[] $replicaSetting + * @param string $replicaIndexName + * @return bool + */ + protected function isVirtualReplica(array $replicaSetting, string $replicaIndexName): bool + { + return (bool) array_filter( + $replicaSetting, + function ($replica) use ($replicaIndexName) { + return str_contains($replica, "virtual($replicaIndexName)"); + } + ); + } + + protected function isStandardReplica(array $replicaSetting, string $replicaIndexName): bool + { + return (bool) array_filter( + $replicaSetting, + function ($replica) use ($replicaIndexName) { + $regex = '/^' . preg_quote($replicaIndexName) . '$/'; + return preg_match($regex, $replica); + } + ); + } + + protected function hasSortingAttribute($sortAttr, $sortDir): bool + { + $sorting = $this->configHelper->getSorting(); + return (bool) array_filter( + $sorting, + function($sort) use ($sortAttr, $sortDir) { + return $sort['attribute'] == $sortAttr + && $sort['sort'] == $sortDir; + } + ); + } + + protected function assertSortingAttribute($sortAttr, $sortDir): void + { + $this->assertTrue($this->hasSortingAttribute($sortAttr, $sortDir)); + } + + protected function assertNoSortingAttribute($sortAttr, $sortDir): void + { + $this->assertFalse($this->hasSortingAttribute($sortAttr, $sortDir)); + } + +} diff --git a/Test/Integration/ProductsIndexingTest.php b/Test/Integration/ProductsIndexingTest.php deleted file mode 100644 index 64c315403..000000000 --- a/Test/Integration/ProductsIndexingTest.php +++ /dev/null @@ -1,161 +0,0 @@ -setConfig('cataloginventory/options/show_out_of_stock', 0); - - $this->setOneProductOutOfStock(); - - /** @var Product $indexer */ - $indexer = $this->getObjectManager()->create(Product::class); - - $this->processTest($indexer, 'products', $this->assertValues->productsOnStockCount); - } - - public function testIncludingOutOfStock() - { - $this->setConfig('cataloginventory/options/show_out_of_stock', 1); - - $this->setOneProductOutOfStock(); - - /** @var Product $indexer */ - $indexer = $this->getObjectManager()->create(Product::class); - - $this->processTest($indexer, 'products', $this->assertValues->productsOutOfStockCount); - } - - public function testDefaultIndexableAttributes() - { - $empty = $this->getSerializer()->serialize([]); - - $this->setConfig('algoliasearch_products/products/product_additional_attributes', $empty); - $this->setConfig('algoliasearch_instant/instant/facets', $empty); - $this->setConfig('algoliasearch_instant/instant/sorts', $empty); - $this->setConfig('algoliasearch_products/products/custom_ranking_product_attributes', $empty); - - /** @var Product $indexer */ - $indexer = $this->getObjectManager()->create(Product::class); - $indexer->executeRow($this->getValidTestProduct()); - - $this->algoliaHelper->waitLastTask(); - - $results = $this->algoliaHelper->getObjects($this->indexPrefix . 'default_products', [$this->getValidTestProduct()]); - $hit = reset($results['results']); - - $defaultAttributes = [ - 'objectID', - 'name', - 'url', - 'visibility_search', - 'visibility_catalog', - 'categories', - 'categories_without_path', - 'thumbnail_url', - 'image_url', - 'in_stock', - 'price', - 'type_id', - 'algoliaLastUpdateAtCET', - 'categoryIds', - ]; - - if (!$hit) { - $this->markTestIncomplete('Hit was not returned correctly from Algolia. No Hit to run assetions on.'); - } - - foreach ($defaultAttributes as $key => $attribute) { - $this->assertArrayHasKey($attribute, $hit, 'Products attribute "' . $attribute . '" should be indexed but it is not"'); - unset($hit[$attribute]); - } - - $extraAttributes = implode(', ', array_keys($hit)); - $this->assertEmpty($hit, 'Extra products attributes (' . $extraAttributes . ') are indexed and should not be.'); - } - - public function testNoSpecialPrice() - { - /** @var Product $indexer */ - $indexer = $this->getObjectManager()->create(Product::class); - $indexer->execute([9]); - - $this->algoliaHelper->waitLastTask(); - - $res = $this->algoliaHelper->getObjects($this->indexPrefix . 'default_products', ['9']); - $algoliaProduct = reset($res['results']); - - if (!$algoliaProduct || !array_key_exists('price', $algoliaProduct)) { - $this->markTestIncomplete('Hit was not returned correctly from Algolia. No Hit to run assetions.'); - } - - $this->assertEquals(32, $algoliaProduct['price']['USD']['default']); - $this->assertEquals('', $algoliaProduct['price']['USD']['special_from_date']); - $this->assertEquals('', $algoliaProduct['price']['USD']['special_to_date']); - } - - public function deprecatedTestSpecialPrice() - { - /** @var \Magento\Framework\App\ProductMetadataInterface $productMetadata */ - $productMetadata = $this->getObjectManager()->create(\Magento\Framework\App\ProductMetadataInterface::class); - $version = $productMetadata->getVersion(); - if (version_compare($version, '2.1', '<') === true) { - $this->markTestSkipped(); - } - - /** @var \Magento\Catalog\Model\Product $product */ - $product = $this->getObjectManager()->create(\Magento\Catalog\Model\Product::class); - $product->load(9); - - $specialPrice = 29; - $from = 1452556800; - $to = 1699920000; - $product->setCustomAttributes([ - 'special_price' => $specialPrice, - 'special_from_date' => date('m/d/Y', $from), - 'special_to_date' => date('m/d/Y', $to), - ]); - - $product->save(); - - /** @var Product $indexer */ - $indexer = $this->getObjectManager()->create(Product::class); - $indexer->execute([9]); - - $this->algoliaHelper->waitLastTask(); - - $res = $this->algoliaHelper->getObjects($this->indexPrefix . 'default_products', ['9']); - $algoliaProduct = reset($res['results']); - - $this->assertEquals($specialPrice, $algoliaProduct['price']['USD']['default']); - $this->assertEquals($from, $algoliaProduct['price']['USD']['special_from_date']); - $this->assertEquals($to, $algoliaProduct['price']['USD']['special_to_date']); - } - - private function setOneProductOutOfStock() - { - /** @var StockRegistry $stockRegistry */ - $stockRegistry = $this->getObjectManager()->create(\Magento\CatalogInventory\Model\StockRegistry::class); - $stockItem = $stockRegistry->getStockItemBySku('24-MB01'); - $stockItem->setIsInStock(false); - $stockRegistry->updateStockItemBySku('24-MB01', $stockItem); - } - - private function getValidTestProduct() - { - if (!$this->testProductId) { - /** @var \Magento\Catalog\Model\Product $product */ - $product = $this->getObjectManager()->get(\Magento\Catalog\Model\Product::class); - $this->testProductId = $product->getIdBySku('MSH09'); - } - - return $this->testProductId; - } -} diff --git a/Test/Integration/QueueTest.php b/Test/Integration/Queue/QueueTest.php similarity index 88% rename from Test/Integration/QueueTest.php rename to Test/Integration/Queue/QueueTest.php index 2d38fff21..6466815ca 100644 --- a/Test/Integration/QueueTest.php +++ b/Test/Integration/Queue/QueueTest.php @@ -1,16 +1,22 @@ jobsCollectionFactory = $this->getObjectManager()->create(JobsCollectionFactory::class); + $this->jobsCollectionFactory = $this->objectManager->create(JobsCollectionFactory::class); /** @var ResourceConnection $resource */ - $resource = $this->getObjectManager()->create(ResourceConnection::class); + $resource = $this->objectManager->get(ResourceConnection::class); $this->connection = $resource->getConnection(); - $this->queue = $this->getObjectManager()->create(Queue::class); + $this->queue = $this->objectManager->get(Queue::class); } public function testFill() { $this->resetConfigs([ - 'algoliasearch_queue/queue/number_of_job_to_run', - 'algoliasearch_advanced/queue/number_of_element_by_page', + ConfigHelper::NUMBER_OF_JOB_TO_RUN, + ConfigHelper::NUMBER_OF_ELEMENT_BY_PAGE, ]); - $this->setConfig('algoliasearch_queue/queue/active', '1'); + $this->setConfig(ConfigHelper::IS_ACTIVE, '1'); $this->connection->query('TRUNCATE TABLE algoliasearch_queue'); /** @var Product $indexer */ - $indexer = $this->getObjectManager()->create(Product::class); + $indexer = $this->objectManager->get(Product::class); $indexer->executeFull(); $rows = $this->connection->query('SELECT * FROM algoliasearch_queue')->fetchAll(); @@ -79,18 +85,17 @@ public function testFill() } } - /** - * @depends testFill - * @magentoDbIsolation disabled - */ public function testExecute() { - $this->markTestIncomplete(self::INCOMPLETE_REASON); + $this->setConfig(ConfigHelper::IS_ACTIVE, '1'); + $this->connection->query('TRUNCATE TABLE algoliasearch_queue'); - $this->setConfig('algoliasearch_queue/queue/active', '1'); + /** @var Product $indexer */ + $indexer = $this->objectManager->get(Product::class); + $indexer->executeFull(); /** @var Queue $queue */ - $queue = $this->getObjectManager()->create(Queue::class); + $queue = $this->objectManager->get(Queue::class); // Run the first two jobs - saveSettings, batch $queue->runCron(2, true); @@ -108,7 +113,7 @@ public function testExecute() $this->assertTrue($existsDefaultTmpIndex, 'Default products production index does not exists and it should'); - // Run the second two jobs - batch, move + // Run the last move - move $queue->runCron(2, true); $this->algoliaHelper->waitLastTask(); @@ -131,31 +136,26 @@ public function testExecute() $this->assertTrue($existsDefaultProdIndex, 'Default product production index does not exists and it should'); /** TODO: There are mystery items being added to queue from unknown save process on product_id=1 */ - /* $rows = $this->connection->query('SELECT * FROM algoliasearch_queue')->fetchAll(); - $this->assertEquals(0, count($rows)); */ + $rows = $this->connection->query('SELECT * FROM algoliasearch_queue')->fetchAll(); + $this->assertEquals(0, count($rows)); } - /** - * @magentoDbIsolation disabled - */ public function testSettings() { - $this->markTestIncomplete(self::INCOMPLETE_REASON); - $this->resetConfigs([ - 'algoliasearch_queue/queue/number_of_job_to_run', - 'algoliasearch_advanced/queue/number_of_element_by_page', - 'algoliasearch_instant/instant/facets', - 'algoliasearch_products/products/product_additional_attributes', + ConfigHelper::NUMBER_OF_JOB_TO_RUN, + ConfigHelper::NUMBER_OF_ELEMENT_BY_PAGE, + ConfigHelper::FACETS, + ConfigHelper::PRODUCT_ATTRIBUTES ]); - $this->setConfig('algoliasearch_queue/queue/active', '1'); + $this->setConfig(ConfigHelper::IS_ACTIVE, '1'); $this->connection->query('DELETE FROM algoliasearch_queue'); // Reindex products multiple times /** @var Product $indexer */ - $indexer = $this->getObjectManager()->create(Product::class); + $indexer = $this->objectManager->get(Product::class); $indexer->executeFull(); $indexer->executeFull(); $indexer->executeFull(); @@ -165,7 +165,7 @@ public function testSettings() // Process the whole queue /** @var QueueRunner $queueRunner */ - $queueRunner = $this->getObjectManager()->create(QueueRunner::class); + $queueRunner = $this->objectManager->get(QueueRunner::class); $queueRunner->executeFull(); $queueRunner->executeFull(); $queueRunner->executeFull(); @@ -180,19 +180,16 @@ public function testSettings() $this->assertFalse(empty($settings['searchableAttributes']), 'SearchableAttributes should be set, but they are not.'); } - /** - * @magentoDbIsolation disabled - */ public function testMergeSettings() { - $this->setConfig('algoliasearch_queue/queue/active', '1'); - $this->setConfig('algoliasearch_queue/queue/number_of_job_to_run', 1); - $this->setConfig('algoliasearch_advanced/queue/number_of_element_by_page', 300); + $this->setConfig(ConfigHelper::IS_ACTIVE, '1'); + $this->setConfig(ConfigHelper::NUMBER_OF_JOB_TO_RUN, 1); + $this->setConfig(ConfigHelper::NUMBER_OF_ELEMENT_BY_PAGE, 300); $this->connection->query('DELETE FROM algoliasearch_queue'); /** @var Product $productIndexer */ - $productIndexer = $this->getObjectManager()->create(Product::class); + $productIndexer = $this->objectManager->get(Product::class); $productIndexer->executeFull(); $rows = $this->connection->query('SELECT * FROM algoliasearch_queue')->fetchAll(); @@ -207,7 +204,7 @@ public function testMergeSettings() $this->assertEquals(['sku'], $settings['disableTypoToleranceOnAttributes']); /** @var QueueRunner $queueRunner */ - $queueRunner = $this->getObjectManager()->create(QueueRunner::class); + $queueRunner = $this->objectManager->get(QueueRunner::class); $queueRunner->executeFull(); $this->algoliaHelper->waitLastTask(); @@ -222,9 +219,6 @@ public function testMergeSettings() $this->assertEquals(['sku'], $settings['disableTypoToleranceOnAttributes']); } - /** - * @magentoDbIsolation disabled - */ public function testMerging() { $this->connection->query('DELETE FROM algoliasearch_queue'); @@ -433,9 +427,6 @@ public function testMerging() $this->assertEquals($expectedProductJob, $productJob->toArray()); } - /** - * @magentoDbIsolation disabled - */ public function testMergingWithStaticMethods() { $this->connection->query('TRUNCATE TABLE algoliasearch_queue'); @@ -586,13 +577,8 @@ public function testMergingWithStaticMethods() $this->assertEquals('rebuildStoreProductIndex', $jobs[11]->getMethod()); } - /** - * @magentoDbIsolation disabled - */ public function testGetJobs() { - $this->markTestIncomplete(self::INCOMPLETE_REASON); - $this->connection->query('TRUNCATE TABLE algoliasearch_queue'); $data = [ @@ -739,18 +725,12 @@ public function testGetJobs() $expectedFirstJob = [ 'job_id' => '1', - 'created' => '2017-09-01 12:00:00', - 'pid' => null, + 'pid' => $pid, 'class' => \Algolia\AlgoliaSearch\Helper\Data::class, 'method' => 'rebuildStoreCategoryIndex', 'data' => '{"store_id":"1","category_ids":["9","22"]}', - 'max_retries' => '3', - 'retries' => '0', - 'error_log' => '', - 'data_size' => 3, 'merged_ids' => ['1', '7'], 'store_id' => '1', - 'is_full_reindex' => 0, 'decoded_data' => [ 'store_id' => '1', 'category_ids' => [ @@ -759,24 +739,16 @@ public function testGetJobs() 2 => '40', ], ], - 'locked_at' => null, - 'debug' => null, ]; $expectedLastJob = [ 'job_id' => '6', - 'created' => '2017-09-01 12:00:00', - 'pid' => null, + 'pid' => $pid, 'class' => \Algolia\AlgoliaSearch\Helper\Data::class, 'method' => 'rebuildStoreProductIndex', 'data' => '{"store_id":"3","product_ids":["448"]}', - 'max_retries' => '3', - 'retries' => '0', - 'error_log' => '', - 'data_size' => 2, 'merged_ids' => ['6', '12'], 'store_id' => '3', - 'is_full_reindex' => 0, 'decoded_data' => [ 'store_id' => '3', 'product_ids' => [ @@ -784,18 +756,31 @@ public function testGetJobs() 1 => '405', ], ], - 'locked_at' => null, - 'debug' => null, ]; /** @var Job $firstJob */ $firstJob = reset($jobs); + $firstJob = $firstJob->toArray(); /** @var Job $lastJob */ $lastJob = end($jobs); + $lastJob = $lastJob->toArray(); + + $valuesToCheck = [ + 'job_id', + 'method', + 'class', + 'store_id', + 'pid', + 'data', + 'merged_ids', + 'decoded_data', + ]; - $this->assertEquals($expectedFirstJob, $firstJob->toArray()); - $this->assertEquals($expectedLastJob, $lastJob->toArray()); + foreach ($valuesToCheck as $valueToCheck) { + $this->assertEquals($expectedFirstJob[$valueToCheck], $firstJob[$valueToCheck]); + $this->assertEquals($expectedLastJob[$valueToCheck], $lastJob[$valueToCheck]); + } $dbJobs = $this->connection->query('SELECT * FROM algoliasearch_queue')->fetchAll(); @@ -806,14 +791,11 @@ public function testGetJobs() } } - /** - * @magentoDbIsolation disabled - */ public function testHugeJob() { // Default value - maxBatchSize = 1000 - $this->setConfig('algoliasearch_queue/queue/number_of_job_to_run', 10); - $this->setConfig('algoliasearch_advanced/queue/number_of_element_by_page', 100); + $this->setConfig(ConfigHelper::NUMBER_OF_JOB_TO_RUN, 10); + $this->setConfig(ConfigHelper::NUMBER_OF_ELEMENT_BY_PAGE, 100); $productIds = range(1, 5000); $jsonProductIds = json_encode($productIds); @@ -850,8 +832,8 @@ public function testHugeJob() public function testMaxSingleJobSize() { // Default value - maxBatchSize = 1000 - $this->setConfig('algoliasearch_queue/queue/number_of_job_to_run', 10); - $this->setConfig('algoliasearch_advanced/queue/number_of_element_by_page', 100); + $this->setConfig(ConfigHelper::NUMBER_OF_JOB_TO_RUN, 10); + $this->setConfig(ConfigHelper::NUMBER_OF_ELEMENT_BY_PAGE, 100); $productIds = range(1, 99); $jsonProductIds = json_encode($productIds); @@ -888,25 +870,22 @@ public function testMaxSingleJobSize() $this->assertEquals($pid, $lastJob['pid']); } - /** - * @magentoDbIsolation disabled - */ public function testMaxSingleJobsSizeOnProductReindex() { $this->resetConfigs([ - 'algoliasearch_queue/queue/number_of_job_to_run', - 'algoliasearch_advanced/queue/number_of_element_by_page', + ConfigHelper::NUMBER_OF_JOB_TO_RUN, + ConfigHelper::NUMBER_OF_ELEMENT_BY_PAGE, ]); - $this->setConfig('algoliasearch_queue/queue/active', '1'); + $this->setConfig(ConfigHelper::IS_ACTIVE, '1'); - $this->setConfig('algoliasearch_queue/queue/number_of_job_to_run', 10); - $this->setConfig('algoliasearch_advanced/queue/number_of_element_by_page', 100); + $this->setConfig(ConfigHelper::NUMBER_OF_JOB_TO_RUN, 10); + $this->setConfig(ConfigHelper::NUMBER_OF_ELEMENT_BY_PAGE, 100); $this->connection->query('TRUNCATE TABLE algoliasearch_queue'); /** @var Product $indexer */ - $indexer = $this->getObjectManager()->create(Product::class); + $indexer = $this->objectManager->get(Product::class); $indexer->execute(range(1, 512)); $dbJobs = $this->connection->query('SELECT * FROM algoliasearch_queue')->fetchAll(); diff --git a/Test/Integration/Search/SearchTest.php b/Test/Integration/Search/SearchTest.php new file mode 100644 index 000000000..575c5dbd8 --- /dev/null +++ b/Test/Integration/Search/SearchTest.php @@ -0,0 +1,107 @@ +productIndexer = $this->objectManager->get(Product::class); + $this->helper = $this->objectManager->create(Data::class); + + $this->productIndexer->executeFull(); + $this->algoliaHelper->waitLastTask(); + } + + public function testSearch() + { + $query = 'bag'; + $results = $this->search($query); + $result = $this->getFirstResult($results); + // Search returns result + $this->assertNotEmpty($result, "Query didn't bring result"); + + $product = $this->objectManager->create(\Magento\Catalog\Model\Product::class); + $product->load($result['entity_id']); + // Result exists in DB + $this->assertNotEmpty($product->getName(), "Query result item couldn't find in the DB"); + // Query word exists title + $this->assertStringContainsString($query, strtolower($product->getName()), "Query word doesn't exist in product name"); + } + + public function testSearchBySku() + { + $sku = "24-MB01"; + $results = $this->search($sku); + $result = $this->getFirstResult($results); + // Search by SKU returns result + $this->assertNotEmpty($result, "SKU search didn't bring result"); + + $product = $this->objectManager->create(\Magento\Catalog\Model\Product::class); + $product->load($result['entity_id']); + // Result exists in DB + $this->assertNotEmpty($product->getSku(), "SKU search result item couldn't find in the DB"); + // Query word exists title + $this->assertEquals($sku, $product->getSku(), "Query SKU doesn't match with product SKU"); + } + + public function testCategorySearch() + { + // Get products by categoryId + list($results, $totalHits, $facetsFromAlgolia) = $this->search('', 1, [ + 'facetFilters' => ['categoryIds:' . self::BAGS_CATEGORY_ID] + ]); + // Category filter returns result + $this->assertNotEmpty($results, "Category filter didn't return result"); + + $collection = $this->objectManager->create(\Magento\Catalog\Model\ResourceModel\Product\Collection::class); + $collection + ->addAttributeToSelect('*') + ->addAttributeToFilter('status',\Magento\Catalog\Model\Product\Attribute\Source\Status::STATUS_ENABLED) + ->addAttributeToFilter('visibility', \Magento\Catalog\Model\Product\Visibility::VISIBILITY_BOTH) + ->addCategoriesFilter(["in" => self::BAGS_CATEGORY_ID]) + ->setStore(1); + // Products in category count matches + $this->assertEquals(count($results), $collection->count(), "Indexed number of products in a category doesn't match with DB"); + } + + /** + * @param array $results + * @return array + */ + protected function getFirstResult(array $results): array + { + list($results, $totalHits, $facetsFromAlgolia) = $results; + return array_shift($results); + } + + /** + * @param string $query + * @param int $storeId + * @param array $params + * @return array + */ + protected function search(string $query = '', int $storeId = 1, array $params = []): array + { + try { + return $this->helper->getSearchResult($query, $storeId, $params); + } catch (NoSuchEntityException $e) { + return []; + } + } +} diff --git a/Test/Integration/SearchTest.php b/Test/Integration/SearchTest.php deleted file mode 100644 index f75aa8292..000000000 --- a/Test/Integration/SearchTest.php +++ /dev/null @@ -1,24 +0,0 @@ -getObjectManager()->create(Product::class); - $indexer->executeFull(); - - $this->algoliaHelper->waitLastTask(); - - /** @var Data $helper */ - $helper = $this->getObjectManager()->create(Data::class); - list($results, $totalHits, $facetsFromAlgolia) = $helper->getSearchResult('', 1); - - $this->assertNotEmpty($results); - } -} diff --git a/Test/Integration/TestCase.php b/Test/Integration/TestCase.php index f13eb4636..2c3f39ec0 100644 --- a/Test/Integration/TestCase.php +++ b/Test/Integration/TestCase.php @@ -3,13 +3,17 @@ namespace Algolia\AlgoliaSearch\Test\Integration; use Algolia\AlgoliaSearch\Exceptions\AlgoliaException; +use Algolia\AlgoliaSearch\Exceptions\ExceededRetriesException; use Algolia\AlgoliaSearch\Helper\AlgoliaHelper; use Algolia\AlgoliaSearch\Helper\ConfigHelper; use Algolia\AlgoliaSearch\Setup\Patch\Schema\ConfigPatch; -use Algolia\AlgoliaSearch\Test\Integration\AssertValues\Magento23; -use Algolia\AlgoliaSearch\Test\Integration\AssertValues\Magento24; +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 Magento\Framework\App\Config\ScopeConfigInterface; use Magento\Framework\App\ProductMetadataInterface; +use Magento\Framework\ObjectManagerInterface; use Magento\Store\Model\ScopeInterface; use Magento\TestFramework\Helper\Bootstrap; @@ -21,6 +25,11 @@ class_alias('\PHPUnit_Framework_TestCase', '\TC'); abstract class TestCase extends \TC { + /** + * @var ObjectManagerInterface + */ + protected $objectManager; + /** @var bool */ private $boostrapped = false; @@ -33,17 +42,33 @@ abstract class TestCase extends \TC /** @var ConfigHelper */ protected $configHelper; - /** @var Magento23|Magento24 */ + /** @var Magento246CE|Magento246EE|Magento247CE|Magento247EE */ protected $assertValues; - public function setUp(): void + /** @var ProductMetadataInterface */ + protected $productMetadata; + + protected ?string $indexSuffix = null; + + protected function setUp(): void { $this->bootstrap(); } - public function tearDown(): void + /** + * @throws ExceededRetriesException + * @throws AlgoliaException + */ + protected function tearDown(): void { $this->clearIndices(); + $this->algoliaHelper->waitLastTask(); + $this->clearIndices(); // Remaining replicas + } + + protected function getIndexName(string $storeIndexPart): string + { + return $this->indexPrefix . $storeIndexPart . ($this->indexSuffix ? '_' . $this->indexSuffix : ''); } protected function resetConfigs($configs = []) @@ -58,16 +83,88 @@ protected function resetConfigs($configs = []) } } - protected function setConfig($path, $value) - { + protected function setConfig( + $path, + $value, + $scopeCode = 'default' + ) { $this->getObjectManager()->get(\Magento\Framework\App\Config\MutableScopeConfigInterface::class)->setValue( $path, $value, ScopeInterface::SCOPE_STORE, - 'default' + $scopeCode ); } + protected function assertConfigInDb( + string $path, + mixed $value, + string $scope = ScopeConfigInterface::SCOPE_TYPE_DEFAULT, + int $scopeId = 0 + ): void + { + $connection = $this->objectManager->create(\Magento\Framework\App\ResourceConnection::class) + ->getConnection(); + + $select = $connection->select() + ->from('core_config_data', 'value') + ->where('path = ?', $path) + ->where('scope = ?', $scope) + ->where('scope_id = ?', $scopeId); + + $configValue = $connection->fetchOne($select); + + $this->assertEquals($value, $configValue); + } + + /** + * If testing classes that use WriterInterface under the hood to update the database + * then you need a way to refresh the in-memory cache + * This function achieves that while preserving the original bootstrap config + */ + protected function refreshConfigFromDb(): void + { + $bootstrap = $this->getBootstrapConfig(); + $this->objectManager->get(\Magento\Framework\App\Config\ReinitableConfigInterface::class)->reinit(); + $this->setConfigFromArray($bootstrap); + } + + /** + * @return array + */ + protected function getBootstrapConfig(): array + { + $config = $this->objectManager->get(ScopeConfigInterface::class); + + $bootstrap = [ + ConfigHelper::APPLICATION_ID, + ConfigHelper::SEARCH_ONLY_API_KEY, + ConfigHelper::API_KEY, + ConfigHelper::INDEX_PREFIX + ]; + + return array_combine( + $bootstrap, + array_map( + function($setting) use ($config) { + return $config->getValue($setting, ScopeInterface::SCOPE_STORE); + }, + $bootstrap + ) + ); + } + + /** + * @param array $settings + * @return void + */ + protected function setConfigFromArray(array $settings): void + { + foreach ($settings as $key => $value) { + $this->setConfig($key, $value); + } + } + protected function clearIndices() { $indices = $this->algoliaHelper->listIndexes(); @@ -97,10 +194,21 @@ private function bootstrap() return; } - if (version_compare($this->getMagentoVersion(), '2.4.0', '<')) { - $this->assertValues = new Magento23(); + $this->objectManager = $this->getObjectManager(); + $this->productMetadata = $this->objectManager->get(ProductMetadataInterface::class); + + if (version_compare($this->getMagentoVersion(), '2.4.7', '<')) { + if ($this->getMagentEdition() === 'Community') { + $this->assertValues = new Magento246CE(); + } else { + $this->assertValues = new Magento246EE(); + } } else { - $this->assertValues = new Magento24(); + if ($this->getMagentEdition() === 'Community') { + $this->assertValues = new Magento247CE(); + } else { + $this->assertValues = new Magento247EE(); + } } $this->configHelper = $this->getObjectManager()->create(ConfigHelper::class); @@ -116,6 +224,18 @@ 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. * @@ -127,21 +247,20 @@ private function bootstrap() * * @return mixed method return */ - protected function invokeMethod(&$object, $methodName, array $parameters = []) + protected function invokeMethod(object $object, string $methodName, array $parameters = []) { $reflection = new \ReflectionClass(get_class($object)); - $method = $reflection->getMethod($methodName); - $method->setAccessible(true); - - return $method->invokeArgs($object, $parameters); + return $reflection->getMethod($methodName)->invokeArgs($object, $parameters); } private function getMagentoVersion() { - /** @var ProductMetadataInterface $productMetadata */ - $productMetadata = $this->getObjectManager()->get(ProductMetadataInterface::class); + return $this->productMetadata->getVersion(); + } - return $productMetadata->getVersion(); + private function getMagentEdition() + { + return $this->productMetadata->getEdition(); } protected function getSerializer() diff --git a/Test/Integration/_files/second_website_with_two_stores_and_products.php b/Test/Integration/_files/second_website_with_two_stores_and_products.php new file mode 100644 index 000000000..f07321e61 --- /dev/null +++ b/Test/Integration/_files/second_website_with_two_stores_and_products.php @@ -0,0 +1,93 @@ +create(\Magento\Store\Model\Website::class); +/** @var $website \Magento\Store\Model\Website */ +if (!$website->load('test', 'code')->getId()) { + $website->setData(['code' => 'test', 'name' => 'Test Website', 'default_group_id' => '1', 'is_default' => '0']); + $website->save(); +} +$websiteId = $website->getId(); +$store = Bootstrap::getObjectManager()->create(\Magento\Store\Model\Store::class); +if (!$store->load('fixture_second_store', 'code')->getId()) { + $groupId = Bootstrap::getObjectManager()->get( + StoreManagerInterface::class + )->getWebsite()->getDefaultGroupId(); + $store->setCode( + 'fixture_second_store' + )->setWebsiteId( + $websiteId + )->setGroupId( + $groupId + )->setName( + 'Fixture Second Store' + )->setSortOrder( + 10 + )->setIsActive( + 1 + ); + $store->save(); +} + +$store = Bootstrap::getObjectManager()->create(\Magento\Store\Model\Store::class); +if (!$store->load('fixture_third_store', 'code')->getId()) { + $groupId = Bootstrap::getObjectManager()->get( + StoreManagerInterface::class + )->getWebsite()->getDefaultGroupId(); + $store->setCode( + 'fixture_third_store' + )->setWebsiteId( + $websiteId + )->setGroupId( + $groupId + )->setName( + 'Fixture Third Store' + )->setSortOrder( + 11 + )->setIsActive( + 1 + ); + $store->save(); +} + +$objectManager = Bootstrap::getObjectManager(); +$websiteRepository = $objectManager->get(WebsiteRepositoryInterface::class); +$websites = $websiteRepository->getList(); +$websiteIds = []; +foreach ($websites as $website) { + $websiteIds[] = $website->getId(); +} + +$configManager = $objectManager->get(\Magento\Framework\App\Config\MutableScopeConfigInterface::class); + +$configManager->setValue('algoliasearch_credentials/credentials/application_id', getenv('ALGOLIA_APPLICATION_ID')); +$configManager->setValue('algoliasearch_credentials/credentials/search_only_api_key', getenv('ALGOLIA_SEARCH_API_KEY')); +$configManager->setValue('algoliasearch_credentials/credentials/api_key', getenv('ALGOLIA_API_KEY')); +$configManager->setValue('algoliasearch_credentials/credentials/index_prefix', 'TEMP'); +// Temporarily disable indexing during product assignment to stores +$configManager->setValue('algoliasearch_credentials/credentials/enable_backend', 0); + +$productSkus = MultiStoreProductsTest::SKUS; +$productRepository = Bootstrap::getObjectManager() + ->get(ProductRepositoryInterface::class); + +foreach ($productSkus as $sku) { + $product = $productRepository->get($sku); + $product->setWebsiteIds($websiteIds); + $productRepository->save($product); +} + +$configManager->setValue('algoliasearch_credentials/credentials/enable_backend', 1); + +/* Refresh CatalogSearch index */ +/** @var IndexerRegistry $indexerRegistry */ +$indexerRegistry = Bootstrap::getObjectManager() + ->create(IndexerRegistry::class); +$indexerRegistry->get(Fulltext::INDEXER_ID)->reindexAll(); diff --git a/Test/Integration/_files/second_website_with_two_stores_and_products_rollback.php b/Test/Integration/_files/second_website_with_two_stores_and_products_rollback.php new file mode 100644 index 000000000..af719e217 --- /dev/null +++ b/Test/Integration/_files/second_website_with_two_stores_and_products_rollback.php @@ -0,0 +1,24 @@ +get(\Magento\Framework\Registry::class); + +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', true); +$website = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create(\Magento\Store\Model\Website::class); +/** @var $website \Magento\Store\Model\Website */ +$websiteId = $website->load('test', 'code')->getId(); +if ($websiteId) { + $website->delete(); +} +$store = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create(\Magento\Store\Model\Store::class); +if ($store->load('fixture_second_store', 'code')->getId()) { + $store->delete(); +} + +$store = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create(\Magento\Store\Model\Store::class); +if ($store->load('fixture_third_store', 'code')->getId()) { + $store->delete(); +} +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', false); diff --git a/Test/Unit/ConfigHelperTest.php b/Test/Unit/ConfigHelperTest.php new file mode 100644 index 000000000..c3da8c03d --- /dev/null +++ b/Test/Unit/ConfigHelperTest.php @@ -0,0 +1,99 @@ +configInterface = $this->createMock(ScopeConfigInterface::class); + $this->configWriter = $this->createMock(WriterInterface::class); + $this->storeManager = $this->createMock(StoreManagerInterface::class); + $this->currency = $this->createMock(Currency::class); + $this->dirCurrency = $this->createMock(DirCurrency::class); + $this->directoryList = $this->createMock(DirectoryList::class); + $this->moduleResource = $this->createMock(ResourceInterface::class); + $this->productMetadata = $this->createMock(ProductMetadataInterface::class); + $this->eventManager = $this->createMock(ManagerInterface::class); + $this->serializer = $this->createMock(SerializerInterface::class); + $this->groupCollection = $this->createMock(GroupCollection::class); + $this->groupExcludedWebsiteRepository = $this->createMock(GroupExcludedWebsiteRepositoryInterface::class); + $this->cookieHelper = $this->createMock(CookieHelper::class); + + $this->configHelper = new ConfigHelperTestable( + $this->configInterface, + $this->configWriter, + $this->storeManager, + $this->currency, + $this->dirCurrency, + $this->directoryList, + $this->moduleResource, + $this->productMetadata, + $this->eventManager, + $this->serializer, + $this->groupCollection, + $this->groupExcludedWebsiteRepository, + $this->cookieHelper + ); + } + + public function testGetIndexPrefix() + { + $testPrefix = 'foo_bar_'; + $this->configInterface->method('getValue')->willReturn($testPrefix); + $this->assertEquals($testPrefix, $this->configHelper->getIndexPrefix()); + } + + public function testGetIndexPrefixWhenNull() { + $this->configInterface->method('getValue')->willReturn(null); + $this->assertEquals('', $this->configHelper->getIndexPrefix()); + } + + public function testSerializerReturnsString() { + $this->serializer->method('serialize')->willReturn('{"foo":"bar"}'); + $array = [ + 'foo' => 'bar' + ]; + $result = $this->configHelper->serialize($array); + $this->assertEquals('{"foo":"bar"}', $result); + } + + public function testSerializerFailure() { + $this->serializer->method('serialize')->willReturn(false); + $array = [ + 'foo' => 'bar' + ]; + $result = $this->configHelper->serialize($array); + $this->assertEquals('', $result); + } +} diff --git a/Test/Unit/ConfigHelperTestable.php b/Test/Unit/ConfigHelperTestable.php new file mode 100644 index 000000000..4b483658e --- /dev/null +++ b/Test/Unit/ConfigHelperTestable.php @@ -0,0 +1,14 @@ + - +