diff --git a/src/Business/Grand.Business.Catalog/Queries/Handlers/GetSearchProductsQueryHandler.cs b/src/Business/Grand.Business.Catalog/Queries/Handlers/GetSearchProductsQueryHandler.cs index d45218416..0e3dd4fe3 100644 --- a/src/Business/Grand.Business.Catalog/Queries/Handlers/GetSearchProductsQueryHandler.cs +++ b/src/Business/Grand.Business.Catalog/Queries/Handlers/GetSearchProductsQueryHandler.cs @@ -4,9 +4,14 @@ using Grand.Domain; using Grand.Domain.Catalog; using Grand.Domain.Customers; +using Grand.Infrastructure.Caching; +using Grand.Infrastructure.Caching.Constants; using Grand.Infrastructure.Configuration; using Grand.Mediator; using Microsoft.Extensions.Logging; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; namespace Grand.Business.Catalog.Queries.Handlers; @@ -15,7 +20,11 @@ public class GetSearchProductsQueryHandler : IRequestHandler productRepository, ISpecificationAttributeService specificationAttributeService, + ICacheBase cacheBase, CatalogSettings catalogSettings, AccessControlConfig accessControlConfig, DatabaseConfig databaseConfig, @@ -34,6 +44,7 @@ public GetSearchProductsQueryHandler( { _productRepository = productRepository; _specificationAttributeService = specificationAttributeService; + _cacheBase = cacheBase; _catalogSettings = catalogSettings; _accessControlConfig = accessControlConfig; _databaseConfig = databaseConfig; @@ -89,27 +100,108 @@ public GetSearchProductsQueryHandler( // Apply specification filters (query, querySpecification) = await ApplySpecificationFilters(request, query, querySpecification); + // A listing page reads its total and filter options from a short-lived cache: both depend on the filters + // only, not on the page or the order, and each costs a pass over every match + if (request.CacheListingSummary && !request.ShowHidden) + return await SearchWithCachedSummary(request, allowedCustomerGroupsIds, query, querySpecification, + cancellationToken); + // Order the results query = OrderByQueryable(request, query); - // The page and the filterable specification option ids are independent, so both queries run at once; - // the first failure cancels the other one instead of waiting for it - using var queriesCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + // The page and the filterable specification option ids are independent, so both queries run at once + return await WhenBoth( + token => _productRepository.PagedAsync(query, request.PageIndex, request.PageSize, token), + token => ShouldLoadFilterableSpecifications(request) + ? GetFilterableSpecificationAttributeOptionIds(querySpecification, token) + : Task.FromResult>(new List()), + cancellationToken); + } + + private async Task<(IPagedList products, IList filterableSpecificationAttributeOptionIds)> + SearchWithCachedSummary(GetSearchProductsQuery request, string[] allowedCustomerGroupsIds, + IQueryable query, IQueryable querySpecification, CancellationToken cancellationToken) + { + var key = string.Format(CacheKey.PRODUCTS_LISTING_SUMMARY_KEY, + ListingSummaryHash(request, allowedCustomerGroupsIds)); + + //same normalization as IRepository.PagedAsync + var pageSize = request.PageSize <= 0 ? 1 : request.PageSize; + var skip = (int)Math.Clamp((long)request.PageIndex * pageSize, 0, int.MaxValue); - // Create paged list first - LiteDB completes synchronously, so the page still runs before the options there - var productsTask = CancelOnFailure( - _productRepository.PagedAsync(query, request.PageIndex, request.PageSize, queriesCancellation.Token), - queriesCancellation); + var (items, summary) = await WhenBoth( + token => _productRepository.ToListAsync(OrderByQueryable(request, query).Skip(skip).Take(pageSize), token), + //a miss is computed once for all waiting requests, so it does not stop when the first of them is aborted + token => _cacheBase.GetAsync(key, + () => LoadListingSummary(request, query, querySpecification, CancellationToken.None), + ListingSummaryCacheMinutes) + .WaitAsync(token), + cancellationToken); - // Get filterable specification attributes if needed + //a short page is the last one and gives the exact total; a full page proves skip + pageSize products and + //an empty one past the end proves at most skip, so a lagging cached total never contradicts the page shown + var totalCount = items.Count == pageSize + ? Math.Max(summary.TotalCount, skip + pageSize) + : items.Count > 0 || skip == 0 ? skip + items.Count : Math.Min(summary.TotalCount, skip); + + return (new PagedList(items, request.PageIndex, pageSize, totalCount), + summary.FilterableSpecificationAttributeOptionIds.ToList()); + } + + private async Task LoadListingSummary(GetSearchProductsQuery request, IQueryable query, + IQueryable querySpecification, CancellationToken cancellationToken) + { + var countTask = _productRepository.CountAsync(query, cancellationToken); var filterableTask = ShouldLoadFilterableSpecifications(request) - ? CancelOnFailure(GetFilterableSpecificationAttributeOptionIds(querySpecification, queriesCancellation.Token), - queriesCancellation) + ? GetFilterableSpecificationAttributeOptionIds(querySpecification, cancellationToken) : Task.FromResult>(new List()); + await Task.WhenAll(countTask, filterableTask); + + return new ListingSummary(await countTask, (await filterableTask).ToArray()); + } + + /// + /// Hash of every request value that changes which products match; page and order are left out on purpose + /// + private string ListingSummaryHash(GetSearchProductsQuery request, string[] allowedCustomerGroupsIds) + { + static string[] Set(IEnumerable values) + { + return values?.Order(StringComparer.Ordinal).ToArray() ?? []; + } + + //JSON keeps the fields apart - a separator inside a tag or an id is escaped - and formats numbers invariantly + var filters = JsonSerializer.Serialize(new object[] { + Set(request.CategoryIds), request.FeaturedProducts, request.BrandId, request.CollectionId, + request.StoreId, request.VendorId, request.WarehouseId, request.ProductType, + request.VisibleIndividuallyOnly, request.MarkedAsNewOnly, request.ShowOnHomePage, + request.OverridePublished, request.PriceMin, request.PriceMax, request.Rating, request.ProductTag, + Set(allowedCustomerGroupsIds), Set(request.FilteredSpecs), Set(request.SpecificationOptions), + ShouldLoadFilterableSpecifications(request), request.SearchDescriptions, request.SearchSku, + request.Keywords + }); + + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(filters))); + } + + private sealed record ListingSummary(int TotalCount, string[] FilterableSpecificationAttributeOptionIds); + + /// + /// Runs two independent queries at once; the first failure cancels the other one instead of waiting for it + /// + private static async Task<(T1, T2)> WhenBoth(Func> first, + Func> second, CancellationToken cancellationToken) + { + using var queriesCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + // The first one starts first - LiteDB completes synchronously, so there it still runs before the second + var firstTask = CancelOnFailure(first(queriesCancellation.Token), queriesCancellation); + var secondTask = CancelOnFailure(second(queriesCancellation.Token), queriesCancellation); + try { - await Task.WhenAll(productsTask, filterableTask); + await Task.WhenAll(firstTask, secondTask); } catch when (cancellationToken.IsCancellationRequested) { @@ -119,7 +211,7 @@ public GetSearchProductsQueryHandler( throw; } - return (await productsTask, await filterableTask); + return (await firstTask, await secondTask); } private static async Task CancelOnFailure(Task task, CancellationTokenSource cancellation) diff --git a/src/Business/Grand.Business.Catalog/Services/Categories/ProductCategoryService.cs b/src/Business/Grand.Business.Catalog/Services/Categories/ProductCategoryService.cs index 6dba39917..971e7a1ca 100644 --- a/src/Business/Grand.Business.Catalog/Services/Categories/ProductCategoryService.cs +++ b/src/Business/Grand.Business.Catalog/Services/Categories/ProductCategoryService.cs @@ -103,6 +103,7 @@ public virtual async Task InsertProductCategory(ProductCategory productCategory, //cache await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTCATEGORIES_PATTERN_KEY); await _cacheBase.RemoveByPrefix(string.Format(CacheKey.PRODUCTS_BY_ID_KEY, productId)); + await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTS_LISTING_PATTERN_KEY); //event notification await _mediator.EntityInserted(productCategory); @@ -123,6 +124,7 @@ await _productRepository.UpdateCollectionFieldItem(productId, x => x.ProductCate //cache await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTCATEGORIES_PATTERN_KEY); await _cacheBase.RemoveByPrefix(string.Format(CacheKey.PRODUCTS_BY_ID_KEY, productId)); + await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTS_LISTING_PATTERN_KEY); //event notification await _mediator.EntityUpdated(productCategory); @@ -142,6 +144,7 @@ public virtual async Task DeleteProductCategory(ProductCategory productCategory, //cache await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTCATEGORIES_PATTERN_KEY); await _cacheBase.RemoveByPrefix(string.Format(CacheKey.PRODUCTS_BY_ID_KEY, productId)); + await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTS_LISTING_PATTERN_KEY); //event notification await _mediator.EntityDeleted(productCategory); diff --git a/src/Business/Grand.Business.Catalog/Services/Collections/ProductCollectionService.cs b/src/Business/Grand.Business.Catalog/Services/Collections/ProductCollectionService.cs index 9a2f8bac0..7000437ea 100644 --- a/src/Business/Grand.Business.Catalog/Services/Collections/ProductCollectionService.cs +++ b/src/Business/Grand.Business.Catalog/Services/Collections/ProductCollectionService.cs @@ -98,6 +98,7 @@ public virtual async Task InsertProductCollection(ProductCollection productColle //cache await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTCOLLECTIONS_PATTERN_KEY); await _cacheBase.RemoveByPrefix(string.Format(CacheKey.PRODUCTS_BY_ID_KEY, productId)); + await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTS_LISTING_PATTERN_KEY); //event notification await _mediator.EntityInserted(productCollection); @@ -118,6 +119,7 @@ await _productRepository.UpdateCollectionFieldItem(productId, x => x.ProductColl //cache await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTCOLLECTIONS_PATTERN_KEY); await _cacheBase.RemoveByPrefix(string.Format(CacheKey.PRODUCTS_BY_ID_KEY, productId)); + await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTS_LISTING_PATTERN_KEY); //event notification await _mediator.EntityUpdated(productCollection); @@ -137,6 +139,7 @@ public virtual async Task DeleteProductCollection(ProductCollection productColle //cache await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTCOLLECTIONS_PATTERN_KEY); await _cacheBase.RemoveByPrefix(string.Format(CacheKey.PRODUCTS_BY_ID_KEY, productId)); + await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTS_LISTING_PATTERN_KEY); //event notification await _mediator.EntityDeleted(productCollection); diff --git a/src/Business/Grand.Business.Catalog/Services/Products/ProductService.cs b/src/Business/Grand.Business.Catalog/Services/Products/ProductService.cs index 60cae6591..9bf207bc5 100644 --- a/src/Business/Grand.Business.Catalog/Services/Products/ProductService.cs +++ b/src/Business/Grand.Business.Catalog/Services/Products/ProductService.cs @@ -363,6 +363,7 @@ public virtual async Task UpdateProduct(Product product) await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTS_CUSTOMER_PERSONAL_PATTERN); await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTS_CUSTOMER_GROUP_PATTERN); await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTS_HOMEPAGE_PATTERN); + await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTS_LISTING_PATTERN_KEY); await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTS_CUSTOMER_TAG_PATTERN); //event notification @@ -429,6 +430,7 @@ public virtual async Task UnPublishProduct(Product product) await _productRepository.UpdateOneAsync(x => x.Id == product.Id, update); await _cacheBase.RemoveByPrefix(string.Format(CacheKey.PRODUCTS_BY_ID_KEY, product.Id)); + await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTS_LISTING_PATTERN_KEY); //event await _mediator.Publish(new ProductUnPublishEvent(product)); diff --git a/src/Business/Grand.Business.Catalog/Services/Products/ProductTagService.cs b/src/Business/Grand.Business.Catalog/Services/Products/ProductTagService.cs index de83876e7..de52569e2 100644 --- a/src/Business/Grand.Business.Catalog/Services/Products/ProductTagService.cs +++ b/src/Business/Grand.Business.Catalog/Services/Products/ProductTagService.cs @@ -190,6 +190,7 @@ public virtual async Task AttachProductTag(ProductTag productTag, string product //cache await _cacheBase.RemoveByPrefix(string.Format(CacheKey.PRODUCTS_BY_ID_KEY, productId)); + await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTS_LISTING_PATTERN_KEY); await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTTAG_PATTERN_KEY); //event notification @@ -212,6 +213,7 @@ public virtual async Task DetachProductTag(ProductTag productTag, string product //cache await _cacheBase.RemoveByPrefix(string.Format(CacheKey.PRODUCTS_BY_ID_KEY, productId)); + await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTS_LISTING_PATTERN_KEY); await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTTAG_PATTERN_KEY); //event notification diff --git a/src/Business/Grand.Business.Catalog/Services/Products/SpecificationAttributeService.cs b/src/Business/Grand.Business.Catalog/Services/Products/SpecificationAttributeService.cs index 86b414ebe..6686a3b64 100644 --- a/src/Business/Grand.Business.Catalog/Services/Products/SpecificationAttributeService.cs +++ b/src/Business/Grand.Business.Catalog/Services/Products/SpecificationAttributeService.cs @@ -230,6 +230,7 @@ await _productRepository.AddToCollectionField(productId, x => x.ProductSpecifica //cache await _cacheBase.RemoveByPrefix(string.Format(CacheKey.PRODUCTS_BY_ID_KEY, productId)); + await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTS_LISTING_PATTERN_KEY); //event notification await _mediator.EntityInserted(productSpecificationAttribute); @@ -249,6 +250,7 @@ public virtual async Task UpdateProductSpecificationAttribute( //cache await _cacheBase.RemoveByPrefix(string.Format(CacheKey.PRODUCTS_BY_ID_KEY, productId)); + await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTS_LISTING_PATTERN_KEY); //event notification await _mediator.EntityUpdated(productSpecificationAttribute); @@ -268,6 +270,7 @@ public virtual async Task DeleteProductSpecificationAttribute( //clear cache await _cacheBase.RemoveByPrefix(string.Format(CacheKey.PRODUCTS_BY_ID_KEY, productId)); + await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTS_LISTING_PATTERN_KEY); //event notification await _mediator.EntityDeleted(productSpecificationAttribute); diff --git a/src/Business/Grand.Business.Core/Queries/Catalog/GetSearchProductsQuery.cs b/src/Business/Grand.Business.Core/Queries/Catalog/GetSearchProductsQuery.cs index 5c2c7c387..ec79a6620 100644 --- a/src/Business/Grand.Business.Core/Queries/Catalog/GetSearchProductsQuery.cs +++ b/src/Business/Grand.Business.Core/Queries/Catalog/GetSearchProductsQuery.cs @@ -44,4 +44,11 @@ public class GetSearchProductsQuery : IRequest<(IPagedList products, IL public ProductSortingEnum OrderBy { get; set; } = ProductSortingEnum.Position; public bool ShowHidden { get; set; } public bool? OverridePublished { get; set; } + + /// + /// Reads the total and the filterable specification option ids from a short-lived cache shared by every + /// request with the same filters, so they may lag behind writes for a few minutes. For storefront listing pages; + /// ignored with + /// + public bool CacheListingSummary { get; set; } } \ No newline at end of file diff --git a/src/Core/Grand.Infrastructure/Caching/Constants/ProductCacheKey.cs b/src/Core/Grand.Infrastructure/Caching/Constants/ProductCacheKey.cs index f2a5553cb..dd4becd96 100644 --- a/src/Core/Grand.Infrastructure/Caching/Constants/ProductCacheKey.cs +++ b/src/Core/Grand.Infrastructure/Caching/Constants/ProductCacheKey.cs @@ -15,6 +15,20 @@ public static partial class CacheKey /// public static string PRODUCTS_BY_ID_KEY => "Grand.product.id-{0}"; + /// + /// Key for caching the total count and the filterable specification option ids of a storefront product listing + /// + /// + /// {0} : hash of every listing filter - categories, brand, collection, store, customer groups, specifications, + /// price, rating, keywords, flags + /// + public static string PRODUCTS_LISTING_SUMMARY_KEY => "Grand.product.listing-{0}"; + + /// + /// Key pattern to clear cache + /// + public static string PRODUCTS_LISTING_PATTERN_KEY => "Grand.product.listing"; + /// /// Key for caching /// diff --git a/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetSearchProductsQueryHandlerTests.cs b/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetSearchProductsQueryHandlerTests.cs index 35db872fa..03a4860df 100644 --- a/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetSearchProductsQueryHandlerTests.cs +++ b/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetSearchProductsQueryHandlerTests.cs @@ -6,6 +6,10 @@ using Grand.Domain; using Grand.Domain.Catalog; using Grand.Domain.Customers; +using Grand.Infrastructure.Caching; +using Grand.Infrastructure.Caching.Constants; +using Grand.Infrastructure.Tests.Caching; +using Grand.Mediator; using Microsoft.Extensions.Logging.Abstractions; using Grand.Infrastructure.Configuration; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -17,13 +21,15 @@ namespace Grand.Business.Catalog.Tests.Queries.Handlers; public class GetSearchProductsQueryHandlerTests { private IRepository _repository; + private ICacheBase _cache; private GetSearchProductsQueryHandler handler; [TestInitialize] public void Init() { _repository = new MongoDBRepositoryTest(); - handler = new GetSearchProductsQueryHandler(_repository, new Mock().Object, + _cache = Cache(); + handler = new GetSearchProductsQueryHandler(_repository, new Mock().Object, _cache, new CatalogSettings { IgnoreFilterableSpecAttributeOption = true }, new AccessControlConfig(), new DatabaseConfig(), Mock.Of(), NullLogger.Instance); @@ -100,7 +106,7 @@ public async Task Handle_WhenFilterableSpecificationsRequested_ReturnsDistinctFi { //Arrange var filteringHandler = new GetSearchProductsQueryHandler(_repository, - new Mock().Object, + new Mock().Object, Cache(), new CatalogSettings { IgnoreFilterableSpecAttributeOption = false }, new AccessControlConfig(), new DatabaseConfig(), Mock.Of(), NullLogger.Instance); @@ -157,7 +163,7 @@ public async Task Handle_WhenFilterableSpecificationsRequested_StartsBothQueries return new List { "red" }; }); var filteringHandler = new GetSearchProductsQueryHandler(repository.Object, - new Mock().Object, + new Mock().Object, Cache(), new CatalogSettings { IgnoreFilterableSpecAttributeOption = false }, new AccessControlConfig(), new DatabaseConfig(), Mock.Of(), NullLogger.Instance); @@ -241,11 +247,226 @@ await Assert.ThrowsAsync(() => It.IsAny()), Times.Once); } + [TestMethod] + public async Task Handle_ListingWithFullPage_ReusesTheCachedTotal() + { + //Arrange + await InsertVisibleProducts(3); + await handler.Handle(Listing(pageSize: 2), CancellationToken.None); + await InsertVisibleProducts(1); + //Act + var result = await handler.Handle(Listing(pageSize: 2), CancellationToken.None); + //Assert + Assert.AreEqual(3, result.products.TotalCount); + } + + [TestMethod] + public async Task Handle_ListingWithShortPage_ReturnsTheExactTotal() + { + //Arrange + await InsertVisibleProducts(3); + await handler.Handle(Listing(pageSize: 10), CancellationToken.None); + await InsertVisibleProducts(1); + //Act + var result = await handler.Handle(Listing(pageSize: 10), CancellationToken.None); + //Assert + Assert.AreEqual(4, result.products.TotalCount); + Assert.AreEqual(4, result.products.Count); + } + + [TestMethod] + public async Task Handle_ListingWithFullPagePastTheCachedTotal_KeepsThePageWithinTheTotal() + { + //Arrange + await InsertVisibleProducts(2); + await handler.Handle(Listing(pageSize: 2), CancellationToken.None); + await InsertVisibleProducts(2); + //Act + var result = await handler.Handle(Listing(pageSize: 2, pageIndex: 1), CancellationToken.None); + //Assert + Assert.AreEqual(2, result.products.Count); + Assert.AreEqual(4, result.products.TotalCount); + Assert.AreEqual(2, result.products.TotalPages); + } + + [TestMethod] + public async Task Handle_ListingPatternCleared_CountsAgain() + { + //Arrange + await InsertVisibleProducts(3); + await handler.Handle(Listing(pageSize: 2), CancellationToken.None); + await InsertVisibleProducts(1); + await _cache.RemoveByPrefix(CacheKey.PRODUCTS_LISTING_PATTERN_KEY); + //Act + var result = await handler.Handle(Listing(pageSize: 2), CancellationToken.None); + //Assert + Assert.AreEqual(4, result.products.TotalCount); + } + + [TestMethod] + public async Task Handle_FirstListingRequestAborted_StillCachesTheSharedTotal() + { + //Arrange + var countStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseCount = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var repository = PagedRepository(); + repository.Setup(x => x.CountAsync(It.IsAny>(), It.IsAny())) + .Returns(async (IQueryable _, CancellationToken token) => { + countStarted.TrySetResult(); + await releaseCount.Task.WaitAsync(TimeSpan.FromSeconds(5), token); + return 3; + }); + var cachingHandler = CachingHandler(repository.Object, _cache); + using var cancellation = new CancellationTokenSource(); + //Act + var aborted = cachingHandler.Handle(Listing(pageSize: 1), cancellation.Token); + await countStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await cancellation.CancelAsync(); + await Assert.ThrowsAsync(() => aborted.WaitAsync(TimeSpan.FromSeconds(5))); + releaseCount.SetResult(); + var result = await cachingHandler.Handle(Listing(pageSize: 1), CancellationToken.None) + .WaitAsync(TimeSpan.FromSeconds(5)); + //Assert + Assert.AreEqual(3, result.products.TotalCount); + repository.Verify(x => x.CountAsync(It.IsAny>(), It.IsAny()), + Times.Once); + } + + [TestMethod] + public async Task Handle_TagRunningIntoTheCustomerGroups_DoesNotShareTheCachedTotal() + { + //Arrange + var repository = PagedRepository(); + repository.Setup(x => x.CountAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(3); + var cachingHandler = CachingHandler(repository.Object, _cache); + var tagged = Listing(pageSize: 1); + tagged.ProductTag = "sale|members"; + tagged.CustomerGroupIds = []; + var member = Listing(pageSize: 1); + member.ProductTag = "sale"; + member.CustomerGroupIds = ["members"]; + //Act + await cachingHandler.Handle(tagged, CancellationToken.None); + await cachingHandler.Handle(member, CancellationToken.None); + //Assert + repository.Verify(x => x.CountAsync(It.IsAny>(), It.IsAny()), + Times.Exactly(2)); + } + + [TestMethod] + public async Task Handle_NotOptedIn_CountsLive() + { + //Arrange + await InsertVisibleProducts(3); + var query = () => new GetSearchProductsQuery { Customer = new Customer(), PageSize = 2 }; + await handler.Handle(query(), CancellationToken.None); + await InsertVisibleProducts(1); + //Act + var result = await handler.Handle(query(), CancellationToken.None); + //Assert + Assert.AreEqual(4, result.products.TotalCount); + } + + [TestMethod] + public async Task Handle_ListingWithShowHidden_CountsLive() + { + //Arrange + await InsertVisibleProducts(3); + var query = () => { + var listing = Listing(pageSize: 2); + listing.ShowHidden = true; + return listing; + }; + await handler.Handle(query(), CancellationToken.None); + await InsertVisibleProducts(1); + //Act + var result = await handler.Handle(query(), CancellationToken.None); + //Assert + Assert.AreEqual(4, result.products.TotalCount); + } + + [TestMethod] + public async Task Handle_DifferentCustomerGroups_DoNotShareTheCachedTotal() + { + //Arrange + await InsertVisibleProducts(2); + await _repository.InsertAsync(new Product { + Name = "item", Published = true, VisibleIndividually = true, LimitedToGroups = true, + CustomerGroups = ["members"] + }); + var members = Listing(pageSize: 1); + members.CustomerGroupIds = ["members"]; + var guests = Listing(pageSize: 1); + guests.CustomerGroupIds = ["guests"]; + //Act + var membersResult = await handler.Handle(members, CancellationToken.None); + var guestsResult = await handler.Handle(guests, CancellationToken.None); + //Assert + Assert.AreEqual(3, membersResult.products.TotalCount); + Assert.AreEqual(2, guestsResult.products.TotalCount); + } + + [TestMethod] + public async Task Handle_DifferentCategories_DoNotShareTheCachedTotal() + { + //Arrange + await InsertVisibleProducts(2, "shoes"); + await InsertVisibleProducts(3, "bags"); + //Act + var shoes = await handler.Handle(Listing(pageSize: 1, categoryId: "shoes"), CancellationToken.None); + var bags = await handler.Handle(Listing(pageSize: 1, categoryId: "bags"), CancellationToken.None); + //Assert + Assert.AreEqual(2, shoes.products.TotalCount); + Assert.AreEqual(3, bags.products.TotalCount); + } + + private static GetSearchProductsQuery Listing(int pageSize, int pageIndex = 0, string categoryId = null) + { + return new GetSearchProductsQuery { + Customer = new Customer(), CacheListingSummary = true, PageSize = pageSize, PageIndex = pageIndex, + CategoryIds = categoryId == null ? null : [categoryId] + }; + } + + private async Task InsertVisibleProducts(int count, string categoryId = null) + { + for (var i = 0; i < count; i++) + { + var product = new Product { Name = "item", Published = true, VisibleIndividually = true }; + if (categoryId != null) + product.ProductCategories.Add(new ProductCategory { CategoryId = categoryId }); + await _repository.InsertAsync(product); + } + } + + //a repository whose every page is full, so the listing total always comes from the summary + private static Mock> PagedRepository() + { + var repository = new Mock>(); + repository.Setup(x => x.Table).Returns(new List().AsQueryable()); + repository.Setup(x => x.ToListAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new List { new() { Id = "1" } }); + return repository; + } + + private static GetSearchProductsQueryHandler CachingHandler(IRepository repository, ICacheBase cache) + { + return new GetSearchProductsQueryHandler(repository, new Mock().Object, cache, + new CatalogSettings { IgnoreFilterableSpecAttributeOption = true }, new AccessControlConfig(), + new DatabaseConfig(), Mock.Of(), NullLogger.Instance); + } + private static GetSearchProductsQueryHandler FilteringHandler(IRepository repository, DatabaseConfig databaseConfig, IDatabaseContext databaseContext) { - return new GetSearchProductsQueryHandler(repository, new Mock().Object, + return new GetSearchProductsQueryHandler(repository, new Mock().Object, Cache(), new CatalogSettings { IgnoreFilterableSpecAttributeOption = false }, new AccessControlConfig(), databaseConfig, databaseContext, NullLogger.Instance); } + + private static ICacheBase Cache() + { + return new MemoryCacheBase(MemoryCacheTest.Get(), Mock.Of(), new CacheConfig()); + } } \ No newline at end of file diff --git a/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetSearchProductsQueryHandlerTextSearchTests.cs b/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetSearchProductsQueryHandlerTextSearchTests.cs index b4b9bb996..0ae3a5b0f 100644 --- a/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetSearchProductsQueryHandlerTextSearchTests.cs +++ b/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetSearchProductsQueryHandlerTextSearchTests.cs @@ -7,6 +7,9 @@ using Grand.Domain.Catalog; using Grand.Domain.Localization; using Grand.Domain.Customers; +using Grand.Infrastructure.Caching; +using Grand.Infrastructure.Tests.Caching; +using Grand.Mediator; using Grand.Infrastructure.Configuration; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -31,6 +34,7 @@ public async Task Init() private GetSearchProductsQueryHandler Handler(bool textSearch = true) { return new GetSearchProductsQueryHandler(_repository, new Mock().Object, + new MemoryCacheBase(MemoryCacheTest.Get(), Mock.Of(), new CacheConfig()), new CatalogSettings { IgnoreFilterableSpecAttributeOption = true }, new AccessControlConfig(), new DatabaseConfig { UseProductTextSearch = textSearch }, _databaseContext, NullLogger.Instance); diff --git a/src/Tests/Grand.Business.Catalog.Tests/Services/Categories/CategoryServiceTests.cs b/src/Tests/Grand.Business.Catalog.Tests/Services/Categories/CategoryServiceTests.cs index c15c82ac3..ff0ef2a27 100644 --- a/src/Tests/Grand.Business.Catalog.Tests/Services/Categories/CategoryServiceTests.cs +++ b/src/Tests/Grand.Business.Catalog.Tests/Services/Categories/CategoryServiceTests.cs @@ -8,6 +8,7 @@ using Grand.Domain.Stores; using Grand.Infrastructure; using Grand.Infrastructure.Caching; +using Grand.Infrastructure.Caching.Constants; using Grand.Infrastructure.Configuration; using Grand.Infrastructure.Events; using Grand.SharedKernel.Extensions; @@ -151,8 +152,9 @@ public async Task DeleteProductCategory_InvokreRepositoryAndClearCache() //TODO //collectonMock.Verify(c => c.UpdateOneAsync(It.IsAny>(), It.IsAny>(), null, default(CancellationToken)), Times.Once); _mediatorMock.Verify(c => c.Publish(It.IsAny>(), default), Times.Once); - //clear product cache and ProductCategory - _casheManagerMock.Verify(c => c.RemoveByPrefix(It.IsAny(), true), Times.Exactly(2)); + //clear product cache, ProductCategory and the storefront listing totals + _casheManagerMock.Verify(c => c.RemoveByPrefix(It.IsAny(), true), Times.Exactly(3)); + _casheManagerMock.Verify(c => c.RemoveByPrefix(CacheKey.PRODUCTS_LISTING_PATTERN_KEY, true), Times.Once); } [TestMethod] @@ -171,7 +173,8 @@ public async Task InsertProductCategory_InvokreRepositoryAndClearCache() //TODO //collectonMock.Verify(c => c.UpdateOneAsync(It.IsAny>(), It.IsAny>(), null, default(CancellationToken)), Times.Once); _mediatorMock.Verify(c => c.Publish(It.IsAny>(), default), Times.Once); - _casheManagerMock.Verify(c => c.RemoveByPrefix(It.IsAny(), true), Times.Exactly(2)); + _casheManagerMock.Verify(c => c.RemoveByPrefix(It.IsAny(), true), Times.Exactly(3)); + _casheManagerMock.Verify(c => c.RemoveByPrefix(CacheKey.PRODUCTS_LISTING_PATTERN_KEY, true), Times.Once); } [TestMethod] diff --git a/src/Web/Grand.Web/Features/Handlers/Catalog/GetBrandHandler.cs b/src/Web/Grand.Web/Features/Handlers/Catalog/GetBrandHandler.cs index ab7802782..550f56144 100644 --- a/src/Web/Grand.Web/Features/Handlers/Catalog/GetBrandHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/Catalog/GetBrandHandler.cs @@ -52,6 +52,7 @@ await model.PagingFilteringContext.SpecificationFilter.GetAlreadyFilteredSpecOpt (_httpContextAccessor.HttpContext?.Request.Query, _specificationAttributeService); var products = await _mediator.Send(new GetSearchProductsQuery { + CacheListingSummary = true, LoadFilterableSpecificationAttributeOptionIds = !_catalogSettings.IgnoreFilterableSpecAttributeOption, BrandId = request.Brand.Id, Customer = request.Customer, diff --git a/src/Web/Grand.Web/Features/Handlers/Catalog/GetCategoryHandler.cs b/src/Web/Grand.Web/Features/Handlers/Catalog/GetCategoryHandler.cs index b594906e2..a06d98308 100644 --- a/src/Web/Grand.Web/Features/Handlers/Catalog/GetCategoryHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/Catalog/GetCategoryHandler.cs @@ -192,6 +192,7 @@ public async Task Handle(GetCategory request, CancellationToken c await model.PagingFilteringContext.SpecificationFilter.GetAlreadyFilteredSpecOptionIds( _httpContextAccessor.HttpContext?.Request.Query, _specificationAttributeService); var products = await _mediator.Send(new GetSearchProductsQuery { + CacheListingSummary = true, LoadFilterableSpecificationAttributeOptionIds = !_catalogSettings.IgnoreFilterableSpecAttributeOption, CategoryIds = categoryIds, Customer = request.Customer, diff --git a/src/Web/Grand.Web/Features/Handlers/Catalog/GetCollectionHandler.cs b/src/Web/Grand.Web/Features/Handlers/Catalog/GetCollectionHandler.cs index 3687d1bc5..dc231a8ad 100644 --- a/src/Web/Grand.Web/Features/Handlers/Catalog/GetCollectionHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/Catalog/GetCollectionHandler.cs @@ -98,6 +98,7 @@ await model.PagingFilteringContext.SpecificationFilter.GetAlreadyFilteredSpecOpt (_httpContextAccessor.HttpContext?.Request.Query, _specificationAttributeService); var products = await _mediator.Send(new GetSearchProductsQuery { + CacheListingSummary = true, LoadFilterableSpecificationAttributeOptionIds = !_catalogSettings.IgnoreFilterableSpecAttributeOption, CollectionId = request.Collection.Id, Customer = request.Customer, diff --git a/src/Web/Grand.Web/Features/Handlers/Catalog/GetProductsByTagHandler.cs b/src/Web/Grand.Web/Features/Handlers/Catalog/GetProductsByTagHandler.cs index cd55f14a5..bf3f988cf 100644 --- a/src/Web/Grand.Web/Features/Handlers/Catalog/GetProductsByTagHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/Catalog/GetProductsByTagHandler.cs @@ -42,6 +42,7 @@ public async Task Handle(GetProductsByTag request, Cancellat //products var products = (await _mediator.Send(new GetSearchProductsQuery { + CacheListingSummary = true, Customer = request.Customer, StoreId = request.Store.Id, ProductTag = request.ProductTag.Name, diff --git a/src/Web/Grand.Web/Features/Handlers/Catalog/GetVendorHandler.cs b/src/Web/Grand.Web/Features/Handlers/Catalog/GetVendorHandler.cs index bb53486c1..428edcfd5 100644 --- a/src/Web/Grand.Web/Features/Handlers/Catalog/GetVendorHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/Catalog/GetVendorHandler.cs @@ -101,6 +101,7 @@ await model.PagingFilteringContext.SpecificationFilter.GetAlreadyFilteredSpecOpt //products var products = await _mediator.Send(new GetSearchProductsQuery { + CacheListingSummary = true, LoadFilterableSpecificationAttributeOptionIds = !_catalogSettings.IgnoreFilterableSpecAttributeOption, Customer = request.Customer, VendorId = request.Vendor.Id,