Skip to content

Commit b9ab1a6

Browse files
VCST-4567: Add support for fetching category IDs by codes in Catalog API (#864)
Co-authored-by: Artem Dudarev <artem@virtoworks.com>
1 parent 8d6fd1c commit b9ab1a6

File tree

6 files changed

+489
-1
lines changed

6 files changed

+489
-1
lines changed

src/VirtoCommerce.CatalogModule.Core/Services/ICategoryService.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@ namespace VirtoCommerce.CatalogModule.Core.Services
88
public interface ICategoryService : IOuterEntityService<Category>
99
{
1010
Task<IList<Category>> GetByIdsAsync(IList<string> ids, string responseGroup, string catalogId);
11+
Task<IDictionary<string, string>> GetIdsByCodes(string catalogId, IList<string> codes);
1112
}
1213
}

src/VirtoCommerce.CatalogModule.Data/Services/CategoryService.cs

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,17 @@ public virtual Task<IList<Category>> GetByIdsAsync(IList<string> ids, string res
6868
return GetByIdsAsync(ids, responseGroup, clone: true, catalogId);
6969
}
7070

71+
public virtual async Task<IDictionary<string, string>> GetIdsByCodes(string catalogId, IList<string> codes)
72+
{
73+
var cacheKeyPrefix = CacheKey.With(GetType(), nameof(GetIdsByCodes), catalogId);
74+
75+
var models = await _platformMemoryCache.GetOrLoadByIdsAsync(cacheKeyPrefix, codes,
76+
missingCodes => GetIdsByCodesNoCache(catalogId, missingCodes),
77+
ConfigureCacheOptions);
78+
79+
return models.ToDictionary(x => x.Id, x => x.CategoryId, StringComparer.OrdinalIgnoreCase);
80+
}
81+
7182
protected virtual async Task<IList<Category>> GetByIdsAsync(IList<string> ids, string responseGroup, bool clone, string catalogId)
7283
{
7384
ids = ids
@@ -286,7 +297,7 @@ protected virtual async Task ValidateCategoryPropertiesAsync(IList<Category> cat
286297
{
287298
ArgumentNullException.ThrowIfNull(categories);
288299

289-
// Validate categories
300+
// Validate categories
290301
var validator = new CategoryValidator();
291302

292303
foreach (var category in categories)
@@ -333,6 +344,20 @@ protected virtual Category ReduceDetails(Category category, string responseGroup
333344
return category;
334345
}
335346

347+
protected virtual async Task<IList<CategoryCodeCacheItem>> GetIdsByCodesNoCache(string catalogId, IList<string> codes)
348+
{
349+
using var repository = _repositoryFactory();
350+
var query = repository.Categories.Where(x => x.CatalogId == catalogId);
351+
352+
query = codes.Count == 1
353+
? query.Where(x => x.Code == codes.First())
354+
: query.Where(x => codes.Contains(x.Code));
355+
356+
return await query
357+
.Select(x => new CategoryCodeCacheItem { Id = x.Code, CategoryId = x.Id })
358+
.ToListAsync();
359+
}
360+
336361
protected virtual async Task<IList<Category>> GetByIdsNoCache(IList<string> ids)
337362
{
338363
var categoryById = await GetAllRelatedCategories(ids);
@@ -405,6 +430,16 @@ protected virtual void ConfigureCacheOptions(MemoryCacheEntryOptions cacheOption
405430
}
406431
}
407432

433+
protected virtual void ConfigureCacheOptions(MemoryCacheEntryOptions cacheOptions, string id, CategoryCodeCacheItem model)
434+
{
435+
cacheOptions.AddExpirationToken(CatalogTreeCacheRegion.CreateChangeTokenForKey(id));
436+
437+
if (model is not null)
438+
{
439+
cacheOptions.AddExpirationToken(CatalogTreeCacheRegion.CreateChangeTokenForKey(model.CategoryId));
440+
}
441+
}
442+
408443
protected override void ClearCache(IList<Category> models)
409444
{
410445
ClearCacheAsync(models).GetAwaiter().GetResult();
@@ -476,5 +511,10 @@ protected virtual GenericChangedEntry<T> CreateDeletedEntry<T>(string id)
476511

477512
return entry;
478513
}
514+
515+
protected class CategoryCodeCacheItem : Entity
516+
{
517+
public string CategoryId { get; set; }
518+
}
479519
}
480520
}

src/VirtoCommerce.CatalogModule.Data/Services/ItemService.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,11 @@ protected virtual void ConfigureCache(MemoryCacheEntryOptions cacheOptions, stri
211211
{
212212
cacheOptions.AddExpirationToken(CatalogCacheRegion.CreateChangeToken());
213213
cacheOptions.AddExpirationToken(ItemCacheRegion.CreateChangeTokenForKey(id));
214+
215+
if (model is not null)
216+
{
217+
cacheOptions.AddExpirationToken(ItemCacheRegion.CreateChangeTokenForKey(model.ProductId));
218+
}
214219
}
215220

216221
protected override void ConfigureCache(MemoryCacheEntryOptions cacheOptions, string id, CatalogProduct model)

src/VirtoCommerce.CatalogModule.Web/Controllers/Api/CatalogModuleCategoriesController.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,20 @@ public Task<ActionResult<Category[]>> GetCategoriesByPlentyIds([FromBody] List<s
108108
return GetCategoriesByIdsAsync(ids, respGroup);
109109
}
110110

111+
/// <summary>
112+
/// Gets categories by codes
113+
/// </summary>
114+
/// <param name="catalogId">Catalog id</param>
115+
/// <param name="codes">Category codes</param>
116+
/// <param name="responseGroup">Response group</param>
117+
[HttpPost("~/api/catalog/{catalogId}/categories-by-codes")]
118+
public async Task<ActionResult<Category[]>> GetCategoriesByCodes([FromRoute] string catalogId, [FromBody] List<string> codes, [FromQuery] string responseGroup)
119+
{
120+
var idsByCodes = await categoryService.GetIdsByCodes(catalogId, codes);
121+
122+
return await GetCategoriesByIdsAsync([.. idsByCodes.Values], responseGroup);
123+
}
124+
111125
/// <summary>
112126
/// Gets the template for a new category.
113127
/// </summary>
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Threading;
5+
using System.Threading.Tasks;
6+
using FluentAssertions;
7+
using FluentValidation;
8+
using FluentValidation.Results;
9+
using Microsoft.Extensions.Caching.Memory;
10+
using Microsoft.Extensions.Logging;
11+
using Microsoft.Extensions.Options;
12+
using MockQueryable.Moq;
13+
using Moq;
14+
using VirtoCommerce.AssetsModule.Core.Assets;
15+
using VirtoCommerce.CatalogModule.Core.Model;
16+
using VirtoCommerce.CatalogModule.Core.Services;
17+
using VirtoCommerce.CatalogModule.Data.Model;
18+
using VirtoCommerce.CatalogModule.Data.Repositories;
19+
using VirtoCommerce.CatalogModule.Data.Services;
20+
using VirtoCommerce.Platform.Caching;
21+
using VirtoCommerce.Platform.Core.Caching;
22+
using VirtoCommerce.Platform.Core.Domain;
23+
using VirtoCommerce.Platform.Core.Events;
24+
using Xunit;
25+
26+
namespace VirtoCommerce.CatalogModule.Tests;
27+
28+
public class CategoryServiceGetIdsByCodesCacheTests
29+
{
30+
[Fact]
31+
public async Task GetIdsByCodes_CalledTwiceWithSameCode_LoadsFromRepositoryOnce()
32+
{
33+
// Arrange
34+
var service = GetCategoryService("category-id", "CODE", "catalog-id");
35+
36+
// Act
37+
var result1 = await service.GetIdsByCodes("catalog-id", ["CODE"]);
38+
var result2 = await service.GetIdsByCodes("catalog-id", ["CODE"]);
39+
40+
// Assert
41+
result1["CODE"].Should().Be("category-id");
42+
result2.Should().BeEquivalentTo(result1);
43+
service.GetIdsByCodesNoCacheCallsCount.Should().Be(1);
44+
}
45+
46+
[Fact]
47+
public async Task GetIdsByCodes_AfterCodeChange_DoesNotReturnCachedValue()
48+
{
49+
// Arrange
50+
var service = GetCategoryService("category-id", "CODE", "catalog-id");
51+
52+
// Act
53+
var result1 = await service.GetIdsByCodes("catalog-id", ["CODE"]);
54+
await service.SaveChangesAsync([CreateCategory("category-id", "CODE-NEW", "catalog-id")]);
55+
var result2 = await service.GetIdsByCodes("catalog-id", ["CODE"]);
56+
57+
// Assert
58+
result1["CODE"].Should().Be("category-id");
59+
result2.Should().BeEmpty();
60+
service.GetIdsByCodesNoCacheCallsCount.Should().Be(2);
61+
}
62+
63+
[Fact]
64+
public async Task GetIdsByCodes_AfterDelete_DoesNotReturnCachedValue()
65+
{
66+
// Arrange
67+
var service = GetCategoryService("category-id", "CODE", "catalog-id");
68+
69+
// Act
70+
var result1 = await service.GetIdsByCodes("catalog-id", ["CODE"]);
71+
await service.DeleteAsync(["category-id"]);
72+
var result2 = await service.GetIdsByCodes("catalog-id", ["CODE"]);
73+
74+
// Assert
75+
result1["CODE"].Should().Be("category-id");
76+
result2.Should().BeEmpty();
77+
service.GetIdsByCodesNoCacheCallsCount.Should().Be(2);
78+
}
79+
80+
81+
private static TestableCategoryService GetCategoryService(string categoryId, string categoryCode, string catalogId)
82+
{
83+
var categoryEntity = CreateCategoryEntity(categoryId, categoryCode, catalogId);
84+
var repository = GetCatalogRepository([categoryEntity]);
85+
86+
return new TestableCategoryService(
87+
() => repository,
88+
GetPlatformMemoryCache(),
89+
Mock.Of<IEventPublisher>(),
90+
GetPropertiesValidator(),
91+
GetCatalogService(catalogId),
92+
Mock.Of<IOutlineService>(),
93+
Mock.Of<IBlobUrlResolver>(),
94+
Mock.Of<IPropertyValueSanitizer>());
95+
}
96+
97+
private static CategoryEntity CreateCategoryEntity(string id, string code, string catalogId)
98+
{
99+
return new CategoryEntity
100+
{
101+
Id = id,
102+
Code = code,
103+
CatalogId = catalogId,
104+
Name = Guid.NewGuid().ToString(),
105+
};
106+
}
107+
108+
private static Category CreateCategory(string id, string code, string catalogId)
109+
{
110+
return new Category
111+
{
112+
Id = id,
113+
Code = code,
114+
CatalogId = catalogId,
115+
Name = Guid.NewGuid().ToString(),
116+
};
117+
}
118+
119+
private static ICatalogRepository GetCatalogRepository(List<CategoryEntity> categories)
120+
{
121+
var repositoryMock = new Mock<ICatalogRepository>();
122+
123+
repositoryMock
124+
.Setup(x => x.Categories)
125+
.Returns(categories.BuildMockDbSet().Object);
126+
127+
repositoryMock
128+
.Setup(x => x.GetCategoriesByIdsAsync(It.IsAny<IList<string>>(), It.IsAny<string>()))
129+
.ReturnsAsync((IList<string> ids, string _) =>
130+
{
131+
return categories.Where(x => ids.Contains(x.Id)).ToList();
132+
});
133+
134+
repositoryMock
135+
.Setup(x => x.RemoveCategoriesAsync(It.IsAny<IList<string>>()))
136+
.Callback((IList<string> ids) =>
137+
{
138+
categories.RemoveAll(x => ids.Contains(x.Id));
139+
});
140+
141+
repositoryMock
142+
.Setup(x => x.CategoryLinks)
143+
.Returns(new List<CategoryRelationEntity>().BuildMockDbSet().Object);
144+
145+
repositoryMock
146+
.Setup(x => x.Items)
147+
.Returns(new List<ItemEntity>().BuildMockDbSet().Object);
148+
149+
repositoryMock
150+
.Setup(x => x.GetAllChildrenCategoriesIdsAsync(It.IsAny<IList<string>>()))
151+
.ReturnsAsync(new List<string>());
152+
153+
repositoryMock.Setup(x => x.UnitOfWork).Returns(Mock.Of<IUnitOfWork>());
154+
155+
return repositoryMock.Object;
156+
}
157+
158+
private static PlatformMemoryCache GetPlatformMemoryCache()
159+
{
160+
var memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
161+
162+
var platformMemoryCache = new PlatformMemoryCache(
163+
memoryCache,
164+
Options.Create(new CachingOptions()),
165+
new Mock<ILogger<PlatformMemoryCache>>().Object);
166+
167+
return platformMemoryCache;
168+
}
169+
170+
private static AbstractValidator<IHasProperties> GetPropertiesValidator()
171+
{
172+
var hasPropertiesValidatorMock = new Mock<AbstractValidator<IHasProperties>>();
173+
174+
hasPropertiesValidatorMock
175+
.Setup(x => x.ValidateAsync(It.IsAny<ValidationContext<IHasProperties>>(), CancellationToken.None))
176+
.ReturnsAsync(new ValidationResult());
177+
178+
return hasPropertiesValidatorMock.Object;
179+
}
180+
181+
private static ICatalogService GetCatalogService(string catalogId)
182+
{
183+
var catalogServiceMock = new Mock<ICatalogService>();
184+
185+
catalogServiceMock
186+
.Setup(x => x.GetAsync(It.IsAny<IList<string>>(), It.IsAny<string>(), It.IsAny<bool>()))
187+
.ReturnsAsync([new Catalog { Id = catalogId }]);
188+
189+
return catalogServiceMock.Object;
190+
}
191+
192+
private sealed class TestableCategoryService(
193+
Func<ICatalogRepository> repositoryFactory,
194+
IPlatformMemoryCache platformMemoryCache,
195+
IEventPublisher eventPublisher,
196+
AbstractValidator<IHasProperties> hasPropertyValidator,
197+
ICatalogService catalogService,
198+
IOutlineService outlineService,
199+
IBlobUrlResolver blobUrlResolver,
200+
IPropertyValueSanitizer propertyValueSanitizer)
201+
: CategoryService(repositoryFactory, platformMemoryCache, eventPublisher, hasPropertyValidator, catalogService, outlineService, blobUrlResolver, propertyValueSanitizer)
202+
{
203+
public int GetIdsByCodesNoCacheCallsCount { get; private set; }
204+
205+
protected override Task<IList<CategoryCodeCacheItem>> GetIdsByCodesNoCache(string catalogId, IList<string> codes)
206+
{
207+
GetIdsByCodesNoCacheCallsCount++;
208+
return base.GetIdsByCodesNoCache(catalogId, codes);
209+
}
210+
}
211+
}

0 commit comments

Comments
 (0)