Skip to content

Commit 36588c6

Browse files
authored
feat: Add CatalogSeoResolver and related tests and helpers (#768)
1 parent 7a7b144 commit 36588c6

File tree

5 files changed

+632
-0
lines changed

5 files changed

+632
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Threading.Tasks;
5+
using Microsoft.EntityFrameworkCore;
6+
using VirtoCommerce.CatalogModule.Core.Model;
7+
using VirtoCommerce.CatalogModule.Core.Services;
8+
using VirtoCommerce.CatalogModule.Data.Repositories;
9+
using VirtoCommerce.CoreModule.Core.Seo;
10+
using VirtoCommerce.Platform.Core.Common;
11+
12+
namespace VirtoCommerce.CatalogModule.Data.Services;
13+
14+
public class CatalogSeoResolver : ISeoResolver
15+
{
16+
private readonly Func<ICatalogRepository> _repositoryFactory;
17+
private readonly ICategoryService _categoryService;
18+
private readonly IItemService _itemService;
19+
20+
private const string CategoryObjectType = "Category";
21+
private const string CatalogProductObjectType = "CatalogProduct";
22+
23+
public CatalogSeoResolver(Func<ICatalogRepository> repositoryFactory,
24+
ICategoryService categoryService,
25+
IItemService itemService)
26+
{
27+
_repositoryFactory = repositoryFactory;
28+
_categoryService = categoryService;
29+
_itemService = itemService;
30+
}
31+
32+
public async Task<IList<SeoInfo>> FindSeoAsync(SeoSearchCriteria criteria)
33+
{
34+
ArgumentNullException.ThrowIfNull(criteria);
35+
36+
var permalink = criteria.Permalink ?? string.Empty;
37+
var segments = permalink.Split('/', StringSplitOptions.RemoveEmptyEntries).ToArray();
38+
if (segments.Length == 0)
39+
{
40+
return [];
41+
}
42+
43+
var currentEntitySeoInfos = await SearchSeoInfos(criteria.StoreId, criteria.LanguageCode, segments.Last());
44+
45+
if (currentEntitySeoInfos.Count == 0)
46+
{
47+
// Try to find deactivated seo entries and revert it back if we found it
48+
currentEntitySeoInfos = await SearchSeoInfos(criteria.StoreId, criteria.LanguageCode, segments.Last(), false);
49+
if (currentEntitySeoInfos.Count == 0)
50+
{
51+
return [];
52+
}
53+
}
54+
55+
var groups = currentEntitySeoInfos.GroupBy(x => new { x.ObjectType, x.ObjectId });
56+
57+
// We found seo information by seo search criteria
58+
if (groups.Count() == 1)
59+
{
60+
return [currentEntitySeoInfos.First()];
61+
}
62+
63+
// It's not possibe to resolve because we don't have parent segment
64+
if (segments.Length == 1)
65+
{
66+
return [];
67+
}
68+
69+
// We found multiple seo information by seo search criteria, need to find correct by checking parent.
70+
var parentSearchCriteria = criteria.Clone() as SeoSearchCriteria;
71+
parentSearchCriteria.Permalink = string.Join('/', segments.Take(segments.Length - 1));
72+
var parentSeoInfos = await FindSeoAsync(parentSearchCriteria);
73+
74+
if (parentSeoInfos.Count == 0)
75+
{
76+
return [];
77+
}
78+
79+
var parentCategorieIds = parentSeoInfos.Select(x => x.ObjectId).Distinct().ToList();
80+
81+
foreach (var groupKey in groups.Select(g => g.Key))
82+
{
83+
if (groupKey.ObjectType == CategoryObjectType)
84+
{
85+
var isMatch = await DoesParentMatchCategoryOutline(parentCategorieIds, groupKey.ObjectId);
86+
if (isMatch)
87+
{
88+
return currentEntitySeoInfos.Where(x =>
89+
x.ObjectId == groupKey.ObjectId
90+
&& groupKey.ObjectType == CategoryObjectType).ToList();
91+
}
92+
}
93+
94+
// Inside the method
95+
else if (groupKey.ObjectType == CatalogProductObjectType)
96+
{
97+
var isMatch = await DoesParentMatchProductOutline(parentCategorieIds, groupKey.ObjectId);
98+
99+
if (isMatch)
100+
{
101+
return currentEntitySeoInfos.Where(x =>
102+
x.ObjectId == groupKey.ObjectId
103+
&& groupKey.ObjectType == CatalogProductObjectType).ToList();
104+
}
105+
}
106+
}
107+
108+
return [];
109+
}
110+
111+
private async Task<bool> DoesParentMatchCategoryOutline(IList<string> parentCategorieIds, string objectId)
112+
{
113+
var category = await _categoryService.GetByIdAsync(objectId, CategoryResponseGroup.WithOutlines.ToString(), false);
114+
if (category == null)
115+
{
116+
throw new InvalidOperationException($"Category with ID '{objectId}' was not found.");
117+
}
118+
var outlines = category.Outlines.Select(x => x.Items.Skip(x.Items.Count - 2).First().Id).Distinct().ToList();
119+
return outlines.Any(parentCategorieIds.Contains);
120+
}
121+
122+
private async Task<bool> DoesParentMatchProductOutline(IList<string> parentCategorieIds, string objectId)
123+
{
124+
var product = await _itemService.GetByIdAsync(objectId, CategoryResponseGroup.WithOutlines.ToString(), false);
125+
if (product == null)
126+
{
127+
throw new InvalidOperationException($"Product with ID '{objectId}' was not found.");
128+
}
129+
var outlines = product.Outlines.Select(x => x.Items.Skip(x.Items.Count - 2).First().Id).Distinct().ToList();
130+
return outlines.Any(parentCategorieIds.Contains);
131+
}
132+
133+
private async Task<List<SeoInfo>> SearchSeoInfos(string storeId, string languageCode, string slug, bool isActive = true)
134+
{
135+
using var repository = _repositoryFactory();
136+
137+
return (await repository.SeoInfos.Where(s => s.IsActive == isActive
138+
&& s.Keyword == slug
139+
&& (s.StoreId == null || s.StoreId == storeId)
140+
&& (s.Language == null || s.Language == languageCode))
141+
.ToListAsync())
142+
.Select(x => x.ToModel(AbstractTypeFactory<SeoInfo>.TryCreateInstance()))
143+
.OrderByDescending(s => GetPriorityScore(s, storeId, languageCode))
144+
.ToList();
145+
}
146+
147+
private static int GetPriorityScore(SeoInfo seoInfo, string storeId, string language)
148+
{
149+
var score = 0;
150+
var hasStoreCriteria = !string.IsNullOrEmpty(storeId);
151+
var hasLangCriteria = !string.IsNullOrEmpty(language);
152+
153+
if (hasStoreCriteria && seoInfo.StoreId == storeId)
154+
{
155+
score += 2;
156+
}
157+
158+
if (hasLangCriteria && seoInfo.LanguageCode == language)
159+
{
160+
score += 1;
161+
}
162+
163+
return score;
164+
}
165+
}
166+

src/VirtoCommerce.CatalogModule.Web/Module.cs

+1
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ public void Initialize(IServiceCollection serviceCollection)
153153
serviceCollection.AddTransient<VideoOwnerChangingEventHandler>();
154154
serviceCollection.AddTransient<TrackSpecialChangesEventHandler>();
155155

156+
serviceCollection.AddTransient<ISeoResolver, CatalogSeoResolver>();
156157
serviceCollection.AddTransient<ISeoBySlugResolver, CatalogSeoBySlugResolver>();
157158

158159
serviceCollection.AddTransient<IInternalListEntrySearchService, InternalListEntrySearchService>();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using Microsoft.EntityFrameworkCore;
5+
using Moq;
6+
using VirtoCommerce.CatalogModule.Core.Model;
7+
using VirtoCommerce.CatalogModule.Core.Services;
8+
using VirtoCommerce.CatalogModule.Data.Model;
9+
using VirtoCommerce.CatalogModule.Data.Repositories;
10+
using VirtoCommerce.CatalogModule.Data.Services;
11+
using VirtoCommerce.CoreModule.Core.Outlines;
12+
using VirtoCommerce.CoreModule.Core.Seo;
13+
14+
namespace VirtoCommerce.CatalogModule.Tests
15+
{
16+
public class CatalogHierarchyHelper
17+
{
18+
public List<CatalogProduct> Products { get; private set; }
19+
public List<Category> Categories { get; private set; }
20+
public List<SeoInfo> SeoInfos { get; private set; }
21+
22+
public CatalogHierarchyHelper()
23+
{
24+
Products = new List<CatalogProduct>();
25+
Categories = new List<Category>();
26+
SeoInfos = new List<SeoInfo>();
27+
}
28+
29+
public void AddProduct(string productId, params string[] outlineIds)
30+
{
31+
var product = new CatalogProduct
32+
{
33+
Id = productId,
34+
Outlines = outlineIds.Select(id => new Outline
35+
{
36+
Items = id.Split('/').Select(outlineId => new OutlineItem { Id = outlineId }).ToList()
37+
}).ToList()
38+
};
39+
Products.Add(product);
40+
}
41+
42+
public void AddCategory(string categoryId, params string[] outlineIds)
43+
{
44+
var category = new Category
45+
{
46+
Id = categoryId,
47+
Outlines = outlineIds.Select(id => new Outline
48+
{
49+
Items = id.Split('/').Select(outlineId => new OutlineItem { Id = outlineId }).ToList()
50+
}).ToList()
51+
};
52+
Categories.Add(category);
53+
}
54+
55+
public void AddSeoInfo(string objectId, string objectType, string semanticUrl, bool isActive = true, string storeId = null, string languageCode = null)
56+
{
57+
var seoInfo = new SeoInfo
58+
{
59+
ObjectId = objectId,
60+
ObjectType = objectType,
61+
SemanticUrl = semanticUrl,
62+
IsActive = isActive,
63+
StoreId = storeId,
64+
LanguageCode = languageCode
65+
};
66+
SeoInfos.Add(seoInfo);
67+
}
68+
69+
public CatalogSeoResolver CreateCatalogSeoResolver()
70+
{
71+
var catalogRepositoryMock = CreateCatalogRepositoryMock();
72+
var categoryServiceMock = CreateCategoryServiceMock();
73+
var productServiceMock = CreateProductServiceMock();
74+
75+
return new CatalogSeoResolver(
76+
catalogRepositoryMock.Object,
77+
categoryServiceMock.Object,
78+
productServiceMock.Object);
79+
}
80+
81+
public Mock<ICategoryService> CreateCategoryServiceMock()
82+
{
83+
var categoryServiceMock = new Mock<ICategoryService>();
84+
85+
categoryServiceMock.Setup(x =>
86+
x.GetAsync(It.IsAny<IList<string>>(), It.IsAny<string>(), It.IsAny<bool>()))
87+
.ReturnsAsync((IList<string> ids, string responseGroup, bool clone) =>
88+
{ return Categories.Where(x => ids.Contains(x.Id)).ToList(); });
89+
90+
return categoryServiceMock;
91+
}
92+
93+
public Mock<IItemService> CreateProductServiceMock()
94+
{
95+
var productServiceMock = new Mock<IItemService>();
96+
97+
productServiceMock.Setup(x =>
98+
x.GetAsync(It.IsAny<IList<string>>(), It.IsAny<string>(), It.IsAny<bool>()))
99+
.ReturnsAsync((IList<string> ids, string responseGroup, bool clone) =>
100+
{ return Products.Where(x => ids.Contains(x.Id)).ToList(); });
101+
102+
return productServiceMock;
103+
}
104+
105+
public Mock<Func<ICatalogRepository>> CreateCatalogRepositoryMock()
106+
{
107+
var repositoryFactoryMock = new Mock<Func<ICatalogRepository>>();
108+
109+
var seoInfoEntities = SeoInfos.Select(x => new SeoInfoEntity
110+
{
111+
ItemId = x.ObjectType == "CatalogProduct" ? x.ObjectId : null,
112+
CategoryId = x.ObjectType == "Category" ? x.ObjectId : null,
113+
Keyword = x.SemanticUrl,
114+
StoreId = x.StoreId,
115+
Language = x.LanguageCode,
116+
IsActive = x.IsActive
117+
}).ToList().AsQueryable();
118+
119+
var options = new DbContextOptionsBuilder<CatalogDbContext>()
120+
.UseInMemoryDatabase(databaseName: $"TestDb{Guid.NewGuid():N}")
121+
.Options;
122+
123+
var context = new CatalogDbContext(options);
124+
context.Set<SeoInfoEntity>().AddRange(seoInfoEntities);
125+
context.SaveChanges();
126+
127+
var repository = new Mock<ICatalogRepository>();
128+
repository.Setup(r => r.SeoInfos).Returns(context.Set<SeoInfoEntity>());
129+
130+
repositoryFactoryMock.Setup(f => f()).Returns(repository.Object);
131+
return repositoryFactoryMock;
132+
}
133+
}
134+
}
135+

0 commit comments

Comments
 (0)