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/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/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 @@ - + 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 new file mode 100644 index 000000000..beda3bb22 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs @@ -0,0 +1,636 @@ +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; +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.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; +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; + +namespace Grand.Web.Admin.Tests.Services; + +[TestClass] +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 Mock _customerProductServiceMock; + private Mock _loyaltyPointsServiceMock; + private Mock _productServiceMock; + private Mock _dateTimeServiceMock; + private Mock _downloadServiceMock; + private Mock _newsLetterSubscriptionServiceMock; + private Mock _translationServiceMock; + private Customer _currentCustomer; + private CustomerViewModelService _customerViewModelService; + + [TestInitialize] + public void Setup() + { + _customerServiceMock = new Mock(); + _groupServiceMock = new Mock(); + _storeServiceMock = new Mock(); + _customerTagServiceMock = new Mock(); + _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(); + _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(_currentCustomer); + 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()); + + //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 + .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()); + + _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, + _customerProductServiceMock.Object, + _newsLetterSubscriptionServiceMock.Object, + _dateTimeServiceMock.Object, + _translationServiceMock.Object, + _loyaltyPointsServiceMock.Object, + new Mock().Object, + contextAccessorMock.Object, + _vendorServiceMock.Object, + _storeServiceMock.Object, + new Mock().Object, + customerAttributeServiceMock.Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + _customerTagServiceMock.Object, + _productServiceMock.Object, + salesEmployeeServiceMock.Object, + _customerNoteServiceMock.Object, + _downloadServiceMock.Object, + httpContextAccessorMock.Object, + new CustomerSettings(), + new TaxSettings(), + new LoyaltyPointsSettings(), + new AddressSettings(), + new CommonSettings(), + _enumTranslationServiceMock.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 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_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() + { + _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() + { + 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); + } + + [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); + } + + [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 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() + { + 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); + + var items = list.ToList(); + Assert.AreEqual(1, items.Count); + 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); + } +} 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.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.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: 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..a9d339eb5 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; @@ -264,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; @@ -385,7 +390,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 +550,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.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 c60e745e3..0b432bbbc 100644 Binary files a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml and b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml differ