Skip to content

Commit

Permalink
feat: Add CatalogSeoResolver and related tests and helpers (#768)
Browse files Browse the repository at this point in the history
  • Loading branch information
OlegoO committed Feb 4, 2025
1 parent 129dbb7 commit b03df2c
Show file tree
Hide file tree
Showing 4 changed files with 631 additions and 0 deletions.
166 changes: 166 additions & 0 deletions src/VirtoCommerce.CatalogModule.Data/Services/CatalogSeoResolver.cs
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;
}
}

1 change: 1 addition & 0 deletions src/VirtoCommerce.CatalogModule.Web/Module.cs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ public void Initialize(IServiceCollection serviceCollection)
serviceCollection.AddTransient<VideoOwnerChangingEventHandler>();
serviceCollection.AddTransient<TrackSpecialChangesEventHandler>();

serviceCollection.AddTransient<ISeoResolver, CatalogSeoResolver>();
serviceCollection.AddTransient<ISeoBySlugResolver, CatalogSeoBySlugResolver>();

serviceCollection.AddTransient<IInternalListEntrySearchService, InternalListEntrySearchService>();
Expand Down
135 changes: 135 additions & 0 deletions tests/VirtoCommerce.CatalogModule.Tests/CatalogHierarchyHelper.cs
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;
}
}
}

Loading

0 comments on commit b03df2c

Please sign in to comment.