From 95c48bf5c189f2e92bcf9a3a82d3f9155926940b Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 14 Jun 2026 10:58:41 +0200 Subject: [PATCH 1/9] Allow editing Store Id field on Customer in Grand.Web.Admin Add StoreId to CustomerModel, map it on read/insert/update in CustomerViewModelService, expose a Store dropdown in the customer info tab, and add the corresponding admin resource string. Co-Authored-By: Claude Opus 4.8 --- .../Partials/CreateOrUpdate.TabInfo.cshtml | 7 +++++++ .../Models/Customers/CustomerModel.cs | 3 +++ .../Services/CustomerViewModelService.cs | 6 +++++- .../App_Data/Resources/DefaultLanguage.xml | Bin 1515260 -> 1515480 bytes 4 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Customer/Partials/CreateOrUpdate.TabInfo.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Customer/Partials/CreateOrUpdate.TabInfo.cshtml index 46e40da93..2df010e46 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Customer/Partials/CreateOrUpdate.TabInfo.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Customer/Partials/CreateOrUpdate.TabInfo.cshtml @@ -183,6 +183,13 @@ +
+ +
+ + +
+
diff --git a/src/Web/Grand.Web.AdminShared/Models/Customers/CustomerModel.cs b/src/Web/Grand.Web.AdminShared/Models/Customers/CustomerModel.cs index e1f3a7d37..d866dce47 100644 --- a/src/Web/Grand.Web.AdminShared/Models/Customers/CustomerModel.cs +++ b/src/Web/Grand.Web.AdminShared/Models/Customers/CustomerModel.cs @@ -50,6 +50,9 @@ public class CustomerModel : BaseEntityModel [GrandResourceDisplayName("Admin.Customers.Customers.Fields.StaffStore")] public string StaffStoreId { get; set; } + [GrandResourceDisplayName("Admin.Customers.Customers.Fields.Store")] + public string StoreId { get; set; } + public IList AvailableStores { get; set; } = new List(); //form fields & properties diff --git a/src/Web/Grand.Web.AdminShared/Services/CustomerViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/CustomerViewModelService.cs index 7a9f4243d..4e3031e75 100644 --- a/src/Web/Grand.Web.AdminShared/Services/CustomerViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/CustomerViewModelService.cs @@ -190,6 +190,7 @@ public virtual async Task PrepareCustomerModel(CustomerModel model, Customer cus model.Username = customer.Username; model.VendorId = customer.VendorId; model.StaffStoreId = customer.StaffStoreId; + model.StoreId = customer.StoreId; model.SeId = customer.SeId; model.AdminComment = customer.AdminComment; model.IsTaxExempt = customer.IsTaxExempt; @@ -385,7 +386,7 @@ public virtual async Task InsertCustomerModel(CustomerModel model) IsTaxExempt = model.IsTaxExempt, FreeShipping = model.FreeShipping, Active = model.Active, - StoreId = _contextAccessor.StoreContext.CurrentStore.Id, + StoreId = model.StoreId, OwnerId = ownerId, Attributes = model.Attributes, LastActivityDateUtc = DateTime.UtcNow @@ -545,6 +546,9 @@ await _customerService.UpdateUserField(customer, //staff store customer.StaffStoreId = model.StaffStoreId; + //store + customer.StoreId = model.StoreId; + //sales employee customer.SeId = model.SeId; diff --git a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml index c60e745e3f723f9a8b030417a031fb558fa0b5f3..1e46d40f0c5dc64b2f4a4483e2ce4f341f6092a1 100644 GIT binary patch delta 122 zcmeyfH|ECvn1&X{7N!>F7M2#)7Pc1lEgWkOPIt&)Rbk|vzEFl;a=O54W-&(I$p_h_ zfg%U@aAZzjaEL>wea}G-Am#*OE+FOxVjdvo1!6uR<_BT{AQl8-kXm6N76D?>?RyT2 HIm7}0&{j1P delta 92 zcmcbyKjzQgn1&X{7N!>F7M2#)7Pc1lEgWkOPJf`u&DP#`hy#c@ftU-3xq+Amh Date: Sun, 14 Jun 2026 11:06:52 +0200 Subject: [PATCH 2/9] Add tests for Customer StoreId mapping in CustomerViewModelService Cover InsertCustomerModel and UpdateCustomerModel mapping of the new editable StoreId field from the model onto the Customer entity. Co-Authored-By: Claude Opus 4.8 --- .../Services/CustomerViewModelServiceTests.cs | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs diff --git a/src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs b/src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs new file mode 100644 index 000000000..d8409cfab --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs @@ -0,0 +1,115 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Common.Addresses; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Common.Stores; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Interfaces.Marketing.Contacts; +using Grand.Business.Core.Interfaces.Marketing.Customers; +using Grand.Business.Core.Interfaces.Marketing.Newsletters; +using Grand.Business.Core.Interfaces.Storage; +using Grand.Domain; +using Grand.Domain.Common; +using Grand.Domain.Customers; +using Grand.Domain.Orders; +using Grand.Domain.Tax; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Models.Customers; +using Grand.Web.AdminShared.Services; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Http; +using Moq; +using Assert = Microsoft.VisualStudio.TestTools.UnitTesting.Assert; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Web.Admin.Tests.Services; + +[TestClass] +public class CustomerViewModelServiceTests +{ + private Mock _customerServiceMock; + private Mock _groupServiceMock; + private Mock _storeServiceMock; + private CustomerViewModelService _customerViewModelService; + + [TestInitialize] + public void Setup() + { + _customerServiceMock = new Mock(); + _groupServiceMock = new Mock(); + _storeServiceMock = new Mock(); + + _customerServiceMock.Setup(c => c.InsertCustomer(It.IsAny())).Returns(Task.CompletedTask); + _customerServiceMock.Setup(c => c.UpdateCustomerInAdminPanel(It.IsAny())).Returns(Task.CompletedTask); + _customerServiceMock + .Setup(c => c.UpdateUserField(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + _customerServiceMock + .Setup(c => c.UpdateUserField(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + _storeServiceMock.Setup(s => s.GetAllStores()).ReturnsAsync(new List()); + + _groupServiceMock + .Setup(g => g.GetAllCustomerGroups(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new PagedList()); + + _customerViewModelService = new CustomerViewModelService( + _customerServiceMock.Object, + _groupServiceMock.Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + _storeServiceMock.Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new CustomerSettings(), + new TaxSettings(), + new LoyaltyPointsSettings(), + new AddressSettings(), + new CommonSettings(), + new Mock().Object); + } + + [TestMethod] + public async Task InsertCustomerModel_MapsStoreIdFromModel() + { + var model = new CustomerModel { StoreId = "store-1" }; + + var customer = await _customerViewModelService.InsertCustomerModel(model); + + Assert.AreEqual("store-1", customer.StoreId); + _customerServiceMock.Verify(c => c.InsertCustomer(It.Is(x => x.StoreId == "store-1")), Times.Once); + } + + [TestMethod] + public async Task UpdateCustomerModel_MapsStoreIdFromModel() + { + var customer = new Customer { StoreId = "store-1" }; + var model = new CustomerModel { Email = "customer@example.com", StoreId = "store-2" }; + + var result = await _customerViewModelService.UpdateCustomerModel(customer, model); + + Assert.AreEqual("store-2", result.StoreId); + _customerServiceMock.Verify( + c => c.UpdateCustomerInAdminPanel(It.Is(x => x.StoreId == "store-2")), Times.Once); + } +} From 292847e571ad44f82efead65d0523fc3c4e32451 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 14 Jun 2026 11:10:39 +0200 Subject: [PATCH 3/9] Require StoreId and restrict it for store managers on Customer Add validation: StoreId is required, and when the current user is a store manager they may only assign the customer to their own current store. Includes resource strings and validator unit tests. Co-Authored-By: Claude Opus 4.8 --- .../Validators/CustomerValidatorTests.cs | 96 ++++++++++++++++++ .../Validators/Customers/CustomerValidator.cs | 11 ++ .../App_Data/Resources/DefaultLanguage.xml | Bin 1515480 -> 1516128 bytes 3 files changed, 107 insertions(+) create mode 100644 src/Tests/Grand.Web.Admin.Tests/Validators/CustomerValidatorTests.cs diff --git a/src/Tests/Grand.Web.Admin.Tests/Validators/CustomerValidatorTests.cs b/src/Tests/Grand.Web.Admin.Tests/Validators/CustomerValidatorTests.cs new file mode 100644 index 000000000..e99f19bde --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Validators/CustomerValidatorTests.cs @@ -0,0 +1,96 @@ +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Domain; +using Grand.Domain.Customers; +using Grand.Domain.Stores; +using Grand.Infrastructure; +using Grand.Infrastructure.Validators; +using Grand.Web.AdminShared.Models.Customers; +using Grand.Web.AdminShared.Validators.Customers; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Admin.Tests.Validators; + +[TestClass] +public class CustomerValidatorTests +{ + private const string CurrentStoreId = "store-current"; + private const string RequiredMessage = "Admin.Customers.Customers.Fields.Store.Required"; + private const string MustBeCurrentStoreMessage = "Admin.Customers.Customers.Fields.Store.MustBeCurrentStore"; + + private Mock _groupServiceMock; + private CustomerValidator _validator; + + [TestInitialize] + public void Setup() + { + var translationServiceMock = new Mock(); + translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns(k => k); + + _groupServiceMock = new Mock(); + _groupServiceMock + .Setup(g => g.GetAllCustomerGroups(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new PagedList()); + + var workContextMock = new Mock(); + workContextMock.Setup(w => w.CurrentCustomer).Returns(new Customer()); + var storeContextMock = new Mock(); + storeContextMock.Setup(s => s.CurrentStore).Returns(new Store { Id = CurrentStoreId }); + var contextAccessorMock = new Mock(); + contextAccessorMock.Setup(c => c.WorkContext).Returns(workContextMock.Object); + contextAccessorMock.Setup(c => c.StoreContext).Returns(storeContextMock.Object); + + _validator = new CustomerValidator( + new List>(), + translationServiceMock.Object, + new Mock().Object, + contextAccessorMock.Object, + new Mock().Object, + _groupServiceMock.Object, + new CustomerSettings()); + } + + private static CustomerModel BuildModel(string storeId) => + new() { Id = "customer-1", Email = "customer@example.com", StoreId = storeId }; + + [TestMethod] + public async Task StoreId_Empty_FailsRequired() + { + var result = await _validator.ValidateAsync(BuildModel(string.Empty)); + + Assert.IsTrue(result.Errors.Any(e => e.ErrorMessage == RequiredMessage)); + } + + [TestMethod] + public async Task StoreManager_WrongStore_Fails() + { + _groupServiceMock.Setup(g => g.IsStoreManager(It.IsAny())).ReturnsAsync(true); + + var result = await _validator.ValidateAsync(BuildModel("store-other")); + + Assert.IsTrue(result.Errors.Any(e => e.ErrorMessage == MustBeCurrentStoreMessage)); + } + + [TestMethod] + public async Task StoreManager_CurrentStore_Passes() + { + _groupServiceMock.Setup(g => g.IsStoreManager(It.IsAny())).ReturnsAsync(true); + + var result = await _validator.ValidateAsync(BuildModel(CurrentStoreId)); + + Assert.IsFalse(result.Errors.Any(e => e.ErrorMessage == MustBeCurrentStoreMessage)); + } + + [TestMethod] + public async Task NonStoreManager_AnyStore_Passes() + { + _groupServiceMock.Setup(g => g.IsStoreManager(It.IsAny())).ReturnsAsync(false); + + var result = await _validator.ValidateAsync(BuildModel("store-other")); + + Assert.IsFalse(result.Errors.Any(e => e.ErrorMessage == MustBeCurrentStoreMessage)); + } +} diff --git a/src/Web/Grand.Web.AdminShared/Validators/Customers/CustomerValidator.cs b/src/Web/Grand.Web.AdminShared/Validators/Customers/CustomerValidator.cs index 0559575ca..2b321ea24 100644 --- a/src/Web/Grand.Web.AdminShared/Validators/Customers/CustomerValidator.cs +++ b/src/Web/Grand.Web.AdminShared/Validators/Customers/CustomerValidator.cs @@ -30,6 +30,17 @@ public CustomerValidator( RuleFor(x => x.Email).NotEmpty().EmailAddress() .WithMessage(translationService.GetResource("Admin.Customers.Customers.Fields.Email.Required")); + //store + RuleFor(x => x.StoreId).NotEmpty() + .WithMessage(translationService.GetResource("Admin.Customers.Customers.Fields.Store.Required")); + + //a store manager can only assign a customer to his own store + RuleFor(x => x.StoreId).MustAsync(async (storeId, _) => + !await groupService.IsStoreManager(contextAccessor.WorkContext.CurrentCustomer) || + storeId == contextAccessor.StoreContext.CurrentStore.Id) + .WithMessage( + translationService.GetResource("Admin.Customers.Customers.Fields.Store.MustBeCurrentStore")); + //form fields if (customerSettings.CountryEnabled && customerSettings.CountryRequired) RuleFor(x => x.CountryId) diff --git a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml index 1e46d40f0c5dc64b2f4a4483e2ce4f341f6092a1..0b432bbbc079ddde4d76818d2dbbca9f0c9bd362 100644 GIT binary patch delta 192 zcmcbyKjy)an1&X{7N!>F7M2#)7Pc1lEgT((raNS?vQ58oiqT;Dg@YUl(aG>T2X&?&+)J*iSy zX!?|14uR=U*q8*E^BMA{Z`9xrogU)LAvOKV8DSyja-dLSH=AtRA&#~~oNb4=+75BI o9pY&_#M^d=uk8?j+aZCrLxOFGgxU@Xw;d8`J0!a8kl2k_0J=^_VgLXD delta 87 zcmaEGB<9Bcn1&X{7N!>F7M2#)7Pc1lEgT((+69hs05K;Ja{)0o5c2>rFA(zqF+UIs X0I?tt3jwh(5Q_k@=yrjlVga!L!!saM From ab4fc6300f5524544939afda891f335969372630 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 14 Jun 2026 11:53:11 +0200 Subject: [PATCH 4/9] Preset StoreId for store managers when creating a customer When preparing the model for a new customer, if the current user is a store manager, default StoreId to the current store. Adds covering tests. Co-Authored-By: Claude Opus 4.8 --- .../Services/CustomerViewModelServiceTests.cs | 45 +++++++++++++++++-- .../Services/CustomerViewModelService.cs | 4 ++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs b/src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs index d8409cfab..8b4d970b8 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs @@ -28,6 +28,8 @@ namespace Grand.Web.Admin.Tests.Services; [TestClass] public class CustomerViewModelServiceTests { + private const string CurrentStoreId = "store-current"; + private Mock _customerServiceMock; private Mock _groupServiceMock; private Mock _storeServiceMock; @@ -40,6 +42,21 @@ public void Setup() _groupServiceMock = new Mock(); _storeServiceMock = new Mock(); + var workContextMock = new Mock(); + workContextMock.Setup(w => w.CurrentCustomer).Returns(new Customer()); + var storeContextMock = new Mock(); + storeContextMock.Setup(s => s.CurrentStore).Returns(new Grand.Domain.Stores.Store { Id = CurrentStoreId }); + var contextAccessorMock = new Mock(); + contextAccessorMock.Setup(c => c.WorkContext).Returns(workContextMock.Object); + contextAccessorMock.Setup(c => c.StoreContext).Returns(storeContextMock.Object); + + var salesEmployeeServiceMock = new Mock(); + salesEmployeeServiceMock.Setup(s => s.GetAll()).ReturnsAsync(new List()); + + var customerAttributeServiceMock = new Mock(); + customerAttributeServiceMock.Setup(c => c.GetAllCustomerAttributes()) + .ReturnsAsync(new List()); + _customerServiceMock.Setup(c => c.InsertCustomer(It.IsAny())).Returns(Task.CompletedTask); _customerServiceMock.Setup(c => c.UpdateCustomerInAdminPanel(It.IsAny())).Returns(Task.CompletedTask); _customerServiceMock @@ -67,17 +84,17 @@ public void Setup() new Mock().Object, new Mock().Object, new Mock().Object, - new Mock().Object, + contextAccessorMock.Object, new Mock().Object, _storeServiceMock.Object, new Mock().Object, - new Mock().Object, + customerAttributeServiceMock.Object, new Mock().Object, new Mock().Object, new Mock().Object, new Mock().Object, new Mock().Object, - new Mock().Object, + salesEmployeeServiceMock.Object, new Mock().Object, new Mock().Object, new Mock().Object, @@ -100,6 +117,28 @@ public async Task InsertCustomerModel_MapsStoreIdFromModel() _customerServiceMock.Verify(c => c.InsertCustomer(It.Is(x => x.StoreId == "store-1")), Times.Once); } + [TestMethod] + public async Task PrepareCustomerModel_StoreManager_PresetsCurrentStoreId() + { + _groupServiceMock.Setup(g => g.IsStoreManager(It.IsAny())).ReturnsAsync(true); + + var model = new CustomerModel(); + await _customerViewModelService.PrepareCustomerModel(model, null, false); + + Assert.AreEqual(CurrentStoreId, model.StoreId); + } + + [TestMethod] + public async Task PrepareCustomerModel_NonStoreManager_DoesNotPresetStoreId() + { + _groupServiceMock.Setup(g => g.IsStoreManager(It.IsAny())).ReturnsAsync(false); + + var model = new CustomerModel(); + await _customerViewModelService.PrepareCustomerModel(model, null, false); + + Assert.IsTrue(string.IsNullOrEmpty(model.StoreId)); + } + [TestMethod] public async Task UpdateCustomerModel_MapsStoreIdFromModel() { diff --git a/src/Web/Grand.Web.AdminShared/Services/CustomerViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/CustomerViewModelService.cs index 4e3031e75..a9d339eb5 100644 --- a/src/Web/Grand.Web.AdminShared/Services/CustomerViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/CustomerViewModelService.cs @@ -265,6 +265,10 @@ public virtual async Task PrepareCustomerModel(CustomerModel model, Customer cus else { model.SeId = _contextAccessor.WorkContext.CurrentCustomer.SeId; + + //a store manager can only create customers for his own store - preset it + if (await _groupService.IsStoreManager(_contextAccessor.WorkContext.CurrentCustomer)) + model.StoreId = _contextAccessor.StoreContext.CurrentStore.Id; } model.UsernamesEnabled = _customerSettings.UsernamesEnabled; From 55e85db505ad37188853d28efba5103b8ec5ddb8 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 14 Jun 2026 12:09:08 +0200 Subject: [PATCH 5/9] Persist Customer StoreId on admin update and add service tests UpdateCustomerInAdminPanel built an explicit UpdateBuilder that omitted StoreId, so edits to the field were never saved. Add StoreId to the update set. Also add basic CustomerViewModelService tests (list model, delete, add-product model, customer note) and assert StoreId persistence. Co-Authored-By: Claude Opus 4.8 --- .../Services/CustomerService.cs | 1 + .../Services/CustomerServiceTests.cs | 5 +- .../Services/CustomerViewModelServiceTests.cs | 101 +++++++++++++++++- 3 files changed, 101 insertions(+), 6 deletions(-) diff --git a/src/Business/Grand.Business.Customers/Services/CustomerService.cs b/src/Business/Grand.Business.Customers/Services/CustomerService.cs index 0695644ba..ea0ad56a8 100644 --- a/src/Business/Grand.Business.Customers/Services/CustomerService.cs +++ b/src/Business/Grand.Business.Customers/Services/CustomerService.cs @@ -485,6 +485,7 @@ public virtual async Task UpdateCustomerInAdminPanel(Customer customer) .Set(x => x.SeId, customer.SeId) .Set(x => x.OwnerId, customer.OwnerId) .Set(x => x.StaffStoreId, customer.StaffStoreId) + .Set(x => x.StoreId, customer.StoreId) .Set(x => x.Attributes, customer.Attributes); await _customerRepository.UpdateOneAsync(x => x.Id == customer.Id, update); diff --git a/src/Tests/Grand.Business.Customers.Tests/Services/CustomerServiceTests.cs b/src/Tests/Grand.Business.Customers.Tests/Services/CustomerServiceTests.cs index 86ec7f3d8..8b6ba3239 100644 --- a/src/Tests/Grand.Business.Customers.Tests/Services/CustomerServiceTests.cs +++ b/src/Tests/Grand.Business.Customers.Tests/Services/CustomerServiceTests.cs @@ -290,9 +290,12 @@ public async Task UpdateCustomerinAdminPanelTest() await _repository.InsertAsync(customer); //Act customer.AdminComment = "test"; + customer.StoreId = "store-1"; await _customerService.UpdateCustomerInAdminPanel(customer); //Assert - Assert.AreEqual("test", _repository.Table.FirstOrDefault(x => x.Id == customer.Id).AdminComment); + var updated = _repository.Table.FirstOrDefault(x => x.Id == customer.Id); + Assert.AreEqual("test", updated.AdminComment); + Assert.AreEqual("store-1", updated.StoreId); } [TestMethod] diff --git a/src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs b/src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs index 8b4d970b8..838ef555c 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs @@ -10,15 +10,18 @@ using Grand.Business.Core.Interfaces.Marketing.Newsletters; using Grand.Business.Core.Interfaces.Storage; using Grand.Domain; +using Grand.Domain.Catalog; using Grand.Domain.Common; using Grand.Domain.Customers; using Grand.Domain.Orders; using Grand.Domain.Tax; +using Grand.Domain.Vendors; using Grand.Infrastructure; using Grand.Web.AdminShared.Models.Customers; using Grand.Web.AdminShared.Services; using Grand.Web.Common.Localization; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Rendering; using Moq; using Assert = Microsoft.VisualStudio.TestTools.UnitTesting.Assert; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -29,10 +32,15 @@ namespace Grand.Web.Admin.Tests.Services; public class CustomerViewModelServiceTests { private const string CurrentStoreId = "store-current"; + private const string CurrentCustomerId = "current-user"; private Mock _customerServiceMock; private Mock _groupServiceMock; private Mock _storeServiceMock; + private Mock _customerTagServiceMock; + private Mock _vendorServiceMock; + private Mock _enumTranslationServiceMock; + private Mock _customerNoteServiceMock; private CustomerViewModelService _customerViewModelService; [TestInitialize] @@ -41,9 +49,13 @@ public void Setup() _customerServiceMock = new Mock(); _groupServiceMock = new Mock(); _storeServiceMock = new Mock(); + _customerTagServiceMock = new Mock(); + _vendorServiceMock = new Mock(); + _enumTranslationServiceMock = new Mock(); + _customerNoteServiceMock = new Mock(); var workContextMock = new Mock(); - workContextMock.Setup(w => w.CurrentCustomer).Returns(new Customer()); + workContextMock.Setup(w => w.CurrentCustomer).Returns(new Customer { Id = CurrentCustomerId }); var storeContextMock = new Mock(); storeContextMock.Setup(s => s.CurrentStore).Returns(new Grand.Domain.Stores.Store { Id = CurrentStoreId }); var contextAccessorMock = new Mock(); @@ -75,6 +87,9 @@ public void Setup() It.IsAny())) .ReturnsAsync(new PagedList()); + _customerNoteServiceMock.Setup(c => c.InsertCustomerNote(It.IsAny())) + .Returns(Task.CompletedTask); + _customerViewModelService = new CustomerViewModelService( _customerServiceMock.Object, _groupServiceMock.Object, @@ -85,17 +100,17 @@ public void Setup() new Mock().Object, new Mock().Object, contextAccessorMock.Object, - new Mock().Object, + _vendorServiceMock.Object, _storeServiceMock.Object, new Mock().Object, customerAttributeServiceMock.Object, new Mock().Object, new Mock().Object, new Mock().Object, - new Mock().Object, + _customerTagServiceMock.Object, new Mock().Object, salesEmployeeServiceMock.Object, - new Mock().Object, + _customerNoteServiceMock.Object, new Mock().Object, new Mock().Object, new CustomerSettings(), @@ -103,7 +118,7 @@ public void Setup() new LoyaltyPointsSettings(), new AddressSettings(), new CommonSettings(), - new Mock().Object); + _enumTranslationServiceMock.Object); } [TestMethod] @@ -151,4 +166,80 @@ public async Task UpdateCustomerModel_MapsStoreIdFromModel() _customerServiceMock.Verify( c => c.UpdateCustomerInAdminPanel(It.Is(x => x.StoreId == "store-2")), Times.Once); } + + [TestMethod] + public async Task PrepareCustomerListModel_SelectsRegisteredGroupByDefault() + { + var registered = new CustomerGroup { Id = "reg", Name = "Registered" }; + _groupServiceMock.Setup(g => g.GetCustomerGroupBySystemName(It.IsAny())) + .ReturnsAsync(registered); + _groupServiceMock + .Setup(g => g.GetAllCustomerGroups(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new PagedList { registered }); + _customerTagServiceMock.Setup(t => t.GetAllCustomerTags()).ReturnsAsync(new List()); + + var model = await _customerViewModelService.PrepareCustomerListModel(); + + Assert.IsTrue(model.AvailableCustomerGroups.Any(x => x.Value == "reg" && x.Selected)); + CollectionAssert.Contains(model.SearchCustomerGroupIds.ToList(), "reg"); + } + + [TestMethod] + public async Task DeleteCustomer_DeletesCustomer() + { + var customer = new Customer { Id = "c1", Email = "customer@example.com" }; + + await _customerViewModelService.DeleteCustomer(customer); + + _customerServiceMock.Verify(c => c.DeleteCustomer(customer), Times.Once); + } + + [TestMethod] + public async Task DeleteSelected_DeletesAllExceptCurrentUser() + { + var other = new Customer { Id = "other" }; + var current = new Customer { Id = CurrentCustomerId }; + _customerServiceMock.Setup(c => c.GetCustomersByIds(It.IsAny())) + .ReturnsAsync(new List { other, current }); + + await _customerViewModelService.DeleteSelected(new[] { "other", CurrentCustomerId }); + + _customerServiceMock.Verify(c => c.DeleteCustomer(other), Times.Once); + _customerServiceMock.Verify(c => c.DeleteCustomer(current), Times.Never); + } + + [TestMethod] + public async Task PrepareCustomerModelAddProductModel_BuildsAvailableLists() + { + _storeServiceMock.Setup(s => s.GetAllStores()) + .ReturnsAsync(new List { new() { Id = "store-1", Shortcut = "Store 1" } }); + _vendorServiceMock + .Setup(v => v.GetAllVendors(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new PagedList { new() { Id = "vendor-1", Name = "Vendor 1" } }); + _enumTranslationServiceMock + .Setup(e => e.ToSelectList(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(new SelectList(Enumerable.Empty())); + + var model = await _customerViewModelService.PrepareCustomerModelAddProductModel(); + + Assert.IsTrue(model.AvailableStores.Any(x => x.Value == "store-1")); + Assert.IsTrue(model.AvailableVendors.Any(x => x.Value == "vendor-1")); + Assert.IsTrue(model.AvailableProductTypes.Count > 0); + } + + [TestMethod] + public async Task InsertCustomerNote_InsertsNoteWithMappedFields() + { + var note = await _customerViewModelService.InsertCustomerNote("c1", "d1", false, "title", "message"); + + Assert.AreEqual("c1", note.CustomerId); + Assert.AreEqual("d1", note.DownloadId); + Assert.AreEqual("title", note.Title); + Assert.AreEqual("message", note.Note); + Assert.IsFalse(note.DisplayToCustomer); + _customerNoteServiceMock.Verify( + c => c.InsertCustomerNote(It.Is(x => x.CustomerId == "c1" && x.Title == "title")), + Times.Once); + } } From 46a9c03ef68a62d90680cca3bdc0a809ff0cd48d Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 14 Jun 2026 12:37:51 +0200 Subject: [PATCH 6/9] Update nuget --- Directory.Packages.props | 53 ++++++++++--------- .../Aspire.AppHost/Aspire.AppHost.csproj | 1 + .../Authentication.Facebook.csproj | 2 +- .../Authentication.Google.csproj | 2 +- 4 files changed, 30 insertions(+), 28 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index bfb6469e1..8b8e6d5e6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,16 +5,17 @@ - - + + + - + - - - + + + @@ -25,23 +26,23 @@ - - - - + + + + - - - + + + - + - + @@ -51,23 +52,23 @@ - + - + - - - - - - - + + + + + + + @@ -75,7 +76,7 @@ - - + + \ No newline at end of file diff --git a/src/Aspire/Aspire.AppHost/Aspire.AppHost.csproj b/src/Aspire/Aspire.AppHost/Aspire.AppHost.csproj index d13247fb5..550a7d6b8 100644 --- a/src/Aspire/Aspire.AppHost/Aspire.AppHost.csproj +++ b/src/Aspire/Aspire.AppHost/Aspire.AppHost.csproj @@ -20,6 +20,7 @@ + diff --git a/src/Plugins/Authentication.Facebook/Authentication.Facebook.csproj b/src/Plugins/Authentication.Facebook/Authentication.Facebook.csproj index d3de7b90d..3089521e3 100644 --- a/src/Plugins/Authentication.Facebook/Authentication.Facebook.csproj +++ b/src/Plugins/Authentication.Facebook/Authentication.Facebook.csproj @@ -21,7 +21,7 @@ - + diff --git a/src/Plugins/Authentication.Google/Authentication.Google.csproj b/src/Plugins/Authentication.Google/Authentication.Google.csproj index 1db5fedda..07847958b 100644 --- a/src/Plugins/Authentication.Google/Authentication.Google.csproj +++ b/src/Plugins/Authentication.Google/Authentication.Google.csproj @@ -20,7 +20,7 @@ - + From d216aaea98e4a5a66e510ccf0b188bb840b16366 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 14 Jun 2026 12:38:09 +0200 Subject: [PATCH 7/9] Remove unused using --- .../Controllers/CustomerController.cs | 67 +++++++++---------- 1 file changed, 33 insertions(+), 34 deletions(-) diff --git a/src/Web/Grand.Web.Admin/Controllers/CustomerController.cs b/src/Web/Grand.Web.Admin/Controllers/CustomerController.cs index f73d8046c..ebb2254ec 100644 --- a/src/Web/Grand.Web.Admin/Controllers/CustomerController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/CustomerController.cs @@ -7,16 +7,16 @@ using Grand.Business.Core.Interfaces.Customers; using Grand.Business.Core.Interfaces.ExportImport; using Grand.Business.Core.Interfaces.Messages; -using Grand.Domain.Permissions; using Grand.Business.Core.Utilities.Customers; using Grand.Domain.Catalog; using Grand.Domain.Common; using Grand.Domain.Customers; +using Grand.Domain.Permissions; using Grand.Domain.Tax; using Grand.Infrastructure; using Grand.SharedKernel; using Grand.SharedKernel.Extensions; -using Grand.Web.Admin.Extensions; +using Grand.Web.AdminShared.Extensions; using Grand.Web.AdminShared.Interfaces; using Grand.Web.AdminShared.Models.Catalog; using Grand.Web.AdminShared.Models.Customers; @@ -26,7 +26,6 @@ using Grand.Web.Common.Models; using Grand.Web.Common.Security.Authorization; using Microsoft.AspNetCore.Mvc; -using Grand.Web.AdminShared.Extensions; namespace Grand.Web.Admin.Controllers; @@ -88,46 +87,46 @@ protected virtual async Task> ParseCustomCustomerAttribut { case AttributeControlType.DropdownList: case AttributeControlType.RadioList: - { - var ctrlAttributes = model.FirstOrDefault(x => x.Key == attribute.Id)?.Value; - if (!string.IsNullOrEmpty(ctrlAttributes)) - customAttributes = _customerAttributeParser.AddCustomerAttribute(customAttributes, - attribute, ctrlAttributes).ToList(); - } + { + var ctrlAttributes = model.FirstOrDefault(x => x.Key == attribute.Id)?.Value; + if (!string.IsNullOrEmpty(ctrlAttributes)) + customAttributes = _customerAttributeParser.AddCustomerAttribute(customAttributes, + attribute, ctrlAttributes).ToList(); + } break; case AttributeControlType.Checkboxes: - { - var cblAttributes = model.FirstOrDefault(x => x.Key == attribute.Id)?.Value; - if (!string.IsNullOrEmpty(cblAttributes)) - foreach (var item in cblAttributes.Split(',')) - if (!string.IsNullOrEmpty(item)) - customAttributes = _customerAttributeParser.AddCustomerAttribute(customAttributes, - attribute, item).ToList(); - } + { + var cblAttributes = model.FirstOrDefault(x => x.Key == attribute.Id)?.Value; + if (!string.IsNullOrEmpty(cblAttributes)) + foreach (var item in cblAttributes.Split(',')) + if (!string.IsNullOrEmpty(item)) + customAttributes = _customerAttributeParser.AddCustomerAttribute(customAttributes, + attribute, item).ToList(); + } break; case AttributeControlType.ReadonlyCheckboxes: - { - //load read-only (already server-side selected) values - var attributeValues = attribute.CustomerAttributeValues; - foreach (var selectedAttributeId in attributeValues - .Where(v => v.IsPreSelected) - .Select(v => v.Id) - .ToList()) - customAttributes = _customerAttributeParser.AddCustomerAttribute(customAttributes, - attribute, selectedAttributeId).ToList(); - } + { + //load read-only (already server-side selected) values + var attributeValues = attribute.CustomerAttributeValues; + foreach (var selectedAttributeId in attributeValues + .Where(v => v.IsPreSelected) + .Select(v => v.Id) + .ToList()) + customAttributes = _customerAttributeParser.AddCustomerAttribute(customAttributes, + attribute, selectedAttributeId).ToList(); + } break; case AttributeControlType.TextBox: case AttributeControlType.MultilineTextbox: - { - var ctrlAttributes = model.FirstOrDefault(x => x.Key == attribute.Id)?.Value; - if (!string.IsNullOrEmpty(ctrlAttributes)) { - var enteredText = ctrlAttributes.Trim(); - customAttributes = _customerAttributeParser.AddCustomerAttribute(customAttributes, - attribute, enteredText).ToList(); + var ctrlAttributes = model.FirstOrDefault(x => x.Key == attribute.Id)?.Value; + if (!string.IsNullOrEmpty(ctrlAttributes)) + { + var enteredText = ctrlAttributes.Trim(); + customAttributes = _customerAttributeParser.AddCustomerAttribute(customAttributes, + attribute, enteredText).ToList(); + } } - } break; case AttributeControlType.Datepicker: case AttributeControlType.ColorSquares: From 62956beb508f3eadd456d6719b89cf723cbeb535 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 14 Jun 2026 13:12:13 +0200 Subject: [PATCH 8/9] Expand CustomerViewModelService test coverage Cover the StoreId read path (PrepareCustomerModel with an existing customer) plus delete/update/insert helpers, loyalty points, product price/personalized product, customer notes and list mapping to raise coverage of the changed file above the SonarQube threshold. Co-Authored-By: Claude Opus 4.8 --- .../Services/CustomerViewModelServiceTests.cs | 310 +++++++++++++++++- 1 file changed, 303 insertions(+), 7 deletions(-) diff --git a/src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs b/src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs index 838ef555c..765df6ee9 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs @@ -1,3 +1,4 @@ +using Grand.Business.Core.Interfaces.Authentication; using Grand.Business.Core.Interfaces.Catalog.Products; using Grand.Business.Core.Interfaces.Checkout.Orders; using Grand.Business.Core.Interfaces.Common.Addresses; @@ -13,9 +14,11 @@ using Grand.Domain.Catalog; using Grand.Domain.Common; using Grand.Domain.Customers; +using Grand.Domain.Media; using Grand.Domain.Orders; using Grand.Domain.Tax; using Grand.Domain.Vendors; +using Grand.Domain.Messages; using Grand.Infrastructure; using Grand.Web.AdminShared.Models.Customers; using Grand.Web.AdminShared.Services; @@ -41,6 +44,12 @@ public class CustomerViewModelServiceTests private Mock _vendorServiceMock; private Mock _enumTranslationServiceMock; private Mock _customerNoteServiceMock; + private Mock _customerProductServiceMock; + private Mock _loyaltyPointsServiceMock; + private Mock _productServiceMock; + private Mock _dateTimeServiceMock; + private Mock _downloadServiceMock; + private Mock _newsLetterSubscriptionServiceMock; private CustomerViewModelService _customerViewModelService; [TestInitialize] @@ -53,6 +62,15 @@ public void Setup() _vendorServiceMock = new Mock(); _enumTranslationServiceMock = new Mock(); _customerNoteServiceMock = new Mock(); + _customerProductServiceMock = new Mock(); + _loyaltyPointsServiceMock = new Mock(); + _productServiceMock = new Mock(); + _dateTimeServiceMock = new Mock(); + _downloadServiceMock = new Mock(); + _newsLetterSubscriptionServiceMock = new Mock(); + + _dateTimeServiceMock.Setup(d => d.ConvertToUserTime(It.IsAny(), It.IsAny())) + .Returns((dt, _) => dt); var workContextMock = new Mock(); workContextMock.Setup(w => w.CurrentCustomer).Returns(new Customer { Id = CurrentCustomerId }); @@ -69,6 +87,18 @@ public void Setup() customerAttributeServiceMock.Setup(c => c.GetAllCustomerAttributes()) .ReturnsAsync(new List()); + //http context with a service provider that resolves the external authentication service + var externalAuthServiceMock = new Mock(); + externalAuthServiceMock.Setup(e => e.GetExternalIdentifiers(It.IsAny())) + .ReturnsAsync(new List()); + var serviceProviderMock = new Mock(); + serviceProviderMock.Setup(s => s.GetService(typeof(IExternalAuthenticationService))) + .Returns(externalAuthServiceMock.Object); + var httpContextMock = new Mock(); + httpContextMock.Setup(c => c.RequestServices).Returns(serviceProviderMock.Object); + var httpContextAccessorMock = new Mock(); + httpContextAccessorMock.Setup(a => a.HttpContext).Returns(httpContextMock.Object); + _customerServiceMock.Setup(c => c.InsertCustomer(It.IsAny())).Returns(Task.CompletedTask); _customerServiceMock.Setup(c => c.UpdateCustomerInAdminPanel(It.IsAny())).Returns(Task.CompletedTask); _customerServiceMock @@ -90,14 +120,17 @@ public void Setup() _customerNoteServiceMock.Setup(c => c.InsertCustomerNote(It.IsAny())) .Returns(Task.CompletedTask); + _groupServiceMock.Setup(g => g.GetAllByIds(It.IsAny())) + .ReturnsAsync(new List()); + _customerViewModelService = new CustomerViewModelService( _customerServiceMock.Object, _groupServiceMock.Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, + _customerProductServiceMock.Object, + _newsLetterSubscriptionServiceMock.Object, + _dateTimeServiceMock.Object, new Mock().Object, - new Mock().Object, + _loyaltyPointsServiceMock.Object, new Mock().Object, contextAccessorMock.Object, _vendorServiceMock.Object, @@ -108,11 +141,11 @@ public void Setup() new Mock().Object, new Mock().Object, _customerTagServiceMock.Object, - new Mock().Object, + _productServiceMock.Object, salesEmployeeServiceMock.Object, _customerNoteServiceMock.Object, - new Mock().Object, - new Mock().Object, + _downloadServiceMock.Object, + httpContextAccessorMock.Object, new CustomerSettings(), new TaxSettings(), new LoyaltyPointsSettings(), @@ -143,6 +176,17 @@ public async Task PrepareCustomerModel_StoreManager_PresetsCurrentStoreId() Assert.AreEqual(CurrentStoreId, model.StoreId); } + [TestMethod] + public async Task PrepareCustomerModel_ExistingCustomer_MapsStoreIdFromEntity() + { + var customer = new Customer { Id = "c1", Email = "customer@example.com", StoreId = "store-9" }; + var model = new CustomerModel(); + + await _customerViewModelService.PrepareCustomerModel(model, customer, false); + + Assert.AreEqual("store-9", model.StoreId); + } + [TestMethod] public async Task PrepareCustomerModel_NonStoreManager_DoesNotPresetStoreId() { @@ -242,4 +286,256 @@ public async Task InsertCustomerNote_InsertsNoteWithMappedFields() c => c.InsertCustomerNote(It.Is(x => x.CustomerId == "c1" && x.Title == "title")), Times.Once); } + + [TestMethod] + public async Task DeleteCustomerNote_DeletesNoteAndAttachment() + { + var note = new CustomerNote { Id = "n1", DownloadId = "d1" }; + var download = new Download { Id = "d1" }; + _customerNoteServiceMock.Setup(c => c.GetCustomerNote("n1")).ReturnsAsync(note); + _customerNoteServiceMock.Setup(c => c.DeleteCustomerNote(It.IsAny())).Returns(Task.CompletedTask); + _downloadServiceMock.Setup(d => d.GetDownloadById("d1")).ReturnsAsync(download); + _downloadServiceMock.Setup(d => d.DeleteDownload(It.IsAny())).Returns(Task.CompletedTask); + + await _customerViewModelService.DeleteCustomerNote("n1", "c1"); + + _customerNoteServiceMock.Verify(c => c.DeleteCustomerNote(note), Times.Once); + _downloadServiceMock.Verify(d => d.DeleteDownload(download), Times.Once); + } + + [TestMethod] + public async Task DeleteCustomerNote_NotFound_Throws() + { + _customerNoteServiceMock.Setup(c => c.GetCustomerNote(It.IsAny())) + .ReturnsAsync((CustomerNote)null); + + await Assert.ThrowsAsync( + () => _customerViewModelService.DeleteCustomerNote("missing", "c1")); + } + + [TestMethod] + public async Task PrepareLoyaltyPointsHistoryModel_MapsHistory() + { + _loyaltyPointsServiceMock + .Setup(l => l.GetLoyaltyPointsHistory(It.IsAny(), It.IsAny(), true)) + .ReturnsAsync(new List { + new() { StoreId = "store-1", Points = 5, PointsBalance = 10, Message = "added" } + }); + _storeServiceMock.Setup(s => s.GetStoreById("store-1")) + .ReturnsAsync(new Grand.Domain.Stores.Store { Id = "store-1", Shortcut = "Store 1" }); + + var result = (await _customerViewModelService.PrepareLoyaltyPointsHistoryModel("c1")).ToList(); + + Assert.AreEqual(1, result.Count); + Assert.AreEqual("Store 1", result[0].StoreName); + Assert.AreEqual(5, result[0].Points); + } + + [TestMethod] + public async Task InsertLoyaltyPointsHistory_DelegatesToService() + { + var history = new LoyaltyPointsHistory(); + _loyaltyPointsServiceMock + .Setup(l => l.AddLoyaltyPointsHistory("c1", 10, "store-1", "msg", It.IsAny(), It.IsAny())) + .ReturnsAsync(history); + + var result = await _customerViewModelService.InsertLoyaltyPointsHistory( + new Customer { Id = "c1" }, "store-1", 10, "msg"); + + Assert.AreSame(history, result); + _loyaltyPointsServiceMock.Verify( + l => l.AddLoyaltyPointsHistory("c1", 10, "store-1", "msg", It.IsAny(), It.IsAny()), + Times.Once); + } + + [TestMethod] + public async Task DeleteAddress_RemovesAddressAndUpdatesCustomer() + { + var address = new Address { Id = "a1" }; + var customer = new Customer(); + customer.Addresses.Add(address); + + await _customerViewModelService.DeleteAddress(customer, address); + + Assert.IsFalse(customer.Addresses.Any(a => a.Id == "a1")); + _customerServiceMock.Verify(c => c.UpdateCustomerInAdminPanel(customer), Times.Once); + } + + [TestMethod] + public async Task UpdateProductPrice_UpdatesWhenFound() + { + var price = new CustomerProductPrice { Id = "pp1", Price = 1 }; + _customerProductServiceMock.Setup(c => c.GetCustomerProductPriceById("pp1")).ReturnsAsync(price); + _customerProductServiceMock.Setup(c => c.UpdateCustomerProductPrice(It.IsAny())) + .Returns(Task.CompletedTask); + + await _customerViewModelService.UpdateProductPrice( + new CustomerModel.ProductPriceModel { Id = "pp1", Price = 99 }); + + _customerProductServiceMock.Verify( + c => c.UpdateCustomerProductPrice(It.Is(x => x.Price == 99)), Times.Once); + } + + [TestMethod] + public async Task DeleteProductPrice_DeletesWhenFound() + { + var price = new CustomerProductPrice { Id = "pp1" }; + _customerProductServiceMock.Setup(c => c.GetCustomerProductPriceById("pp1")).ReturnsAsync(price); + _customerProductServiceMock.Setup(c => c.DeleteCustomerProductPrice(It.IsAny())) + .Returns(Task.CompletedTask); + + await _customerViewModelService.DeleteProductPrice("pp1"); + + _customerProductServiceMock.Verify(c => c.DeleteCustomerProductPrice(price), Times.Once); + } + + [TestMethod] + public async Task DeleteProductPrice_NotFound_Throws() + { + _customerProductServiceMock.Setup(c => c.GetCustomerProductPriceById(It.IsAny())) + .ReturnsAsync((CustomerProductPrice)null); + + await Assert.ThrowsAsync( + () => _customerViewModelService.DeleteProductPrice("missing")); + } + + [TestMethod] + public async Task UpdatePersonalizedProduct_UpdatesWhenFound() + { + var customerProduct = new CustomerProduct { Id = "cp1", DisplayOrder = 1 }; + _customerProductServiceMock.Setup(c => c.GetCustomerProduct("cp1")).ReturnsAsync(customerProduct); + _customerProductServiceMock.Setup(c => c.UpdateCustomerProduct(It.IsAny())) + .Returns(Task.CompletedTask); + + await _customerViewModelService.UpdatePersonalizedProduct( + new CustomerModel.ProductModel { Id = "cp1", DisplayOrder = 7 }); + + _customerProductServiceMock.Verify( + c => c.UpdateCustomerProduct(It.Is(x => x.DisplayOrder == 7)), Times.Once); + } + + [TestMethod] + public async Task DeletePersonalizedProduct_NotFound_Throws() + { + _customerProductServiceMock.Setup(c => c.GetCustomerProduct(It.IsAny())) + .ReturnsAsync((CustomerProduct)null); + + await Assert.ThrowsAsync( + () => _customerViewModelService.DeletePersonalizedProduct("missing")); + } + + [TestMethod] + public async Task PrepareProductPriceModel_MapsItems() + { + _customerProductServiceMock + .Setup(c => c.GetProductsPriceByCustomer(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new PagedList { new() { Id = "pp1", ProductId = "p1", Price = 3 } }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)) + .ReturnsAsync(new Product { Id = "p1", Name = "Prod 1" }); + + var (items, _) = await _customerViewModelService.PrepareProductPriceModel("c1", 1, 10); + + var list = items.ToList(); + Assert.AreEqual(1, list.Count); + Assert.AreEqual("Prod 1", list[0].ProductName); + Assert.AreEqual(3, list[0].Price); + } + + [TestMethod] + public async Task InsertCustomerAddProductModel_Personalized_InsertsCustomerProduct() + { + _productServiceMock.Setup(p => p.GetProductById("p1", false)) + .ReturnsAsync(new Product { Id = "p1", Price = 5 }); + _customerProductServiceMock.Setup(c => c.GetCustomerProduct("c1", "p1")).ReturnsAsync((CustomerProduct)null); + _customerProductServiceMock.Setup(c => c.InsertCustomerProduct(It.IsAny())) + .Returns(Task.CompletedTask); + + await _customerViewModelService.InsertCustomerAddProductModel("c1", true, + new CustomerModel.AddProductModel { SelectedProductIds = new[] { "p1" } }); + + _customerProductServiceMock.Verify( + c => c.InsertCustomerProduct(It.Is(x => x.CustomerId == "c1" && x.ProductId == "p1")), + Times.Once); + } + + [TestMethod] + public async Task InsertCustomerAddProductModel_NotPersonalized_InsertsProductPrice() + { + _productServiceMock.Setup(p => p.GetProductById("p1", false)) + .ReturnsAsync(new Product { Id = "p1", Price = 5 }); + _customerProductServiceMock.Setup(c => c.GetPriceByCustomerProduct("c1", "p1")) + .ReturnsAsync((double?)null); + _customerProductServiceMock.Setup(c => c.InsertCustomerProductPrice(It.IsAny())) + .Returns(Task.CompletedTask); + + await _customerViewModelService.InsertCustomerAddProductModel("c1", false, + new CustomerModel.AddProductModel { SelectedProductIds = new[] { "p1" } }); + + _customerProductServiceMock.Verify( + c => c.InsertCustomerProductPrice( + It.Is(x => x.CustomerId == "c1" && x.ProductId == "p1" && x.Price == 5)), + Times.Once); + } + + [TestMethod] + public async Task PreparePersonalizedProducts_MapsItems() + { + _customerProductServiceMock + .Setup(c => c.GetProductsByCustomer(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new PagedList + { new() { Id = "cp1", ProductId = "p1", DisplayOrder = 2 } }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)) + .ReturnsAsync(new Product { Id = "p1", Name = "Prod 1" }); + + var (items, _) = await _customerViewModelService.PreparePersonalizedProducts("c1", 1, 10); + + var list = items.ToList(); + Assert.AreEqual(1, list.Count); + Assert.AreEqual("Prod 1", list[0].ProductName); + Assert.AreEqual(2, list[0].DisplayOrder); + } + + [TestMethod] + public async Task DeleteCustomer_RemovesNewsletterSubscriptions() + { + var customer = new Customer { Id = "c1", Email = "customer@example.com" }; + var subscription = new NewsLetterSubscription { Id = "s1", Email = customer.Email, StoreId = "store-1" }; + _storeServiceMock.Setup(s => s.GetAllStores()) + .ReturnsAsync(new List { new() { Id = "store-1" } }); + _newsLetterSubscriptionServiceMock + .Setup(n => n.GetNewsLetterSubscriptionByEmailAndStoreId(customer.Email, "store-1")) + .ReturnsAsync(subscription); + _newsLetterSubscriptionServiceMock + .Setup(n => n.DeleteNewsLetterSubscription(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + await _customerViewModelService.DeleteCustomer(customer); + + _customerServiceMock.Verify(c => c.DeleteCustomer(customer), Times.Once); + _newsLetterSubscriptionServiceMock.Verify( + n => n.DeleteNewsLetterSubscription(subscription, It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task PrepareCustomerList_MapsCustomers() + { + _customerServiceMock + .Setup(c => c.GetAllCustomers( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>>())) + .ReturnsAsync(new PagedList + { new() { Id = "c1", Email = "customer@example.com", Active = true } }); + + var (list, _) = await _customerViewModelService.PrepareCustomerList( + new CustomerListModel(), new[] { "grp" }, new[] { "tag" }, 1, 10); + + var items = list.ToList(); + Assert.AreEqual(1, items.Count); + Assert.AreEqual("c1", items[0].Id); + Assert.AreEqual("customer@example.com", items[0].Email); + } } From d54cf84bb9f7fdd78e47f76bf808424f3cff00a9 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 14 Jun 2026 13:44:21 +0200 Subject: [PATCH 9/9] Add more tests for PrepareCustomerListModel and PrepareCustomerList Cover group/tag/settings mapping and the missing-registered-group case for the list model, plus sales-employee filtering, paging, guest mapping and the empty-result case for the customer list. Co-Authored-By: Claude Opus 4.8 --- .../Services/CustomerViewModelServiceTests.cs | 119 ++++++++++++++++-- 1 file changed, 107 insertions(+), 12 deletions(-) diff --git a/src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs b/src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs index 765df6ee9..beda3bb22 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs @@ -50,6 +50,8 @@ public class CustomerViewModelServiceTests private Mock _dateTimeServiceMock; private Mock _downloadServiceMock; private Mock _newsLetterSubscriptionServiceMock; + private Mock _translationServiceMock; + private Customer _currentCustomer; private CustomerViewModelService _customerViewModelService; [TestInitialize] @@ -68,12 +70,15 @@ public void Setup() _dateTimeServiceMock = new Mock(); _downloadServiceMock = new Mock(); _newsLetterSubscriptionServiceMock = new Mock(); + _translationServiceMock = new Mock(); + _translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns(k => k); _dateTimeServiceMock.Setup(d => d.ConvertToUserTime(It.IsAny(), It.IsAny())) .Returns((dt, _) => dt); + _currentCustomer = new Customer { Id = CurrentCustomerId }; var workContextMock = new Mock(); - workContextMock.Setup(w => w.CurrentCustomer).Returns(new Customer { Id = CurrentCustomerId }); + workContextMock.Setup(w => w.CurrentCustomer).Returns(_currentCustomer); var storeContextMock = new Mock(); storeContextMock.Setup(s => s.CurrentStore).Returns(new Grand.Domain.Stores.Store { Id = CurrentStoreId }); var contextAccessorMock = new Mock(); @@ -129,7 +134,7 @@ public void Setup() _customerProductServiceMock.Object, _newsLetterSubscriptionServiceMock.Object, _dateTimeServiceMock.Object, - new Mock().Object, + _translationServiceMock.Object, _loyaltyPointsServiceMock.Object, new Mock().Object, contextAccessorMock.Object, @@ -516,19 +521,53 @@ public async Task DeleteCustomer_RemovesNewsletterSubscriptions() n => n.DeleteNewsLetterSubscription(subscription, It.IsAny()), Times.Once); } + [TestMethod] + public async Task PrepareCustomerListModel_MapsSettingsGroupsAndTags() + { + var registered = new CustomerGroup { Id = "reg", Name = "Registered" }; + var guests = new CustomerGroup { Id = "gst", Name = "Guests" }; + _groupServiceMock.Setup(g => g.GetCustomerGroupBySystemName(It.IsAny())) + .ReturnsAsync(registered); + _groupServiceMock + .Setup(g => g.GetAllCustomerGroups(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new PagedList { registered, guests }); + _customerTagServiceMock.Setup(t => t.GetAllCustomerTags()) + .ReturnsAsync(new List { new() { Id = "t1", Name = "VIP" } }); + + var model = await _customerViewModelService.PrepareCustomerListModel(); + + Assert.AreEqual(2, model.AvailableCustomerGroups.Count); + Assert.IsTrue(model.AvailableCustomerGroups.Any(x => x.Value == "gst" && !x.Selected)); + Assert.IsTrue(model.AvailableCustomerTags.Any(x => x.Value == "t1" && x.Text == "VIP")); + Assert.AreEqual(new CustomerSettings().UsernamesEnabled, model.UsernamesEnabled); + Assert.AreEqual(new CustomerSettings().CompanyEnabled, model.CompanyEnabled); + } + + [TestMethod] + public async Task PrepareCustomerListModel_RegisteredGroupMissing_NullSearchGroupId() + { + _groupServiceMock.Setup(g => g.GetCustomerGroupBySystemName(It.IsAny())) + .ReturnsAsync(new CustomerGroup { Id = "reg" }); + _groupServiceMock + .Setup(g => g.GetAllCustomerGroups(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new PagedList { new() { Id = "other" } }); + _customerTagServiceMock.Setup(t => t.GetAllCustomerTags()).ReturnsAsync(new List()); + + var model = await _customerViewModelService.PrepareCustomerListModel(); + + //registered group is not among the available groups -> the search id resolves to null + Assert.AreEqual(1, model.SearchCustomerGroupIds.Count); + Assert.IsNull(model.SearchCustomerGroupIds.First()); + Assert.IsFalse(model.AvailableCustomerGroups.Any(x => x.Selected)); + } + [TestMethod] public async Task PrepareCustomerList_MapsCustomers() { - _customerServiceMock - .Setup(c => c.GetAllCustomers( - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny>>())) - .ReturnsAsync(new PagedList - { new() { Id = "c1", Email = "customer@example.com", Active = true } }); + SetupGetAllCustomers(new PagedList + { new() { Id = "c1", Email = "customer@example.com", Active = true } }); var (list, _) = await _customerViewModelService.PrepareCustomerList( new CustomerListModel(), new[] { "grp" }, new[] { "tag" }, 1, 10); @@ -538,4 +577,60 @@ public async Task PrepareCustomerList_MapsCustomers() Assert.AreEqual("c1", items[0].Id); Assert.AreEqual("customer@example.com", items[0].Email); } + + [TestMethod] + public async Task PrepareCustomerList_FiltersBySalesEmployeeAndPaging() + { + _currentCustomer.SeId = "se-1"; + SetupGetAllCustomers(new PagedList()); + + await _customerViewModelService.PrepareCustomerList( + new CustomerListModel(), new[] { "grp" }, new[] { "tag" }, 2, 15); + + //salesEmployeeId comes from the current customer, pageIndex is zero-based + _customerServiceMock.Verify(c => c.GetAllCustomers( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), "se-1", + It.Is(g => g.SequenceEqual(new[] { "grp" })), + It.Is(t => t.SequenceEqual(new[] { "tag" })), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), 1, 15, + It.IsAny>>()), Times.Once); + } + + [TestMethod] + public async Task PrepareCustomerList_GuestCustomer_UsesGuestResource() + { + SetupGetAllCustomers(new PagedList { new() { Id = "guest", Email = "", Active = true } }); + + var (list, _) = await _customerViewModelService.PrepareCustomerList( + new CustomerListModel(), Array.Empty(), Array.Empty(), 1, 10); + + Assert.AreEqual("Admin.Customers.Guest", list.First().Email); + } + + [TestMethod] + public async Task PrepareCustomerList_NoCustomers_ReturnsEmpty() + { + SetupGetAllCustomers(new PagedList()); + + var (list, _) = await _customerViewModelService.PrepareCustomerList( + new CustomerListModel(), Array.Empty(), Array.Empty(), 1, 10); + + Assert.AreEqual(0, list.Count()); + } + + private void SetupGetAllCustomers(IPagedList result) + { + _customerServiceMock + .Setup(c => c.GetAllCustomers( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>>())) + .ReturnsAsync(result); + } }