-
Notifications
You must be signed in to change notification settings - Fork 51
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add CatalogSeoResolver and related tests and helpers (#768)
- Loading branch information
Showing
4 changed files
with
631 additions
and
0 deletions.
There are no files selected for viewing
166 changes: 166 additions & 0 deletions
166
src/VirtoCommerce.CatalogModule.Data/Services/CatalogSeoResolver.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<ICatalogRepository> _repositoryFactory; | ||
private readonly ICategoryService _categoryService; | ||
private readonly IItemService _itemService; | ||
|
||
private const string CategoryObjectType = "Category"; | ||
private const string CatalogProductObjectType = "CatalogProduct"; | ||
|
||
public CatalogSeoResolver(Func<ICatalogRepository> repositoryFactory, | ||
ICategoryService categoryService, | ||
IItemService itemService) | ||
{ | ||
_repositoryFactory = repositoryFactory; | ||
_categoryService = categoryService; | ||
_itemService = itemService; | ||
} | ||
|
||
public async Task<IList<SeoInfo>> 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<bool> DoesParentMatchCategoryOutline(IList<string> 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<bool> DoesParentMatchProductOutline(IList<string> 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<List<SeoInfo>> 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<SeoInfo>.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; | ||
} | ||
} | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
135 changes: 135 additions & 0 deletions
135
tests/VirtoCommerce.CatalogModule.Tests/CatalogHierarchyHelper.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<CatalogProduct> Products { get; private set; } | ||
public List<Category> Categories { get; private set; } | ||
public List<SeoInfo> SeoInfos { get; private set; } | ||
|
||
public CatalogHierarchyHelper() | ||
{ | ||
Products = new List<CatalogProduct>(); | ||
Categories = new List<Category>(); | ||
SeoInfos = new List<SeoInfo>(); | ||
} | ||
|
||
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<ICategoryService> CreateCategoryServiceMock() | ||
{ | ||
var categoryServiceMock = new Mock<ICategoryService>(); | ||
|
||
categoryServiceMock.Setup(x => | ||
x.GetAsync(It.IsAny<IList<string>>(), It.IsAny<string>(), It.IsAny<bool>())) | ||
.ReturnsAsync((IList<string> ids, string responseGroup, bool clone) => | ||
{ return Categories.Where(x => ids.Contains(x.Id)).ToList(); }); | ||
|
||
return categoryServiceMock; | ||
} | ||
|
||
public Mock<IItemService> CreateProductServiceMock() | ||
{ | ||
var productServiceMock = new Mock<IItemService>(); | ||
|
||
productServiceMock.Setup(x => | ||
x.GetAsync(It.IsAny<IList<string>>(), It.IsAny<string>(), It.IsAny<bool>())) | ||
.ReturnsAsync((IList<string> ids, string responseGroup, bool clone) => | ||
{ return Products.Where(x => ids.Contains(x.Id)).ToList(); }); | ||
|
||
return productServiceMock; | ||
} | ||
|
||
public Mock<Func<ICatalogRepository>> CreateCatalogRepositoryMock() | ||
{ | ||
var repositoryFactoryMock = new Mock<Func<ICatalogRepository>>(); | ||
|
||
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<CatalogDbContext>() | ||
.UseInMemoryDatabase(databaseName: $"TestDb{Guid.NewGuid():N}") | ||
.Options; | ||
|
||
var context = new CatalogDbContext(options); | ||
context.Set<SeoInfoEntity>().AddRange(seoInfoEntities); | ||
context.SaveChanges(); | ||
|
||
var repository = new Mock<ICatalogRepository>(); | ||
repository.Setup(r => r.SeoInfos).Returns(context.Set<SeoInfoEntity>()); | ||
|
||
repositoryFactoryMock.Setup(f => f()).Returns(repository.Object); | ||
return repositoryFactoryMock; | ||
} | ||
} | ||
} | ||
|
Oops, something went wrong.