From 84f746fb0ec4fe2205543a10fa55414a6dae3b3d Mon Sep 17 00:00:00 2001 From: support Date: Wed, 21 Oct 2026 21:06:44 +0200 Subject: [PATCH 1/2] Read sitemap products in projected batches instead of whole documents The sitemap task loaded every visible product as a full document in one query. GetSitemapProductsQuery returns only the id, translated SeName, first picture and last change, and pages by product id so each batch starts after the previous one. --- .../GetSitemapProductsQueryHandler.cs | 70 ++++++++++ .../Catalog/GetSitemapProductsQuery.cs | 28 ++++ .../Common/GetSitemapXMLCommandHandler.cs | 56 ++++---- .../GetSitemapProductsQueryHandlerTests.cs | 126 ++++++++++++++++++ .../GetSitemapXmlCommandHandlerTests.cs | 31 ++++- 5 files changed, 283 insertions(+), 28 deletions(-) create mode 100644 src/Business/Grand.Business.Catalog/Queries/Handlers/GetSitemapProductsQueryHandler.cs create mode 100644 src/Business/Grand.Business.Core/Queries/Catalog/GetSitemapProductsQuery.cs create mode 100644 src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetSitemapProductsQueryHandlerTests.cs diff --git a/src/Business/Grand.Business.Catalog/Queries/Handlers/GetSitemapProductsQueryHandler.cs b/src/Business/Grand.Business.Catalog/Queries/Handlers/GetSitemapProductsQueryHandler.cs new file mode 100644 index 000000000..1d6e97c0f --- /dev/null +++ b/src/Business/Grand.Business.Catalog/Queries/Handlers/GetSitemapProductsQueryHandler.cs @@ -0,0 +1,70 @@ +using Grand.Business.Core.Queries.Catalog; +using Grand.Data; +using Grand.Domain.Catalog; +using Grand.Infrastructure.Configuration; +using Grand.Mediator; + +namespace Grand.Business.Catalog.Queries.Handlers; + +public class GetSitemapProductsQueryHandler : IRequestHandler> +{ + private readonly AccessControlConfig _accessControlConfig; + private readonly CatalogSettings _catalogSettings; + private readonly IRepository _productRepository; + + public GetSitemapProductsQueryHandler( + IRepository productRepository, + CatalogSettings catalogSettings, + AccessControlConfig accessControlConfig) + { + _productRepository = productRepository; + _catalogSettings = catalogSettings; + _accessControlConfig = accessControlConfig; + } + + public async Task> Handle(GetSitemapProductsQuery request, + CancellationToken cancellationToken) + { + //the same products GetSearchProductsQuery returns for a storefront search without keywords + var query = _productRepository.Table.Where(p => p.Published && p.VisibleIndividually); + + if (!_catalogSettings.IgnoreFilterableAvailableStartEndDateTime) + { + var nowUtc = DateTime.UtcNow; + query = query.Where(p => + (p.AvailableStartDateTimeUtc == null || p.AvailableStartDateTimeUtc < nowUtc) && + (p.AvailableEndDateTimeUtc == null || p.AvailableEndDateTimeUtc > nowUtc)); + } + + if (!_accessControlConfig.IgnoreAcl) + { + var groupIds = request.CustomerGroupIds.ToArray(); + query = query.Where(p => !p.LimitedToGroups || groupIds.Any(x => p.CustomerGroups.Contains(x))); + } + + if (!string.IsNullOrEmpty(request.StoreId) && !_accessControlConfig.IgnoreStoreLimitations) + query = query.Where(p => p.Stores.Any(y => y == request.StoreId) || !p.LimitedToStores); + + //keyset paging: a skip would rescan every earlier batch + if (!string.IsNullOrEmpty(request.AfterId)) + query = query.Where(p => string.Compare(p.Id, request.AfterId) > 0); + + //only the fields of a sitemap entry leave the database, not the whole product + var languageId = request.LanguageId; + var batch = query.OrderBy(p => p.Id).Take(request.PageSize).Select(p => new { + p.Id, + p.SeName, + TranslatedSeName = p.Locales.Where(l => l.LanguageId == languageId && l.LocaleKey == "SeName") + .Select(l => l.LocaleValue).FirstOrDefault(), + PictureId = p.ProductPictures.Select(x => x.PictureId).FirstOrDefault(), + p.UpdatedOnUtc, + p.CreatedOnUtc + }); + + return (await _productRepository.ToListAsync(batch, cancellationToken)) + .Select(p => new SitemapProduct(p.Id, + string.IsNullOrEmpty(p.TranslatedSeName) ? p.SeName : p.TranslatedSeName, + p.PictureId, p.UpdatedOnUtc ?? p.CreatedOnUtc)) + .ToList(); + } +} diff --git a/src/Business/Grand.Business.Core/Queries/Catalog/GetSitemapProductsQuery.cs b/src/Business/Grand.Business.Core/Queries/Catalog/GetSitemapProductsQuery.cs new file mode 100644 index 000000000..6bd67a2aa --- /dev/null +++ b/src/Business/Grand.Business.Core/Queries/Catalog/GetSitemapProductsQuery.cs @@ -0,0 +1,28 @@ +using Grand.Mediator; + +namespace Grand.Business.Core.Queries.Catalog; + +/// +/// One batch of the products a sitemap lists: published, visible individually, available now, in the store and +/// visible to the given customer groups. Batches follow product id, so the next one starts after the last id +/// +public class GetSitemapProductsQuery : IRequest> +{ + public string StoreId { get; set; } + public IList CustomerGroupIds { get; set; } = []; + public string LanguageId { get; set; } + + /// + /// Id of the last product of the previous batch; null for the first batch + /// + public string AfterId { get; set; } + + public int PageSize { get; set; } = 1000; +} + +/// +/// The fields of a product a sitemap entry needs +/// +/// Search engine name in the requested language, or the default one +/// The first product picture; null when the product has none +public record SitemapProduct(string Id, string SeName, string PictureId, DateTime UpdatedOnUtc); diff --git a/src/Business/Grand.Business.Messages/Commands/Handlers/Common/GetSitemapXMLCommandHandler.cs b/src/Business/Grand.Business.Messages/Commands/Handlers/Common/GetSitemapXMLCommandHandler.cs index 9e6b8151d..f4dba36ac 100644 --- a/src/Business/Grand.Business.Messages/Commands/Handlers/Common/GetSitemapXMLCommandHandler.cs +++ b/src/Business/Grand.Business.Messages/Commands/Handlers/Common/GetSitemapXMLCommandHandler.cs @@ -28,6 +28,7 @@ namespace Grand.Business.Messages.Commands.Handlers.Common; public class GetSitemapXmlCommandHandler : IRequestHandler { private const string DateFormat = "yyyy-MM-dd"; + private const int ProductBatchSize = 1000; private readonly AppConfig _appConfig; private readonly IBlogService _blogService; private readonly BlogSettings _blogSettings; @@ -292,35 +293,42 @@ private async Task> GetBrandUrls(Language language, Stor private async Task> GetProductUrls(Language language, Store store) { - var search = await _mediator.Send(new GetSearchProductsQuery { - CustomerGroupIds = _guestGroupIds, - StoreId = store.Id, - VisibleIndividuallyOnly = true, - OrderBy = ProductSortingEnum.CreatedOn - }); var storeLocation = GetStoreLocation(); var products = new List(); - foreach (var product in search.products) + //a large catalog is read in batches of the few fields an entry needs, never as whole documents at once + IList batch; + string afterId = null; + do { - var url = - _appConfig.SeoFriendlyUrlsForLanguagesEnabled - ? _linkGenerator.GetUriByRouteValues("Product", - new { SeName = product.GetSeName(language.Id), language = language.UniqueSeoCode }, - GetHttpProtocol(), GetHost()) - : _linkGenerator.GetUriByRouteValues("Product", new { SeName = product.GetSeName(language.Id) }, - GetHttpProtocol(), GetHost()); + batch = await _mediator.Send(new GetSitemapProductsQuery { + StoreId = store.Id, + CustomerGroupIds = _guestGroupIds, + LanguageId = language.Id, + AfterId = afterId, + PageSize = ProductBatchSize + }); - var imageUrl = string.Empty; - if (_commonSettings.SitemapIncludeImage) - if (!string.IsNullOrEmpty(product.ProductPictures.FirstOrDefault()?.PictureId)) - { - imageUrl = await _pictureService.GetPictureUrl(product.ProductPictures.FirstOrDefault()?.PictureId, - showDefaultPicture: false, storeLocation: storeLocation); - } + foreach (var product in batch) + { + var url = + _appConfig.SeoFriendlyUrlsForLanguagesEnabled + ? _linkGenerator.GetUriByRouteValues("Product", + new { product.SeName, language = language.UniqueSeoCode }, + GetHttpProtocol(), GetHost()) + : _linkGenerator.GetUriByRouteValues("Product", new { product.SeName }, + GetHttpProtocol(), GetHost()); - products.Add(new SitemapUrl(url, imageUrl, UpdateFrequency.Weekly, - product.UpdatedOnUtc ?? product.CreatedOnUtc)); - } + var imageUrl = string.Empty; + if (_commonSettings.SitemapIncludeImage && !string.IsNullOrEmpty(product.PictureId)) + imageUrl = await _pictureService.GetPictureUrl(product.PictureId, showDefaultPicture: false, + storeLocation: storeLocation); + + products.Add(new SitemapUrl(url, imageUrl, UpdateFrequency.Weekly, product.UpdatedOnUtc)); + } + + if (batch.Count > 0) + afterId = batch[^1].Id; + } while (batch.Count == ProductBatchSize); return products; } diff --git a/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetSitemapProductsQueryHandlerTests.cs b/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetSitemapProductsQueryHandlerTests.cs new file mode 100644 index 000000000..898835799 --- /dev/null +++ b/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetSitemapProductsQueryHandlerTests.cs @@ -0,0 +1,126 @@ +using Grand.Business.Catalog.Queries.Handlers; +using Grand.Business.Core.Queries.Catalog; +using Grand.Data; +using Grand.Data.Tests.MongoDb; +using Grand.Domain.Catalog; +using Grand.Domain.Localization; +using Grand.Infrastructure.Configuration; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Business.Catalog.Tests.Queries.Handlers; + +[TestClass] +public class GetSitemapProductsQueryHandlerTests +{ + private const string StoreId = "store1"; + private const string GuestsGroupId = "guests-group"; + private GetSitemapProductsQueryHandler _handler; + private IRepository _repository; + + [TestInitialize] + public void Init() + { + _repository = new MongoDBRepositoryTest(); + _handler = new GetSitemapProductsQueryHandler(_repository, new CatalogSettings(), new AccessControlConfig()); + } + + private Task> Handle(string afterId = null, int pageSize = 1000) + { + return _handler.Handle(new GetSitemapProductsQuery { + StoreId = StoreId, + CustomerGroupIds = [GuestsGroupId], + LanguageId = "lang1", + AfterId = afterId, + PageSize = pageSize + }, CancellationToken.None); + } + + [TestMethod] + public async Task Handle_ReturnsOnlyProductsAGuestCanSeeInTheStore() + { + //Arrange + await _repository.InsertAsync(new Product { Id = "open", Published = true, VisibleIndividually = true }); + await _repository.InsertAsync(new Product { + Id = "guests", Published = true, VisibleIndividually = true, LimitedToGroups = true, + CustomerGroups = [GuestsGroupId] + }); + await _repository.InsertAsync(new Product { + Id = "admins", Published = true, VisibleIndividually = true, LimitedToGroups = true, + CustomerGroups = ["admins-group"] + }); + await _repository.InsertAsync(new Product { + Id = "this-store", Published = true, VisibleIndividually = true, LimitedToStores = true, + Stores = [StoreId] + }); + await _repository.InsertAsync(new Product { + Id = "other-store", Published = true, VisibleIndividually = true, LimitedToStores = true, + Stores = ["store2"] + }); + await _repository.InsertAsync(new Product { Id = "draft", Published = false, VisibleIndividually = true }); + await _repository.InsertAsync(new Product { Id = "variant", Published = true, VisibleIndividually = false }); + await _repository.InsertAsync(new Product { + Id = "expired", Published = true, VisibleIndividually = true, + AvailableEndDateTimeUtc = DateTime.UtcNow.AddDays(-1) + }); + await _repository.InsertAsync(new Product { + Id = "upcoming", Published = true, VisibleIndividually = true, + AvailableStartDateTimeUtc = DateTime.UtcNow.AddDays(1) + }); + + //Act + var result = await Handle(); + + //Assert + CollectionAssert.AreEquivalent(new[] { "open", "guests", "this-store" }, result.Select(p => p.Id).ToList()); + } + + [TestMethod] + public async Task Handle_ProjectsTheTranslatedSeNameFirstPictureAndLastChange() + { + //Arrange + var updatedOn = new DateTime(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc); + var product = new Product { + Id = "translated", SeName = "default-name", Published = true, VisibleIndividually = true, + Locales = [ + new TranslationEntity { LanguageId = "lang2", LocaleKey = "SeName", LocaleValue = "other-name" }, + new TranslationEntity { LanguageId = "lang1", LocaleKey = "Name", LocaleValue = "Name" }, + new TranslationEntity { LanguageId = "lang1", LocaleKey = "SeName", LocaleValue = "translated-name" } + ] + }; + product.ProductPictures.Add(new ProductPicture { PictureId = "picture1" }); + product.ProductPictures.Add(new ProductPicture { PictureId = "picture2" }); + await _repository.InsertAsync(product); + await _repository.UpdateField(product.Id, p => p.UpdatedOnUtc, updatedOn); + await _repository.InsertAsync(new Product { + Id = "untranslated", SeName = "default-only", Published = true, VisibleIndividually = true + }); + + //Act + var result = (await Handle()).ToDictionary(p => p.Id); + + //Assert + Assert.AreEqual("translated-name", result["translated"].SeName); + Assert.AreEqual("picture1", result["translated"].PictureId); + Assert.AreEqual(updatedOn, result["translated"].UpdatedOnUtc.ToUniversalTime()); + Assert.AreEqual("default-only", result["untranslated"].SeName); + Assert.IsNull(result["untranslated"].PictureId); + } + + [TestMethod] + public async Task Handle_PagesByIdAfterTheLastProductOfTheBatch() + { + //Arrange + foreach (var id in new[] { "c", "a", "e", "b", "d" }) + await _repository.InsertAsync(new Product { Id = id, Published = true, VisibleIndividually = true }); + + //Act + var first = await Handle(pageSize: 2); + var second = await Handle(first[^1].Id, 2); + var last = await Handle(second[^1].Id, 2); + + //Assert + CollectionAssert.AreEqual(new[] { "a", "b" }, first.Select(p => p.Id).ToList()); + CollectionAssert.AreEqual(new[] { "c", "d" }, second.Select(p => p.Id).ToList()); + CollectionAssert.AreEqual(new[] { "e" }, last.Select(p => p.Id).ToList()); + } +} diff --git a/src/Tests/Grand.Business.Messages.Tests/Commands/GetSitemapXmlCommandHandlerTests.cs b/src/Tests/Grand.Business.Messages.Tests/Commands/GetSitemapXmlCommandHandlerTests.cs index f5620396d..d279a9ac7 100644 --- a/src/Tests/Grand.Business.Messages.Tests/Commands/GetSitemapXmlCommandHandlerTests.cs +++ b/src/Tests/Grand.Business.Messages.Tests/Commands/GetSitemapXmlCommandHandlerTests.cs @@ -70,8 +70,8 @@ public void Init() _knowledgebaseServiceMock.Setup(x => x.GetPublicKnowledgebaseArticles(StoreId, It.Is>(g => g.SequenceEqual(new[] { GuestsGroupId })))) .ReturnsAsync(new List()); - _mediatorMock.Setup(x => x.Send(It.IsAny(), It.IsAny())) - .ReturnsAsync((new PagedList(), (IList)new List())); + _mediatorMock.Setup(x => x.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); _commonSettings = new CommonSettings { SitemapIncludeCategories = true, @@ -131,11 +131,34 @@ public async Task Handle_WithoutWorkContext_UsesOnlyExplicitlyScopedQueries() _brandServiceMock.Verify(x => x.GetAllBrands("", CatalogAccess.IncludingHidden(StoreId), 0, int.MaxValue), Times.Once); _pageServiceMock.Verify(x => x.GetAllPages(CatalogAccess.IncludingHidden(StoreId)), Times.Once); - _mediatorMock.Verify(x => x.Send(It.Is(q => - q.Customer == null && q.StoreId == StoreId && q.VisibleIndividuallyOnly && + _mediatorMock.Verify(x => x.Send(It.Is(q => + q.StoreId == StoreId && q.LanguageId == "lang1" && q.AfterId == null && q.CustomerGroupIds.SequenceEqual(new[] { GuestsGroupId })), It.IsAny()), Times.Once); } + [TestMethod] + public async Task Handle_WhenABatchIsFull_ReadsTheNextOneAfterItsLastProduct() + { + //Arrange + var firstBatch = Enumerable.Range(0, 1000) + .Select(i => new SitemapProduct($"p{i:D4}", $"product-{i}", null, DateTime.UtcNow)).ToList(); + var lastBatch = new List { new("p1000", "last-product", null, DateTime.UtcNow) }; + _mediatorMock.Setup(x => x.Send(It.Is(q => q.AfterId == null), + It.IsAny())).ReturnsAsync(firstBatch); + _mediatorMock.Setup(x => x.Send(It.Is(q => q.AfterId == "p0999"), + It.IsAny())).ReturnsAsync(lastBatch); + + //Act + var locations = Locations(await Generate()); + + //Assert + CollectionAssert.Contains(locations, "https://shop.example.com/product-0"); + CollectionAssert.Contains(locations, "https://shop.example.com/product-999"); + CollectionAssert.Contains(locations, "https://shop.example.com/last-product"); + _mediatorMock.Verify(x => x.Send(It.IsAny(), It.IsAny()), + Times.Exactly(2)); + } + [TestMethod] public async Task Handle_ListsOnlyWhatAGuestCanSee() { From 25aff2e2c29a72917d1cad1cf7ee57412057301b Mon Sep 17 00:00:00 2001 From: support Date: Wed, 21 Oct 2026 21:26:22 +0200 Subject: [PATCH 2/2] Read the sitemap catalog once on LiteDB and honour cancellation between batches LiteDB materialises the whole collection on every query, so batching there read the catalog once per batch; the query now returns the rest of it in one go. The batch loop passes the command's cancellation token, and a null CustomerGroupIds no longer throws. --- .../GetSitemapProductsQueryHandler.cs | 11 +++++- .../Common/GetSitemapXMLCommandHandler.cs | 5 ++- .../GetSitemapProductsQueryHandlerTests.cs | 37 +++++++++++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/Business/Grand.Business.Catalog/Queries/Handlers/GetSitemapProductsQueryHandler.cs b/src/Business/Grand.Business.Catalog/Queries/Handlers/GetSitemapProductsQueryHandler.cs index 1d6e97c0f..bf6f5db92 100644 --- a/src/Business/Grand.Business.Catalog/Queries/Handlers/GetSitemapProductsQueryHandler.cs +++ b/src/Business/Grand.Business.Catalog/Queries/Handlers/GetSitemapProductsQueryHandler.cs @@ -1,5 +1,6 @@ using Grand.Business.Core.Queries.Catalog; using Grand.Data; +using Grand.Data.LiteDb; using Grand.Domain.Catalog; using Grand.Infrastructure.Configuration; using Grand.Mediator; @@ -38,7 +39,7 @@ public async Task> Handle(GetSitemapProductsQuery request, if (!_accessControlConfig.IgnoreAcl) { - var groupIds = request.CustomerGroupIds.ToArray(); + var groupIds = request.CustomerGroupIds?.ToArray() ?? []; query = query.Where(p => !p.LimitedToGroups || groupIds.Any(x => p.CustomerGroups.Contains(x))); } @@ -49,9 +50,15 @@ public async Task> Handle(GetSitemapProductsQuery request, if (!string.IsNullOrEmpty(request.AfterId)) query = query.Where(p => string.Compare(p.Id, request.AfterId) > 0); + //LiteDB materialises the whole collection on every query, so batching there would read the catalog + //once per batch: the rest of the catalog comes back in one go instead + IQueryable ordered = query.OrderBy(p => p.Id); + if (_productRepository is not LiteDBRepository) + ordered = ordered.Take(request.PageSize); + //only the fields of a sitemap entry leave the database, not the whole product var languageId = request.LanguageId; - var batch = query.OrderBy(p => p.Id).Take(request.PageSize).Select(p => new { + var batch = ordered.Select(p => new { p.Id, p.SeName, TranslatedSeName = p.Locales.Where(l => l.LanguageId == languageId && l.LocaleKey == "SeName") diff --git a/src/Business/Grand.Business.Messages/Commands/Handlers/Common/GetSitemapXMLCommandHandler.cs b/src/Business/Grand.Business.Messages/Commands/Handlers/Common/GetSitemapXMLCommandHandler.cs index f4dba36ac..61f8d9e7f 100644 --- a/src/Business/Grand.Business.Messages/Commands/Handlers/Common/GetSitemapXMLCommandHandler.cs +++ b/src/Business/Grand.Business.Messages/Commands/Handlers/Common/GetSitemapXMLCommandHandler.cs @@ -50,6 +50,7 @@ public class GetSitemapXmlCommandHandler : IRequestHandler Handle(GetSitemapXmlCommand request, CancellationToken cancellationToken) { _request = request; + _cancellationToken = cancellationToken; var guests = await _groupService.GetCustomerGroupBySystemName(SystemCustomerGroupNames.Guests); _guestGroupIds = guests != null ? [guests.Id] : []; return await Generate(request.Language, request.Store); @@ -300,13 +302,14 @@ private async Task> GetProductUrls(Language language, St string afterId = null; do { + _cancellationToken.ThrowIfCancellationRequested(); batch = await _mediator.Send(new GetSitemapProductsQuery { StoreId = store.Id, CustomerGroupIds = _guestGroupIds, LanguageId = language.Id, AfterId = afterId, PageSize = ProductBatchSize - }); + }, _cancellationToken); foreach (var product in batch) { diff --git a/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetSitemapProductsQueryHandlerTests.cs b/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetSitemapProductsQueryHandlerTests.cs index 898835799..cbc754aa7 100644 --- a/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetSitemapProductsQueryHandlerTests.cs +++ b/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetSitemapProductsQueryHandlerTests.cs @@ -1,6 +1,7 @@ using Grand.Business.Catalog.Queries.Handlers; using Grand.Business.Core.Queries.Catalog; using Grand.Data; +using Grand.Data.Tests.LiteDb; using Grand.Data.Tests.MongoDb; using Grand.Domain.Catalog; using Grand.Domain.Localization; @@ -123,4 +124,40 @@ public async Task Handle_PagesByIdAfterTheLastProductOfTheBatch() CollectionAssert.AreEqual(new[] { "c", "d" }, second.Select(p => p.Id).ToList()); CollectionAssert.AreEqual(new[] { "e" }, last.Select(p => p.Id).ToList()); } + + [TestMethod] + public async Task Handle_NoCustomerGroups_ReturnsOnlyProductsNotLimitedToGroups() + { + //Arrange + await _repository.InsertAsync(new Product { Id = "open", Published = true, VisibleIndividually = true }); + await _repository.InsertAsync(new Product { + Id = "guests", Published = true, VisibleIndividually = true, LimitedToGroups = true, + CustomerGroups = [GuestsGroupId] + }); + + //Act + var result = await _handler.Handle(new GetSitemapProductsQuery { StoreId = StoreId, CustomerGroupIds = null }, + CancellationToken.None); + + //Assert + CollectionAssert.AreEqual(new[] { "open" }, result.Select(p => p.Id).ToList()); + } + + [TestMethod] + public async Task Handle_LiteDB_ReturnsTheRestOfTheCatalogInOneBatch() + { + //Arrange + var repository = new LiteDBRepositoryMock(); + var handler = new GetSitemapProductsQueryHandler(repository, new CatalogSettings(), new AccessControlConfig()); + foreach (var id in new[] { "c", "a", "e", "b", "d" }) + await repository.InsertAsync(new Product { Id = id, Published = true, VisibleIndividually = true }); + + //Act + var result = await handler.Handle(new GetSitemapProductsQuery { + StoreId = StoreId, CustomerGroupIds = [GuestsGroupId], AfterId = "a", PageSize = 2 + }, CancellationToken.None); + + //Assert + CollectionAssert.AreEqual(new[] { "b", "c", "d", "e" }, result.Select(p => p.Id).ToList()); + } }