diff --git a/src/VirtoCommerce.CatalogModule.Data/Services/CatalogSeoResolver.cs b/src/VirtoCommerce.CatalogModule.Data/Services/CatalogSeoResolver.cs new file mode 100644 index 000000000..49408855f --- /dev/null +++ b/src/VirtoCommerce.CatalogModule.Data/Services/CatalogSeoResolver.cs @@ -0,0 +1,166 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using VirtoCommerce.CatalogModule.Core.Model; +using VirtoCommerce.CatalogModule.Core.Services; +using VirtoCommerce.CatalogModule.Data.Repositories; +using VirtoCommerce.CoreModule.Core.Seo; +using VirtoCommerce.Platform.Core.Common; + +namespace VirtoCommerce.CatalogModule.Data.Services; + +public class CatalogSeoResolver : ISeoResolver +{ + private readonly Func _repositoryFactory; + private readonly ICategoryService _categoryService; + private readonly IItemService _itemService; + + private const string CategoryObjectType = "Category"; + private const string CatalogProductObjectType = "CatalogProduct"; + + public CatalogSeoResolver(Func repositoryFactory, + ICategoryService categoryService, + IItemService itemService) + { + _repositoryFactory = repositoryFactory; + _categoryService = categoryService; + _itemService = itemService; + } + + public async Task> FindSeoAsync(SeoSearchCriteria criteria) + { + ArgumentNullException.ThrowIfNull(criteria); + + var permalink = criteria.Permalink ?? string.Empty; + var segments = permalink.Split('/', StringSplitOptions.RemoveEmptyEntries).ToArray(); + if (segments.Length == 0) + { + return []; + } + + var currentEntitySeoInfos = await SearchSeoInfos(criteria.StoreId, criteria.LanguageCode, segments.Last()); + + if (currentEntitySeoInfos.Count == 0) + { + // Try to find deactivated seo entries and revert it back if we found it + currentEntitySeoInfos = await SearchSeoInfos(criteria.StoreId, criteria.LanguageCode, segments.Last(), false); + if (currentEntitySeoInfos.Count == 0) + { + return []; + } + } + + var groups = currentEntitySeoInfos.GroupBy(x => new { x.ObjectType, x.ObjectId }); + + // We found seo information by seo search criteria + if (groups.Count() == 1) + { + return [currentEntitySeoInfos.First()]; + } + + // It's not possibe to resolve because we don't have parent segment + if (segments.Length == 1) + { + return []; + } + + // We found multiple seo information by seo search criteria, need to find correct by checking parent. + var parentSearchCriteria = criteria.Clone() as SeoSearchCriteria; + parentSearchCriteria.Permalink = string.Join('/', segments.Take(segments.Length - 1)); + var parentSeoInfos = await FindSeoAsync(parentSearchCriteria); + + if (parentSeoInfos.Count == 0) + { + return []; + } + + var parentCategorieIds = parentSeoInfos.Select(x => x.ObjectId).Distinct().ToList(); + + foreach (var groupKey in groups.Select(g => g.Key)) + { + if (groupKey.ObjectType == CategoryObjectType) + { + var isMatch = await DoesParentMatchCategoryOutline(parentCategorieIds, groupKey.ObjectId); + if (isMatch) + { + return currentEntitySeoInfos.Where(x => + x.ObjectId == groupKey.ObjectId + && groupKey.ObjectType == CategoryObjectType).ToList(); + } + } + + // Inside the method + else if (groupKey.ObjectType == CatalogProductObjectType) + { + var isMatch = await DoesParentMatchProductOutline(parentCategorieIds, groupKey.ObjectId); + + if (isMatch) + { + return currentEntitySeoInfos.Where(x => + x.ObjectId == groupKey.ObjectId + && groupKey.ObjectType == CatalogProductObjectType).ToList(); + } + } + } + + return []; + } + + private async Task DoesParentMatchCategoryOutline(IList parentCategorieIds, string objectId) + { + var category = await _categoryService.GetByIdAsync(objectId, CategoryResponseGroup.WithOutlines.ToString(), false); + if (category == null) + { + throw new InvalidOperationException($"Category with ID '{objectId}' was not found."); + } + var outlines = category.Outlines.Select(x => x.Items.Skip(x.Items.Count - 2).First().Id).Distinct().ToList(); + return outlines.Any(parentCategorieIds.Contains); + } + + private async Task DoesParentMatchProductOutline(IList parentCategorieIds, string objectId) + { + var product = await _itemService.GetByIdAsync(objectId, CategoryResponseGroup.WithOutlines.ToString(), false); + if (product == null) + { + throw new InvalidOperationException($"Product with ID '{objectId}' was not found."); + } + var outlines = product.Outlines.Select(x => x.Items.Skip(x.Items.Count - 2).First().Id).Distinct().ToList(); + return outlines.Any(parentCategorieIds.Contains); + } + + private async Task> SearchSeoInfos(string storeId, string languageCode, string slug, bool isActive = true) + { + using var repository = _repositoryFactory(); + + return (await repository.SeoInfos.Where(s => s.IsActive == isActive + && s.Keyword == slug + && (s.StoreId == null || s.StoreId == storeId) + && (s.Language == null || s.Language == languageCode)) + .ToListAsync()) + .Select(x => x.ToModel(AbstractTypeFactory.TryCreateInstance())) + .OrderByDescending(s => GetPriorityScore(s, storeId, languageCode)) + .ToList(); + } + + private static int GetPriorityScore(SeoInfo seoInfo, string storeId, string language) + { + var score = 0; + var hasStoreCriteria = !string.IsNullOrEmpty(storeId); + var hasLangCriteria = !string.IsNullOrEmpty(language); + + if (hasStoreCriteria && seoInfo.StoreId == storeId) + { + score += 2; + } + + if (hasLangCriteria && seoInfo.LanguageCode == language) + { + score += 1; + } + + return score; + } +} + diff --git a/src/VirtoCommerce.CatalogModule.Web/Module.cs b/src/VirtoCommerce.CatalogModule.Web/Module.cs index eecf6fd8e..d3f3e20b2 100644 --- a/src/VirtoCommerce.CatalogModule.Web/Module.cs +++ b/src/VirtoCommerce.CatalogModule.Web/Module.cs @@ -153,6 +153,7 @@ public void Initialize(IServiceCollection serviceCollection) serviceCollection.AddTransient(); serviceCollection.AddTransient(); + serviceCollection.AddTransient(); serviceCollection.AddTransient(); serviceCollection.AddTransient(); diff --git a/tests/VirtoCommerce.CatalogModule.Tests/CatalogHierarchyHelper.cs b/tests/VirtoCommerce.CatalogModule.Tests/CatalogHierarchyHelper.cs new file mode 100644 index 000000000..8e9e31c4f --- /dev/null +++ b/tests/VirtoCommerce.CatalogModule.Tests/CatalogHierarchyHelper.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.EntityFrameworkCore; +using Moq; +using VirtoCommerce.CatalogModule.Core.Model; +using VirtoCommerce.CatalogModule.Core.Services; +using VirtoCommerce.CatalogModule.Data.Model; +using VirtoCommerce.CatalogModule.Data.Repositories; +using VirtoCommerce.CatalogModule.Data.Services; +using VirtoCommerce.CoreModule.Core.Outlines; +using VirtoCommerce.CoreModule.Core.Seo; + +namespace VirtoCommerce.CatalogModule.Tests +{ + public class CatalogHierarchyHelper + { + public List Products { get; private set; } + public List Categories { get; private set; } + public List SeoInfos { get; private set; } + + public CatalogHierarchyHelper() + { + Products = new List(); + Categories = new List(); + SeoInfos = new List(); + } + + public void AddProduct(string productId, params string[] outlineIds) + { + var product = new CatalogProduct + { + Id = productId, + Outlines = outlineIds.Select(id => new Outline + { + Items = id.Split('/').Select(outlineId => new OutlineItem { Id = outlineId }).ToList() + }).ToList() + }; + Products.Add(product); + } + + public void AddCategory(string categoryId, params string[] outlineIds) + { + var category = new Category + { + Id = categoryId, + Outlines = outlineIds.Select(id => new Outline + { + Items = id.Split('/').Select(outlineId => new OutlineItem { Id = outlineId }).ToList() + }).ToList() + }; + Categories.Add(category); + } + + public void AddSeoInfo(string objectId, string objectType, string semanticUrl, bool isActive = true, string storeId = null, string languageCode = null) + { + var seoInfo = new SeoInfo + { + ObjectId = objectId, + ObjectType = objectType, + SemanticUrl = semanticUrl, + IsActive = isActive, + StoreId = storeId, + LanguageCode = languageCode + }; + SeoInfos.Add(seoInfo); + } + + public CatalogSeoResolver CreateCatalogSeoResolver() + { + var catalogRepositoryMock = CreateCatalogRepositoryMock(); + var categoryServiceMock = CreateCategoryServiceMock(); + var productServiceMock = CreateProductServiceMock(); + + return new CatalogSeoResolver( + catalogRepositoryMock.Object, + categoryServiceMock.Object, + productServiceMock.Object); + } + + public Mock CreateCategoryServiceMock() + { + var categoryServiceMock = new Mock(); + + categoryServiceMock.Setup(x => + x.GetAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync((IList ids, string responseGroup, bool clone) => + { return Categories.Where(x => ids.Contains(x.Id)).ToList(); }); + + return categoryServiceMock; + } + + public Mock CreateProductServiceMock() + { + var productServiceMock = new Mock(); + + productServiceMock.Setup(x => + x.GetAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync((IList ids, string responseGroup, bool clone) => + { return Products.Where(x => ids.Contains(x.Id)).ToList(); }); + + return productServiceMock; + } + + public Mock> CreateCatalogRepositoryMock() + { + var repositoryFactoryMock = new Mock>(); + + var seoInfoEntities = SeoInfos.Select(x => new SeoInfoEntity + { + ItemId = x.ObjectType == "CatalogProduct" ? x.ObjectId : null, + CategoryId = x.ObjectType == "Category" ? x.ObjectId : null, + Keyword = x.SemanticUrl, + StoreId = x.StoreId, + Language = x.LanguageCode, + IsActive = x.IsActive + }).ToList().AsQueryable(); + + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: $"TestDb{Guid.NewGuid():N}") + .Options; + + var context = new CatalogDbContext(options); + context.Set().AddRange(seoInfoEntities); + context.SaveChanges(); + + var repository = new Mock(); + repository.Setup(r => r.SeoInfos).Returns(context.Set()); + + repositoryFactoryMock.Setup(f => f()).Returns(repository.Object); + return repositoryFactoryMock; + } + } +} + diff --git a/tests/VirtoCommerce.CatalogModule.Tests/CatalogSeoResolverTests.cs b/tests/VirtoCommerce.CatalogModule.Tests/CatalogSeoResolverTests.cs new file mode 100644 index 000000000..872c54602 --- /dev/null +++ b/tests/VirtoCommerce.CatalogModule.Tests/CatalogSeoResolverTests.cs @@ -0,0 +1,329 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using VirtoCommerce.CoreModule.Core.Seo; +using Xunit; + +namespace VirtoCommerce.CatalogModule.Tests +{ + public class CatalogSeoResolverTests + { + private const string ProductType = "CatalogProduct"; + private const string CategoryType = "Category"; + + public CatalogSeoResolverTests() + { + } + + [Fact] + public async Task FindSeoAsync_ProductWithoutConflict_ReturnsSingleSeoInfo() + { + // Setup + var helper = new CatalogHierarchyHelper(); + + helper.AddSeoInfo("product1", ProductType, "product", true, "B2B-store", "en-US"); + helper.AddSeoInfo("level3", CategoryType, "level3", true, "B2B-store", "en-US"); + + helper.AddCategory("level1", "level1"); + helper.AddCategory("level2", "level1/level2"); + helper.AddCategory("level3", "level1/level3", "level1/level2/level3"); + helper.AddCategory("level3-wrong", "level1/level3-wrong", "level1/level2/level3-wrong"); + helper.AddCategory("category", "level1/category", "level1/level2/category", "level1/level2/level3/category"); + helper.AddCategory("category-wrong", "level1/level2/level3-wrong/category-wrong"); + + helper.AddProduct("product1", "level1/level2/level3/product1"); + helper.AddProduct("product1-wrong", "level1/level2/level3-wrong/product1-wrong"); + + var seoResolver = helper.CreateCatalogSeoResolver(); + + // Arrange + var criteria = new SeoSearchCriteria { Permalink = "level1/level2/level3/product", StoreId = "B2B-store", LanguageCode = "en-US" }; + + // Act + var result = await seoResolver.FindSeoAsync(criteria); + + // Assert + Assert.Single(result); + Assert.Equal("product1", result.First().ObjectId); + } + + [Fact] + public async Task FindSeoAsync_TwoProductsWithConflictsAtLevel2_ReturnsSingleSeoInfo() + { + // Setup + var helper = new CatalogHierarchyHelper(); + + helper.AddSeoInfo("product1", ProductType, "product", true, "B2B-store", "en-US"); + helper.AddSeoInfo("product1-wrong", ProductType, "product", true, "B2B-store", "en-US"); + + helper.AddSeoInfo("level3", CategoryType, "level3", true, "B2B-store", "en-US"); + helper.AddSeoInfo("level3-wrong", CategoryType, "level3", true, "B2B-store", "en-US"); + helper.AddSeoInfo("level2", CategoryType, "level2", true, "B2B-store", "en-US"); + helper.AddSeoInfo("level2-wrong", CategoryType, "level2-wrong", true, "B2B-store", "en-US"); + helper.AddSeoInfo("level1", CategoryType, "level1", true, "B2B-store", "en-US"); + + helper.AddCategory("level1", "level1"); + helper.AddCategory("level2", "level1/level2"); + helper.AddCategory("level2-wrong", "level1/level2-wrong"); + helper.AddCategory("level3", "level1/level2/level3"); + helper.AddCategory("level3-wrong", "level1/level2-wrong/level3"); + + helper.AddProduct("product1", "level1/level2/level3/product1"); + helper.AddProduct("product1-wrong", "level1/level2-wrong/level3/product1-wrong"); + + var seoResolver = helper.CreateCatalogSeoResolver(); + + // Arrange + var criteria = new SeoSearchCriteria { Permalink = "level1/level2/level3/product", StoreId = "B2B-store", LanguageCode = "en-US" }; + + // Act + var result = await seoResolver.FindSeoAsync(criteria); + + // Assert + Assert.Single(result); + Assert.Equal("product1", result.First().ObjectId); + } + + [Fact] + public async Task FindSeoAsync_TwoCategoriesWithConflictsAtLevel3_ReturnsSingleSeoInfo() + { + // Arrange + var criteria = new SeoSearchCriteria { Permalink = "level1/level2/level3/category", StoreId = "B2B-store", LanguageCode = "en-US" }; + + var helper = new CatalogHierarchyHelper(); + + helper.AddSeoInfo("level3", CategoryType, "level3", true, "B2B-store", "en-US"); + helper.AddSeoInfo("level3-wrong", ProductType, "level3-wrong", true, "B2B-store", "en-US"); + helper.AddSeoInfo("category", CategoryType, "category", true, "B2B-store", "en-US"); + helper.AddSeoInfo("category-wrong", CategoryType, "category", true, "B2B-store", "en-US"); + + helper.AddCategory("level1", "level1"); + helper.AddCategory("level2", "level1/level2"); + helper.AddCategory("level3", "level1/level2/level3"); + helper.AddCategory("level3-wrong", "level1/level2/level3-wrong"); + helper.AddCategory("category", "level1/level2/level3/category"); + helper.AddCategory("category-wrong", "level1/level2/level3-wrong/category-wrong"); + + var seoResolver = helper.CreateCatalogSeoResolver(); + + // Act + var result = await seoResolver.FindSeoAsync(criteria); + + // Assert + Assert.Single(result); + Assert.Equal("category", result.First().ObjectId); + } + + [Fact] + public async Task FindSeoAsync_NonExistentPermalink_ReturnsEmptyList() + { + var helper = new CatalogHierarchyHelper(); + + helper.AddSeoInfo("level3", CategoryType, "level3", true, "B2B-store", "en-US"); + helper.AddSeoInfo("level3-wrong", ProductType, "level3", true, "B2B-store", "en-US"); + + helper.AddCategory("level1", "level1"); + helper.AddCategory("level2", "level1/level2"); + helper.AddCategory("level3", "level1/level3", "level1/level2/level3"); + helper.AddCategory("level3-wrong", "level1/level3-wrong", "level1/level2/level3-wrong"); + helper.AddCategory("category", "level1/category", "level1/level2/category", "level1/level2/level3/category"); + helper.AddCategory("category-wrong", "level1/level2/level3-wrong/category-wrong"); + + helper.AddProduct("product1", "level1/level2/level3/product1"); + helper.AddProduct("product1-wrong", "level1/level2/level3-wrong/product1-wrong"); + + // Arrange + var criteria = new SeoSearchCriteria { Permalink = "non-existent", StoreId = "B2B-store", LanguageCode = "en-US" }; + + var seoResolver = helper.CreateCatalogSeoResolver(); + + // Act + var result = await seoResolver.FindSeoAsync(criteria); + + // Assert + Assert.Empty(result); + } + + [Fact] + public async Task FindSeoAsync_TwoProductsWithConflictsAtLevel3_ReturnsSingleSeoInfo() + { + // Arrange + var criteria = new SeoSearchCriteria { Permalink = "level1/level2/level3/product", StoreId = "B2B-store", LanguageCode = "en-US" }; + + var helper = new CatalogHierarchyHelper(); + + helper.AddSeoInfo("product1", ProductType, "product", true, "B2B-store", "en-US"); + helper.AddSeoInfo("product1-wrong", ProductType, "product", true, "B2B-store", "en-US"); + helper.AddSeoInfo("level3", CategoryType, "level3", true, "B2B-store", "en-US"); + + helper.AddCategory("level1", "level1"); + helper.AddCategory("level2", "level1/level2"); + helper.AddCategory("level3", "level1/level3", "level1/level2/level3"); + helper.AddCategory("level3-wrong", "level1/level3-wrong", "level1/level2/level3-wrong"); + + helper.AddProduct("product1", "level1/level2/level3/product1"); + helper.AddProduct("product1-wrong", "level1/level2/level3-wrong/product1-wrong"); + + var seoResolver = helper.CreateCatalogSeoResolver(); + + // Act + var result = await seoResolver.FindSeoAsync(criteria); + + // Assert + Assert.Single(result); + Assert.Equal("product1", result.First().ObjectId); + } + + [Fact] + public async Task FindSeoAsync_TwoCategoriesWithSameSlugAtLevel2_ReturnsSingleSeoInfo() + { + // Arrange + var helper = new CatalogHierarchyHelper(); + + helper.AddSeoInfo("level3", CategoryType, "level3", true, "B2B-store", "en-US"); + helper.AddSeoInfo("level3-wrong", CategoryType, "level3-wrong", true, "B2B-store", "en-US"); + helper.AddSeoInfo("category", CategoryType, "category", true, "B2B-store", "en-US"); + helper.AddSeoInfo("category-wrong", CategoryType, "category", true, "B2B-store", "en-US"); + + helper.AddCategory("level1", "level1"); + helper.AddCategory("level2", "level1/level2"); + helper.AddCategory("level3", "level1/level3", "level1/level2/level3"); + helper.AddCategory("level3-wrong", "level1/level2/level3-wrong"); + helper.AddCategory("category", "level1/level2/level3/category"); + helper.AddCategory("category-wrong", "level1/level2/level3-wrong/category-wrong"); + + var seoResolver = helper.CreateCatalogSeoResolver(); + + // Act + var criteria = new SeoSearchCriteria { Permalink = "level1/level2/level3/category", StoreId = "B2B-store", LanguageCode = "en-US" }; + var result = await seoResolver.FindSeoAsync(criteria); + + // Assert + Assert.Single(result); + Assert.Equal("category", result.First().ObjectId); + Assert.Equal("Category", result.First().ObjectType); + } + + [Fact] + public async Task FindSeoAsync_SeoInfoForCurrentStoreOnly_ReturnsSingleSeoInfo() + { + // Arrange + var helper = new CatalogHierarchyHelper(); + + helper.AddSeoInfo("product1", ProductType, "product", true, "B2B-store", "en-US"); + helper.AddSeoInfo("product1", ProductType, "product", true, null, "en-US"); + helper.AddSeoInfo("product1-wrong", ProductType, "product", true, "Other-store", "en-US"); + + helper.AddCategory("level1", "level1"); + helper.AddCategory("level2", "level1/level2"); + helper.AddCategory("level3", "level1/level2/level3"); + + helper.AddProduct("product1", "level1/level2/level3/product1"); + helper.AddProduct("product1-wrong", "level1/level2/level3-wrong/product1-wrong"); + + var seoResolver = helper.CreateCatalogSeoResolver(); + + var criteria = new SeoSearchCriteria { Permalink = "level1/level2/level3/product", StoreId = "B2B-store", LanguageCode = "en-US" }; + + // Act + var result = await seoResolver.FindSeoAsync(criteria); + + // Assert + Assert.Single(result); + Assert.Equal("product1", result.First().ObjectId); + Assert.Equal("B2B-store", result.First().StoreId); + } + + [Fact] + public async Task FindSeoAsync_SeoInfoForNullStore_ReturnsSingleSeoInfo() + { + // Arrange + var helper = new CatalogHierarchyHelper(); + + helper.AddSeoInfo("product1", ProductType, "product", true, "B2B-store", "en-US"); + helper.AddSeoInfo("product1", ProductType, "product", true, null, "en-US"); + helper.AddSeoInfo("product1-wrong", ProductType, "product", true, "Other-store", "en-US"); + + helper.AddCategory("level1", "level1"); + helper.AddCategory("level2", "level1/level2"); + helper.AddCategory("level3", "level1/level2/level3"); + + helper.AddProduct("product1", "level1/level2/level3/product1"); + helper.AddProduct("product1-wrong", "level1/level2/level3-wrong/product1-wrong"); + + var seoResolver = helper.CreateCatalogSeoResolver(); + + var criteria = new SeoSearchCriteria { Permalink = "level1/level2/level3/product", StoreId = "B2B-store", LanguageCode = "en-US" }; + + // Act + var result = await seoResolver.FindSeoAsync(criteria); + + // Assert + Assert.Single(result); + Assert.Equal("product1", result.First().ObjectId); + Assert.Equal("B2B-store", result.First().StoreId); + } + + [Fact] + public async Task FindSeoAsync_TwoCategoriesWithSameSlugInRoot_ReturnsEmptyList() + { + // Arrange + var helper = new CatalogHierarchyHelper(); + + helper.AddSeoInfo("category1", CategoryType, "category", true, "B2B-store", "en-US"); + helper.AddSeoInfo("category2", CategoryType, "category", true, "B2B-store", "en-US"); + + helper.AddCategory("category1", "category"); + helper.AddCategory("category2", "category"); + + var seoResolver = helper.CreateCatalogSeoResolver(); + + var criteria = new SeoSearchCriteria { Permalink = "category", StoreId = "B2B-store", LanguageCode = "en-US" }; + + // Act + var result = await seoResolver.FindSeoAsync(criteria); + + // Assert + Assert.Empty(result); + } + + [Fact] + public Task Wrong_ProductId_ErrorHandling() + { + // Arrange + var helper = new CatalogHierarchyHelper(); + + helper.AddSeoInfo("product1", ProductType, "product", true, "B2B-store", "en-US"); + helper.AddSeoInfo("product2", ProductType, "product", true, "B2B-store", "en-US"); + helper.AddSeoInfo("category1", CategoryType, "category", true, "B2B-store", "en-US"); + helper.AddSeoInfo("category2", CategoryType, "category2", true, "B2B-store", "en-US"); + + var seoResolver = helper.CreateCatalogSeoResolver(); + + var criteria = new SeoSearchCriteria { Permalink = "category/product", StoreId = "B2B-store", LanguageCode = "en-US" }; + + // Act & Assert + return Assert.ThrowsAsync(() => seoResolver.FindSeoAsync(criteria)); + } + + [Fact] + public Task Wrong_CategoryId_ErrorHandling() + { + // Arrange + var helper = new CatalogHierarchyHelper(); + + helper.AddSeoInfo("category11", CategoryType, "category", true, "B2B-store", "en-US"); + helper.AddSeoInfo("category21", CategoryType, "category", true, "B2B-store", "en-US"); + helper.AddSeoInfo("category1", CategoryType, "level1", true, "B2B-store", "en-US"); + helper.AddSeoInfo("category2", CategoryType, "level1-wrong", true, "B2B-store", "en-US"); + + var seoResolver = helper.CreateCatalogSeoResolver(); + + var criteria = new SeoSearchCriteria { Permalink = "level1/category", StoreId = "B2B-store", LanguageCode = "en-US" }; + + // Act & Assert + return Assert.ThrowsAsync(() => seoResolver.FindSeoAsync(criteria)); + } + } +} +