From ff9b70a66d4559f232b85cede5088e576b8bbec5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Jul 2026 16:22:56 +0000 Subject: [PATCH 1/3] Initial plan From 7211916fbe42d64125d848270ece9e626cc200e4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Jul 2026 16:30:11 +0000 Subject: [PATCH 2/3] Optimize store lookups to avoid loading full store list Co-authored-by: KrzysztofPajak <16772986+KrzysztofPajak@users.noreply.github.com> --- .../Services/Stores/StoreService.cs | 22 +++++++++++--- .../Interfaces/Common/Stores/IStoreService.cs | 6 ++++ .../Services/Campaigns/CampaignService.cs | 4 +-- .../Services/MessageProviderService.cs | 6 ++-- .../Caching/Constants/CommonCacheKey.cs | 13 ++++++++ .../Services/Stores/StoreServiceTests.cs | 30 +++++++++++-------- .../Grand.Web.Common/StoreContextSetter.cs | 3 +- src/Web/Grand.Web.Common/WorkContextSetter.cs | 2 +- 8 files changed, 62 insertions(+), 24 deletions(-) diff --git a/src/Business/Grand.Business.Common/Services/Stores/StoreService.cs b/src/Business/Grand.Business.Common/Services/Stores/StoreService.cs index 9ede97eba..4024efed6 100644 --- a/src/Business/Grand.Business.Common/Services/Stores/StoreService.cs +++ b/src/Business/Grand.Business.Common/Services/Stores/StoreService.cs @@ -56,6 +56,16 @@ public virtual async Task> GetAllStores() }); } + /// + /// Gets the first configured store + /// + /// Store + public virtual Task GetFirstStore() + { + return _cacheBase.GetAsync(CacheKey.STORES_FIRST_KEY, () => Task.FromResult( + _storeRepository.Table.OrderBy(x => x.DisplayOrder).FirstOrDefault())); + } + /// /// Gets a store /// @@ -109,8 +119,7 @@ public virtual async Task DeleteStore(Store store) { ArgumentNullException.ThrowIfNull(store); - var allStores = await GetAllStores(); - if (allStores.Count == 1) + if (_storeRepository.Table.Count() == 1) throw new Exception("You cannot delete the only configured store"); await _storeRepository.DeleteAsync(store); @@ -128,8 +137,13 @@ public virtual async Task DeleteStore(Store store) /// public async Task GetStoreByHost(string host) { - var allStores = await GetAllStores(); - return allStores.FirstOrDefault(s => s.ContainsHostValue(host)); + if (string.IsNullOrEmpty(host)) + return null; + + var key = string.Format(CacheKey.STORES_BY_HOST_KEY, host.ToLowerInvariant()); + return await _cacheBase.GetAsync(key, () => Task.FromResult( + _storeRepository.Table.FirstOrDefault(s => + s.Domains.Any(domain => domain.HostName.Equals(host, StringComparison.OrdinalIgnoreCase))))); } #endregion diff --git a/src/Business/Grand.Business.Core/Interfaces/Common/Stores/IStoreService.cs b/src/Business/Grand.Business.Core/Interfaces/Common/Stores/IStoreService.cs index 80e5d7b13..6d4ea63b8 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Common/Stores/IStoreService.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Common/Stores/IStoreService.cs @@ -15,6 +15,12 @@ public interface IStoreService /// Stores Task> GetAllStores(); + /// + /// Gets the first configured store + /// + /// Store + Task GetFirstStore(); + /// /// Gets a store /// diff --git a/src/Business/Grand.Business.Marketing/Services/Campaigns/CampaignService.cs b/src/Business/Grand.Business.Marketing/Services/Campaigns/CampaignService.cs index df835ea0c..3ebe3635b 100644 --- a/src/Business/Grand.Business.Marketing/Services/Campaigns/CampaignService.cs +++ b/src/Business/Grand.Business.Marketing/Services/Campaigns/CampaignService.cs @@ -249,7 +249,7 @@ public virtual async Task SendCampaign(Campaign campaign, EmailAccount emai var builder = new LiquidObjectBuilder(mediator); var store = await storeService.GetStoreById(campaign.StoreId) ?? - (await storeService.GetAllStores()).FirstOrDefault(); + await storeService.GetFirstStore(); builder.AddStoreTokens(store, language, emailAccount) .AddNewsLetterSubscriptionTokens(subscription, store, contextAccessor.StoreContext.CurrentHost); @@ -303,7 +303,7 @@ public virtual async Task SendCampaign(Campaign campaign, EmailAccount emailAcco (await languageService.GetAllLanguages()).FirstOrDefault(); var store = await storeService.GetStoreById(campaign.StoreId) ?? - (await storeService.GetAllStores()).FirstOrDefault(); + await storeService.GetFirstStore(); var builder = new LiquidObjectBuilder(mediator); builder.AddStoreTokens(store, language, emailAccount); diff --git a/src/Business/Grand.Business.Messages/Services/MessageProviderService.cs b/src/Business/Grand.Business.Messages/Services/MessageProviderService.cs index 21fa6f7de..ef7496725 100644 --- a/src/Business/Grand.Business.Messages/Services/MessageProviderService.cs +++ b/src/Business/Grand.Business.Messages/Services/MessageProviderService.cs @@ -76,7 +76,7 @@ public MessageProviderService( protected virtual async Task GetStore(string storeId) { - return await _storeService.GetStoreById(storeId) ?? (await _storeService.GetAllStores()).FirstOrDefault(); + return await _storeService.GetStoreById(storeId) ?? await _storeService.GetFirstStore(); } protected virtual async Task GetMessageTemplate(string messageTemplateName, string storeId) @@ -1161,7 +1161,7 @@ public virtual async Task SendGiftVoucherMessage(GiftVoucher giftVoucher, O Store store = null; if (order != null) store = await _storeService.GetStoreById(order.StoreId); - store ??= (await _storeService.GetAllStores()).FirstOrDefault(); + store ??= await _storeService.GetFirstStore(); var language = await EnsureLanguageIsActive(languageId, store?.Id); @@ -1843,7 +1843,7 @@ public virtual async Task SendAuctionEndedStoreOwnerMessage(Product product } else { - var store = (await _storeService.GetAllStores()).FirstOrDefault(); + var store = await _storeService.GetFirstStore(); var language = await EnsureLanguageIsActive(languageId, store?.Id); messageTemplate = await GetMessageTemplate("AuctionExpired.StoreOwnerNotification", ""); if (messageTemplate == null) diff --git a/src/Core/Grand.Infrastructure/Caching/Constants/CommonCacheKey.cs b/src/Core/Grand.Infrastructure/Caching/Constants/CommonCacheKey.cs index 660c6befc..9b6de4999 100644 --- a/src/Core/Grand.Infrastructure/Caching/Constants/CommonCacheKey.cs +++ b/src/Core/Grand.Infrastructure/Caching/Constants/CommonCacheKey.cs @@ -155,6 +155,19 @@ public static partial class CacheKey /// public static string STORES_BY_ID_KEY => "Grand.stores.id-{0}"; + /// + /// Key for caching + /// + public static string STORES_FIRST_KEY => "Grand.stores.first"; + + /// + /// Key for caching + /// + /// + /// {0} : host + /// + public static string STORES_BY_HOST_KEY => "Grand.stores.host-{0}"; + #endregion #region Tax diff --git a/src/Tests/Grand.Business.Common.Tests/Services/Stores/StoreServiceTests.cs b/src/Tests/Grand.Business.Common.Tests/Services/Stores/StoreServiceTests.cs index aeb3d2668..178158df3 100644 --- a/src/Tests/Grand.Business.Common.Tests/Services/Stores/StoreServiceTests.cs +++ b/src/Tests/Grand.Business.Common.Tests/Services/Stores/StoreServiceTests.cs @@ -21,6 +21,8 @@ public class StoreServiceTests public void Init() { _cacheMock = new Mock(); + _cacheMock.Setup(c => c.GetAsync(It.IsAny(), It.IsAny>>())) + .Returns>>((_, acquire) => acquire()); _mediatorMock = new Mock(); _repository = new Mock>(); _service = new StoreService(_cacheMock.Object, _repository.Object, _mediatorMock.Object); @@ -47,8 +49,7 @@ public async Task UpdateStore_ValidArgument_InvokeExpectedMethods() [TestMethod] public async Task DeleteStore_ValidArgument_InvokeExpectedMethods() { - _cacheMock.Setup(c => c.GetAsync(It.IsAny(), It.IsAny>>>())) - .Returns(Task.FromResult(new List { new(), new() })); + _repository.Setup(c => c.Table).Returns(new List { new(), new() }.AsQueryable()); await _service.DeleteStore(new Store()); _repository.Verify(c => c.DeleteAsync(It.IsAny()), Times.Once); _mediatorMock.Verify(c => c.Publish(It.IsAny>(), default), Times.Once); @@ -59,8 +60,7 @@ public async Task DeleteStore_ValidArgument_InvokeExpectedMethods() public void DeleteStore_OnlyOneStore_ThrowException() { //can not remove store if it is only one - _cacheMock.Setup(c => c.GetAsync(It.IsAny(), It.IsAny>>>())) - .Returns(Task.FromResult(new List { new() })); + _repository.Setup(c => c.Table).Returns(new List { new() }.AsQueryable()); Assert.ThrowsExactlyAsync(async () => await _service.DeleteStore(new Store())); } @@ -80,10 +80,7 @@ public async Task GetStoreByHost_MatchingHost_ReturnsMatchingStore() new DomainHost { HostName = "store2.com", Url = "http://store2.com", Primary = true } }; - var stores = new List { store1, store2 }; - - _cacheMock.Setup(c => c.GetAsync(It.IsAny(), It.IsAny>>>())) - .Returns(Task.FromResult(stores)); + _repository.Setup(c => c.Table).Returns(new List { store1, store2 }.AsQueryable()); // Act var result = await _service.GetStoreByHost("store1.com"); @@ -108,10 +105,7 @@ public async Task GetStoreByHost_NoMatchingHost_ReturnsNullStore() new DomainHost { HostName = "store2.com", Url = "http://store2.com", Primary = true } }; - var stores = new List { store1, store2 }; - - _cacheMock.Setup(c => c.GetAsync(It.IsAny(), It.IsAny>>>())) - .Returns(Task.FromResult(stores)); + _repository.Setup(c => c.Table).Returns(new List { store1, store2 }.AsQueryable()); // Act - host not found in any DomainHost var result = await _service.GetStoreByHost("nonexisting.com"); @@ -119,4 +113,16 @@ public async Task GetStoreByHost_NoMatchingHost_ReturnsNullStore() // Assert - returns first store Assert.IsNull(result); } + + [TestMethod] + public async Task GetFirstStore_ReturnsStoreWithLowestDisplayOrder() + { + var store1 = new Store { DisplayOrder = 2 }; + var store2 = new Store { DisplayOrder = 1 }; + _repository.Setup(c => c.Table).Returns(new List { store1, store2 }.AsQueryable()); + + var result = await _service.GetFirstStore(); + + Assert.AreEqual(store2, result); + } } \ No newline at end of file diff --git a/src/Web/Grand.Web.Common/StoreContextSetter.cs b/src/Web/Grand.Web.Common/StoreContextSetter.cs index 337ed8f26..b27f712da 100644 --- a/src/Web/Grand.Web.Common/StoreContextSetter.cs +++ b/src/Web/Grand.Web.Common/StoreContextSetter.cs @@ -58,7 +58,7 @@ protected async Task CurrentStore(string id = null) } // Fallback: return the first available store - return (await _storeService.GetAllStores()).FirstOrDefault(); + return await _storeService.GetFirstStore(); } /// @@ -89,4 +89,3 @@ private sealed class CurrentStoreContext : IStoreContext } } - diff --git a/src/Web/Grand.Web.Common/WorkContextSetter.cs b/src/Web/Grand.Web.Common/WorkContextSetter.cs index 805ace465..f5e173675 100644 --- a/src/Web/Grand.Web.Common/WorkContextSetter.cs +++ b/src/Web/Grand.Web.Common/WorkContextSetter.cs @@ -440,7 +440,7 @@ protected async Task CurrentStore(string id = null) if (!string.IsNullOrEmpty(id)) return await _storeService.GetStoreById(id); - return (await _storeService.GetAllStores()).FirstOrDefault(); + return await _storeService.GetFirstStore(); } private sealed class CurrentWorkContext : IWorkContext From 944665e6b646caa8ad45f4cc1b9ed04fa55268f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Jul 2026 16:32:20 +0000 Subject: [PATCH 3/3] Refine store deletion check and extend store service tests Co-authored-by: KrzysztofPajak <16772986+KrzysztofPajak@users.noreply.github.com> --- .../Grand.Business.Common/Services/Stores/StoreService.cs | 2 +- .../Services/Stores/StoreServiceTests.cs | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Business/Grand.Business.Common/Services/Stores/StoreService.cs b/src/Business/Grand.Business.Common/Services/Stores/StoreService.cs index 4024efed6..9f843417b 100644 --- a/src/Business/Grand.Business.Common/Services/Stores/StoreService.cs +++ b/src/Business/Grand.Business.Common/Services/Stores/StoreService.cs @@ -119,7 +119,7 @@ public virtual async Task DeleteStore(Store store) { ArgumentNullException.ThrowIfNull(store); - if (_storeRepository.Table.Count() == 1) + if (!_storeRepository.Table.Skip(1).Any()) throw new Exception("You cannot delete the only configured store"); await _storeRepository.DeleteAsync(store); diff --git a/src/Tests/Grand.Business.Common.Tests/Services/Stores/StoreServiceTests.cs b/src/Tests/Grand.Business.Common.Tests/Services/Stores/StoreServiceTests.cs index 178158df3..18700ea4e 100644 --- a/src/Tests/Grand.Business.Common.Tests/Services/Stores/StoreServiceTests.cs +++ b/src/Tests/Grand.Business.Common.Tests/Services/Stores/StoreServiceTests.cs @@ -2,6 +2,7 @@ using Grand.Data; using Grand.Domain.Stores; using Grand.Infrastructure.Caching; +using Grand.Infrastructure.Caching.Constants; using Grand.Infrastructure.Events; using MediatR; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -87,6 +88,8 @@ public async Task GetStoreByHost_MatchingHost_ReturnsMatchingStore() // Assert Assert.AreEqual(store1, result); + _cacheMock.Verify(c => c.GetAsync(string.Format(CacheKey.STORES_BY_HOST_KEY, "store1.com"), + It.IsAny>>()), Times.Once); } [TestMethod] @@ -124,5 +127,6 @@ public async Task GetFirstStore_ReturnsStoreWithLowestDisplayOrder() var result = await _service.GetFirstStore(); Assert.AreEqual(store2, result); + _cacheMock.Verify(c => c.GetAsync(CacheKey.STORES_FIRST_KEY, It.IsAny>>()), Times.Once); } } \ No newline at end of file