From 10e037c0d53f3a802a83205bb626ef7a16e61766 Mon Sep 17 00:00:00 2001 From: support Date: Wed, 21 Oct 2026 22:25:12 +0200 Subject: [PATCH 01/27] Share the product controller test fixture through a base class --- .../Controllers/BaseProductControllerTests.cs | 86 +++-------------- .../Controllers/ProductControllerTestsBase.cs | 96 +++++++++++++++++++ .../Controllers/bash.exe.stackdump | 29 ++++++ 3 files changed, 139 insertions(+), 72 deletions(-) create mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs create mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/bash.exe.stackdump diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index 520a793e3..c3ecef56e 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -31,11 +31,11 @@ namespace Grand.Web.Admin.Tests.Controllers; [TestClass] -public class BaseProductControllerTests +public class BaseProductControllerTests : ProductControllerTestsBase { // BaseProductController is abstract - it has no host until Task 11 subclasses it. This // minimal subclass exists only so the actions under test can be invoked directly. - private class TestProductController( + public class TestProductController( IProductViewModelService productViewModelService, IProductService productService, IInventoryManageService inventoryManageService, @@ -51,76 +51,18 @@ private class TestProductController( languageService, translationService, productReservationService, auctionService, dateTimeService, permissionService, enumTranslationService, scope); - private TestProductController _controller; - private Mock _productServiceMock; - private Mock _productViewModelServiceMock; - private Mock _translationServiceMock; - private Mock> _scopeMock; - private Mock _permissionServiceMock; - private Mock _productReservationServiceMock; - private Mock _auctionServiceMock; - - [TestInitialize] - public void Setup() - { - var mapperConfig = new MapperConfiguration(cfg => - { - cfg.AddProfile(); - cfg.AddProfile(); - }); - AutoMapperConfig.Init(mapperConfig); - - _productServiceMock = new Mock(); - _productViewModelServiceMock = new Mock(); - _translationServiceMock = new Mock(); - _translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns("resource"); - - _scopeMock = new Mock>(); - _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Admin"); - _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); - - _permissionServiceMock = new Mock(); - _permissionServiceMock.Setup(p => p.Authorize(It.IsAny())).ReturnsAsync(true); - - var languageServiceMock = new Mock(); - languageServiceMock.Setup(l => l.GetAllLanguages(true, It.IsAny())).ReturnsAsync(new List()); - - _productReservationServiceMock = new Mock(); - _auctionServiceMock = new Mock(); - - _controller = new TestProductController( - _productViewModelServiceMock.Object, - _productServiceMock.Object, - new Mock().Object, - languageServiceMock.Object, - _translationServiceMock.Object, - _productReservationServiceMock.Object, - _auctionServiceMock.Object, - new Mock().Object, - _permissionServiceMock.Object, - new Mock().Object, - _scopeMock.Object); - - var httpContext = new DefaultHttpContext(); - // Needed for actions whose catch (Exception) block calls Error(exc), which logs via - // HttpContext.RequestServices.GetRequiredService() (see Export / Import below). - // Once RequestServices is non-null, ControllerBase.Url's own - // HttpContext.RequestServices.GetRequiredService() call (used by every - // RedirectToAction(action, controller) - e.g. GoToSku above) stops being short-circuited by the - // null-conditional it uses when RequestServices itself is null, so IUrlHelperFactory must resolve - // too or those pre-existing tests start throwing. - var loggerFactoryMock = new Mock(); - loggerFactoryMock.Setup(l => l.CreateLogger(It.IsAny())).Returns(new Mock().Object); - var urlHelperFactoryMock = new Mock(); - urlHelperFactoryMock.Setup(f => f.GetUrlHelper(It.IsAny())) - .Returns(new Mock().Object); - var requestServicesMock = new Mock(); - requestServicesMock.Setup(s => s.GetService(typeof(ILoggerFactory))).Returns(loggerFactoryMock.Object); - requestServicesMock.Setup(s => s.GetService(typeof(IUrlHelperFactory))).Returns(urlHelperFactoryMock.Object); - httpContext.RequestServices = requestServicesMock.Object; - _controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; - _controller.TempData = new TempDataDictionary(httpContext, new Mock().Object); - } + protected override TestProductController CreateController() => new( + _productViewModelServiceMock.Object, + _productServiceMock.Object, + _inventoryManageServiceMock.Object, + _languageServiceMock.Object, + _translationServiceMock.Object, + _productReservationServiceMock.Object, + _auctionServiceMock.Object, + _dateTimeServiceMock.Object, + _permissionServiceMock.Object, + _enumTranslationServiceMock.Object, + _scopeMock.Object); // --- Edit (GET) -------------------------------------------------------------------------------- diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs new file mode 100644 index 000000000..560bbfe7d --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs @@ -0,0 +1,96 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Domain.Catalog; +using Grand.Domain.Permissions; +using Grand.Infrastructure.Mapper; +using Grand.Mapping; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Mapper; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Admin.Tests.Controllers; + +/// +/// Fixture shared by the tests of BaseProductController and the per-tab product controllers split out of it. +/// Each derived class only says how to build its controller from the mocks below. +/// +public abstract class ProductControllerTestsBase where TController : Controller +{ + protected TController _controller; + protected Mock _productServiceMock; + protected Mock _productViewModelServiceMock; + protected Mock _translationServiceMock; + protected Mock> _scopeMock; + protected Mock _permissionServiceMock; + protected Mock _productReservationServiceMock; + protected Mock _auctionServiceMock; + protected Mock _inventoryManageServiceMock; + protected Mock _languageServiceMock; + protected Mock _dateTimeServiceMock; + protected Mock _enumTranslationServiceMock; + + protected abstract TController CreateController(); + + [TestInitialize] + public void Setup() + { + var mapperConfig = new MapperConfiguration(cfg => + { + cfg.AddProfile(); + cfg.AddProfile(); + }); + AutoMapperConfig.Init(mapperConfig); + + _productServiceMock = new Mock(); + _productViewModelServiceMock = new Mock(); + _translationServiceMock = new Mock(); + _translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns("resource"); + + _scopeMock = new Mock>(); + _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Admin"); + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + + _permissionServiceMock = new Mock(); + _permissionServiceMock.Setup(p => p.Authorize(It.IsAny())).ReturnsAsync(true); + + _languageServiceMock = new Mock(); + _languageServiceMock.Setup(l => l.GetAllLanguages(true, It.IsAny())).ReturnsAsync(new List()); + + _productReservationServiceMock = new Mock(); + _auctionServiceMock = new Mock(); + _inventoryManageServiceMock = new Mock(); + _dateTimeServiceMock = new Mock(); + _enumTranslationServiceMock = new Mock(); + + _controller = CreateController(); + + var httpContext = new DefaultHttpContext(); + // Needed for actions whose catch (Exception) block calls Error(exc), which logs via + // HttpContext.RequestServices.GetRequiredService() (see Export / Import). + // Once RequestServices is non-null, ControllerBase.Url's own + // HttpContext.RequestServices.GetRequiredService() call (used by every + // RedirectToAction(action, controller) - e.g. GoToSku) stops being short-circuited by the + // null-conditional it uses when RequestServices itself is null, so IUrlHelperFactory must resolve + // too or those tests start throwing. + var loggerFactoryMock = new Mock(); + loggerFactoryMock.Setup(l => l.CreateLogger(It.IsAny())).Returns(new Mock().Object); + var urlHelperFactoryMock = new Mock(); + urlHelperFactoryMock.Setup(f => f.GetUrlHelper(It.IsAny())) + .Returns(new Mock().Object); + var requestServicesMock = new Mock(); + requestServicesMock.Setup(s => s.GetService(typeof(ILoggerFactory))).Returns(loggerFactoryMock.Object); + requestServicesMock.Setup(s => s.GetService(typeof(IUrlHelperFactory))).Returns(urlHelperFactoryMock.Object); + httpContext.RequestServices = requestServicesMock.Object; + _controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; + _controller.TempData = new TempDataDictionary(httpContext, new Mock().Object); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/bash.exe.stackdump b/src/Tests/Grand.Web.Admin.Tests/Controllers/bash.exe.stackdump new file mode 100644 index 000000000..aa91f7e17 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/bash.exe.stackdump @@ -0,0 +1,29 @@ +Stack trace: +Frame Function Args +0007FFFFBEF0 00021005FE8E (000210285F68, 00021026AB6E, 000000000000, 0007FFFFADF0) msys-2.0.dll+0x1FE8E +0007FFFFBEF0 0002100467F9 (000000000000, 000000000000, 000000000000, 0007FFFFC1C8) msys-2.0.dll+0x67F9 +0007FFFFBEF0 000210046832 (000210286019, 0007FFFFBDA8, 000000000000, 000000000000) msys-2.0.dll+0x6832 +0007FFFFBEF0 000210068CF6 (000000000000, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x28CF6 +0007FFFFBEF0 000210068E24 (0007FFFFBF00, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x28E24 +0007FFFFC1D0 00021006A225 (0007FFFFBF00, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x2A225 +End of stack trace +Loaded modules: +000100400000 bash.exe +7FFEBEF20000 ntdll.dll +7FFEBE1D0000 KERNEL32.DLL +7FFEBC110000 KERNELBASE.dll +7FFEB8800000 apphelp.dll +7FFEBD680000 USER32.dll +7FFEBC520000 win32u.dll +000210040000 msys-2.0.dll +7FFEBE4B0000 GDI32.dll +7FFEBCB00000 gdi32full.dll +7FFEBC8F0000 msvcp_win.dll +7FFEBC9A0000 ucrtbase.dll +7FFEBD860000 advapi32.dll +7FFEBE400000 msvcrt.dll +7FFEBD930000 sechost.dll +7FFEBE2B0000 RPCRT4.dll +7FFEBB690000 CRYPTBASE.DLL +7FFEBC830000 bcryptPrimitives.dll +7FFEBD9E0000 IMM32.DLL From b461262731f84b363eb94681cca64e92eb0bc639 Mon Sep 17 00:00:00 2001 From: support Date: Thu, 22 Oct 2026 06:20:55 +0200 Subject: [PATCH 02/27] Move the product bids tab into its own controller --- .../BaseProductBidControllerTests.cs | 212 ++++++++++++++++++ .../Controllers/BaseProductControllerTests.cs | 173 -------------- .../Controllers/ProductBidController.cs | 22 ++ .../Controllers/BaseProductBidController.cs | 97 ++++++++ .../Controllers/BaseProductController.cs | 72 ------ .../Partials/CreateOrUpdate.Bids.cshtml | 4 +- .../Controllers/ProductBidController.cs | 22 ++ .../Controllers/ProductBidController.cs | 22 ++ 8 files changed, 377 insertions(+), 247 deletions(-) create mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductBidControllerTests.cs create mode 100644 src/Web/Grand.Web.Admin/Controllers/ProductBidController.cs create mode 100644 src/Web/Grand.Web.AdminShared/Controllers/BaseProductBidController.cs create mode 100644 src/Web/Grand.Web.Store/Controllers/ProductBidController.cs create mode 100644 src/Web/Grand.Web.Vendor/Controllers/ProductBidController.cs diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductBidControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductBidControllerTests.cs new file mode 100644 index 000000000..328524115 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductBidControllerTests.cs @@ -0,0 +1,212 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Linq.Expressions; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class BaseProductBidControllerTests : ProductControllerTestsBase +{ + // BaseProductBidController is abstract. This minimal subclass exists only so the actions under test + // can be invoked directly. + public class TestProductBidController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAuctionService auctionService, + IAdminDataScope scope) + : BaseProductBidController(productViewModelService, productService, translationService, auctionService, scope); + + protected override TestProductBidController CreateController() => new( + _productViewModelServiceMock.Object, + _productServiceMock.Object, + _translationServiceMock.Object, + _auctionServiceMock.Object, + _scopeMock.Object); + + private static Bid NewBid(string id, string productId, string orderId = "") => + new() { Id = id, ProductId = productId, OrderId = orderId, Date = DateTime.UtcNow }; + + // --- ListBids -------------------------------------------------------------------------------- + + [TestMethod] + public async Task ListBids_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing")).ReturnsAsync((Product)null); + + await Assert.ThrowsExactlyAsync( + () => _controller.ListBids(new Grand.Web.Common.DataSource.DataSourceRequest(), "missing")); + } + + [TestMethod] + public async Task ListBids_ScopeDeniesAccess_ReturnsKendoGridError_DoesNotQuery() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + + var result = await _controller.ListBids(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.PrepareBidMode(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ListBids_ScopeGrantsAccess_ReturnsBids() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareBidMode("p1", 0, 10)) + .ReturnsAsync((Enumerable.Empty(), 0)); + + var result = await _controller.ListBids( + new Grand.Web.Common.DataSource.DataSourceRequest { PageSize = 10 }, "p1"); + + Assert.IsInstanceOfType(result); + var json = result as JsonResult; + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(0, gridModel.Total); + } + + // A product shared with other stores opens read-only on Store: the tab loads, but the bids + // (other stores' customers) are not listed. + [TestMethod] + public async Task ListBids_ViewableButNotOwned_ReturnsEmptyGrid_DoesNotQuery() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ListBids(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var gridModel = (result as JsonResult)?.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(0, gridModel.Total); + _translationServiceMock.Verify(t => t.GetResource(It.IsAny()), Times.Never); + _productViewModelServiceMock.Verify( + s => s.PrepareBidMode(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + // --- BidDelete --------------------------------------------------------------------------------- + + [TestMethod] + public async Task BidDelete_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing")).ReturnsAsync((Product)null); + var model = new ProductModel.BidModel { ProductId = "missing", BidId = "b1" }; + + await Assert.ThrowsExactlyAsync(() => _controller.BidDelete(model)); + } + + [TestMethod] + public async Task BidDelete_ScopeDeniesAccess_ReturnsKendoGridError_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.BidModel { ProductId = "p1", BidId = "b1" }; + + var result = await _controller.BidDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _auctionServiceMock.Verify(s => s.DeleteBid(It.IsAny()), Times.Never); + } + + // Regression guard for the same cross-product IDOR shape as ProductReservationDelete above (found by + // the row 23 reviewer to apply identically here): the access check ran against model.ProductId while + // the delete ran against model.BidId, with no check that the two referred to the same product. A + // caller with access to *any* product could pass that product's id for the access check while + // supplying the BidId of a bid belonging to a different, unowned product, deleting it. + [TestMethod] + public async Task BidDelete_BidBelongsToDifferentProduct_ReturnsKendoGridError_DoesNotDelete() + { + var ownedProduct = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(ownedProduct); + _scopeMock.Setup(s => s.HasAccess(ownedProduct)).ReturnsAsync(true); + var foreignBid = NewBid("b1", "p2"); + _auctionServiceMock.Setup(s => s.GetBid("b1")).ReturnsAsync(foreignBid); + var model = new ProductModel.BidModel { ProductId = "p1", BidId = "b1" }; + + var result = await _controller.BidDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _auctionServiceMock.Verify(s => s.DeleteBid(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task BidDelete_BidHasOrder_ReturnsErrorsJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var bid = NewBid("b1", "p1", "order1"); + _auctionServiceMock.Setup(s => s.GetBid("b1")).ReturnsAsync(bid); + var model = new ProductModel.BidModel { ProductId = "p1", BidId = "b1" }; + + var result = await _controller.BidDelete(model); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.IsNotNull(gridModel.Errors); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Bids.CantDeleteWithOrder"), Times.Once); + _auctionServiceMock.Verify(s => s.DeleteBid(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task BidDelete_ScopeGrantsAccess_SameProduct_Deletes() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var bid = NewBid("b1", "p1"); + _auctionServiceMock.Setup(s => s.GetBid("b1")).ReturnsAsync(bid); + var model = new ProductModel.BidModel { ProductId = "p1", BidId = "b1" }; + + var result = await _controller.BidDelete(model); + + Assert.IsInstanceOfType(result); + _auctionServiceMock.Verify(s => s.DeleteBid(bid), Times.Once); + } + + [TestMethod] + public async Task BidDelete_BidNotFound_ReturnsErrorsJson_NoOp() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _auctionServiceMock.Setup(s => s.GetBid("missing")).ReturnsAsync((Bid)null); + var model = new ProductModel.BidModel { ProductId = "p1", BidId = "missing" }; + + var result = await _controller.BidDelete(model); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual("Bid not exists", gridModel.Errors); + _auctionServiceMock.Verify(s => s.DeleteBid(It.IsAny()), Times.Never); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index c3ecef56e..d0973f380 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -5521,177 +5521,4 @@ public async Task ProductReservationDelete_ReservationNotFound_ReturnsEmptyJson_ _productReservationServiceMock.Verify( s => s.DeleteProductReservation(It.IsAny()), Times.Never); } - - private static Bid NewBid(string id, string productId, string orderId = "") => - new() { Id = id, ProductId = productId, OrderId = orderId, Date = DateTime.UtcNow }; - - // --- ListBids -------------------------------------------------------------------------------- - - [TestMethod] - public async Task ListBids_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing")).ReturnsAsync((Product)null); - - await Assert.ThrowsExactlyAsync( - () => _controller.ListBids(new Grand.Web.Common.DataSource.DataSourceRequest(), "missing")); - } - - [TestMethod] - public async Task ListBids_ScopeDeniesAccess_ReturnsKendoGridError_DoesNotQuery() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - - var result = await _controller.ListBids(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( - s => s.PrepareBidMode(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ListBids_ScopeGrantsAccess_ReturnsBids() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _productViewModelServiceMock.Setup(s => s.PrepareBidMode("p1", 0, 10)) - .ReturnsAsync((Enumerable.Empty(), 0)); - - var result = await _controller.ListBids( - new Grand.Web.Common.DataSource.DataSourceRequest { PageSize = 10 }, "p1"); - - Assert.IsInstanceOfType(result); - var json = result as JsonResult; - var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.AreEqual(0, gridModel.Total); - } - - // A product shared with other stores opens read-only on Store: the tab loads, but the bids - // (other stores' customers) are not listed. - [TestMethod] - public async Task ListBids_ViewableButNotOwned_ReturnsEmptyGrid_DoesNotQuery() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - var result = await _controller.ListBids(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - var gridModel = (result as JsonResult)?.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.AreEqual(0, gridModel.Total); - _translationServiceMock.Verify(t => t.GetResource(It.IsAny()), Times.Never); - _productViewModelServiceMock.Verify( - s => s.PrepareBidMode(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - } - - // --- BidDelete --------------------------------------------------------------------------------- - - [TestMethod] - public async Task BidDelete_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing")).ReturnsAsync((Product)null); - var model = new ProductModel.BidModel { ProductId = "missing", BidId = "b1" }; - - await Assert.ThrowsExactlyAsync(() => _controller.BidDelete(model)); - } - - [TestMethod] - public async Task BidDelete_ScopeDeniesAccess_ReturnsKendoGridError_DoesNotDelete() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.BidModel { ProductId = "p1", BidId = "b1" }; - - var result = await _controller.BidDelete(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _auctionServiceMock.Verify(s => s.DeleteBid(It.IsAny()), Times.Never); - } - - // Regression guard for the same cross-product IDOR shape as ProductReservationDelete above (found by - // the row 23 reviewer to apply identically here): the access check ran against model.ProductId while - // the delete ran against model.BidId, with no check that the two referred to the same product. A - // caller with access to *any* product could pass that product's id for the access check while - // supplying the BidId of a bid belonging to a different, unowned product, deleting it. - [TestMethod] - public async Task BidDelete_BidBelongsToDifferentProduct_ReturnsKendoGridError_DoesNotDelete() - { - var ownedProduct = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(ownedProduct); - _scopeMock.Setup(s => s.HasAccess(ownedProduct)).ReturnsAsync(true); - var foreignBid = NewBid("b1", "p2"); - _auctionServiceMock.Setup(s => s.GetBid("b1")).ReturnsAsync(foreignBid); - var model = new ProductModel.BidModel { ProductId = "p1", BidId = "b1" }; - - var result = await _controller.BidDelete(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _auctionServiceMock.Verify(s => s.DeleteBid(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task BidDelete_BidHasOrder_ReturnsErrorsJson_DoesNotDelete() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var bid = NewBid("b1", "p1", "order1"); - _auctionServiceMock.Setup(s => s.GetBid("b1")).ReturnsAsync(bid); - var model = new ProductModel.BidModel { ProductId = "p1", BidId = "b1" }; - - var result = await _controller.BidDelete(model); - - var json = result as JsonResult; - Assert.IsNotNull(json); - var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.IsNotNull(gridModel.Errors); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Bids.CantDeleteWithOrder"), Times.Once); - _auctionServiceMock.Verify(s => s.DeleteBid(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task BidDelete_ScopeGrantsAccess_SameProduct_Deletes() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var bid = NewBid("b1", "p1"); - _auctionServiceMock.Setup(s => s.GetBid("b1")).ReturnsAsync(bid); - var model = new ProductModel.BidModel { ProductId = "p1", BidId = "b1" }; - - var result = await _controller.BidDelete(model); - - Assert.IsInstanceOfType(result); - _auctionServiceMock.Verify(s => s.DeleteBid(bid), Times.Once); - } - - [TestMethod] - public async Task BidDelete_BidNotFound_ReturnsErrorsJson_NoOp() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _auctionServiceMock.Setup(s => s.GetBid("missing")).ReturnsAsync((Bid)null); - var model = new ProductModel.BidModel { ProductId = "p1", BidId = "missing" }; - - var result = await _controller.BidDelete(model); - - var json = result as JsonResult; - Assert.IsNotNull(json); - var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.AreEqual("Bid not exists", gridModel.Errors); - _auctionServiceMock.Verify(s => s.DeleteBid(It.IsAny()), Times.Never); - } } diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductBidController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductBidController.cs new file mode 100644 index 000000000..c5f18311a --- /dev/null +++ b/src/Web/Grand.Web.Admin/Controllers/ProductBidController.cs @@ -0,0 +1,22 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.Admin.Extensions; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Admin.Controllers; + +[AuthorizeAdmin] +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaAdmin)] +[AuthorizeMenu] +public class ProductBidController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAuctionService auctionService, + IAdminDataScope scope) + : BaseProductBidController(productViewModelService, productService, translationService, auctionService, scope); diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductBidController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductBidController.cs new file mode 100644 index 000000000..73a7a5b3c --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductBidController.cs @@ -0,0 +1,97 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Domain.Permissions; +using Grand.SharedKernel.Extensions; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.AdminShared.Controllers; + +[PermissionAuthorize(PermissionSystemName.Products)] +[AutoValidateAntiforgeryToken] +public abstract class BaseProductBidController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAuctionService auctionService, + IAdminDataScope scope) + : BaseController +{ + #region Bids + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ListBids(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // CanView on this read: a product shared with other stores opens read-only on Store, so its + // tabs must load; the mutating actions below keep the strict HasAccess. History: + // HasAccess: mirrors Store's CanAccessProduct and Vendor's HasAccessToProduct checks; Admin's + // original had no check at all - normalized to scope.HasAccess for all three hosts. + if (!await scope.CanView(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + //a product shared with other stores is shown read-only; its bids and reservations carry + //other stores' customers and orders, so they are listed only for a product this store owns + if (!await scope.HasAccess(product)) + return Json(new DataSourceResult { Data = Array.Empty(), Total = 0 }); + + var (bidModels, totalCount) = + await productViewModelService.PrepareBidMode(productId, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = bidModels.ToList(), + Total = totalCount + }; + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task BidDelete(ProductModel.BidModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var toDelete = await auctionService.GetBid(model.BidId); + + // Cross-product IDOR closed here, identical shape to ProductReservationDelete above: none of the + // three original hosts verified that the bid being deleted (looked up purely by model.BidId) + // actually belongs to the product just access-checked (model.ProductId). Both are independent, + // attacker-supplied POST fields - Store and Vendor's original code let a caller who owns/has + // access to *any* product pass the access check using that product's id while supplying a BidId + // belonging to a different, unowned product, deleting a bid on a product they have no access to. + if (toDelete != null && toDelete.ProductId != product.Id) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (toDelete != null) + { + if (string.IsNullOrEmpty(toDelete.OrderId)) + { + //delete bid + await auctionService.DeleteBid(toDelete); + return Json(""); + } + + return Json(new DataSourceResult { + Errors = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Bids.CantDeleteWithOrder") + }); + } + + return Json(new DataSourceResult { Errors = "Bid not exists" }); + } + + #endregion +} diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index 15eb5161c..45f7acf94 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -3321,76 +3321,4 @@ public async Task ProductReservationDelete(ProductModel.Reservati } #endregion - - #region Bids - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ListBids(DataSourceRequest command, string productId) - { - var product = await productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - // CanView on this read: a product shared with other stores opens read-only on Store, so its - // tabs must load; the mutating actions below keep the strict HasAccess. History: - // HasAccess: mirrors Store's CanAccessProduct and Vendor's HasAccessToProduct checks; Admin's - // original had no check at all - normalized to scope.HasAccess for all three hosts. - if (!await scope.CanView(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - //a product shared with other stores is shown read-only; its bids and reservations carry - //other stores' customers and orders, so they are listed only for a product this store owns - if (!await scope.HasAccess(product)) - return Json(new DataSourceResult { Data = Array.Empty(), Total = 0 }); - - var (bidModels, totalCount) = - await productViewModelService.PrepareBidMode(productId, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = bidModels.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BidDelete(ProductModel.BidModel model) - { - var product = await productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var toDelete = await auctionService.GetBid(model.BidId); - - // Cross-product IDOR closed here, identical shape to ProductReservationDelete above: none of the - // three original hosts verified that the bid being deleted (looked up purely by model.BidId) - // actually belongs to the product just access-checked (model.ProductId). Both are independent, - // attacker-supplied POST fields - Store and Vendor's original code let a caller who owns/has - // access to *any* product pass the access check using that product's id while supplying a BidId - // belonging to a different, unowned product, deleting a bid on a product they have no access to. - if (toDelete != null && toDelete.ProductId != product.Id) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (toDelete != null) - { - if (string.IsNullOrEmpty(toDelete.OrderId)) - { - //delete bid - await auctionService.DeleteBid(toDelete); - return Json(""); - } - - return Json(new DataSourceResult { - Errors = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Bids.CantDeleteWithOrder") - }); - } - - return Json(new DataSourceResult { Errors = "Bid not exists" }); - } - - #endregion } diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Bids.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Bids.cshtml index 4af366861..04687ae07 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Bids.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Bids.cshtml @@ -13,8 +13,8 @@
diff --git a/src/Web/Grand.Web.Store/Controllers/ProductBidController.cs b/src/Web/Grand.Web.Store/Controllers/ProductBidController.cs new file mode 100644 index 000000000..24d92f9bd --- /dev/null +++ b/src/Web/Grand.Web.Store/Controllers/ProductBidController.cs @@ -0,0 +1,22 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Store.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Store.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaStore)] +[AuthorizeStore] +[AuthorizeMenu] +public class ProductBidController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAuctionService auctionService, + IAdminDataScope scope) + : BaseProductBidController(productViewModelService, productService, translationService, auctionService, scope); diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductBidController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductBidController.cs new file mode 100644 index 000000000..4af7ea3fa --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductBidController.cs @@ -0,0 +1,22 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Vendor.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Vendor.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaVendor)] +[AuthorizeVendor] +[AuthorizeMenu] +public class ProductBidController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAuctionService auctionService, + IAdminDataScope scope) + : BaseProductBidController(productViewModelService, productService, translationService, auctionService, scope); From cef521a224a61828f2df0d4a18f80d856ec412a5 Mon Sep 17 00:00:00 2001 From: support Date: Thu, 22 Oct 2026 06:22:42 +0200 Subject: [PATCH 03/27] Move the product reservations tab into its own controller --- .../Controllers/BaseProductControllerTests.cs | 338 ---------------- .../BaseProductReservationControllerTests.cs | 377 ++++++++++++++++++ .../ProductReservationController.cs | 22 + .../Controllers/BaseProductController.cs | 252 ------------ .../BaseProductReservationController.cs | 280 +++++++++++++ .../Partials/CreateOrUpdate.Calendar.cshtml | 10 +- .../ProductReservationController.cs | 22 + .../ProductReservationController.cs | 22 + 8 files changed, 728 insertions(+), 595 deletions(-) create mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductReservationControllerTests.cs create mode 100644 src/Web/Grand.Web.Admin/Controllers/ProductReservationController.cs create mode 100644 src/Web/Grand.Web.AdminShared/Controllers/BaseProductReservationController.cs create mode 100644 src/Web/Grand.Web.Store/Controllers/ProductReservationController.cs create mode 100644 src/Web/Grand.Web.Vendor/Controllers/ProductReservationController.cs diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index d0973f380..6dba20655 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -5183,342 +5183,4 @@ public async Task ProductAttributeCombinationTierPriceDelete_ScopeGrantsAccess_D _productViewModelServiceMock.Verify( s => s.DeleteProductAttributeCombinationTierPrices(product, combination, tierPrice), Times.Once); } - - private static ProductReservation NewReservation(string id, string productId, string orderId = "") => - new() { Id = id, ProductId = productId, OrderId = orderId, Date = DateTime.UtcNow }; - - // --- ListReservations ---------------------------------------------------------------------------- - - [TestMethod] - public async Task ListReservations_ScopeDeniesAccess_ReturnsKendoGridError_DoesNotQuery() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - - var result = await _controller.ListReservations( - new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productReservationServiceMock.Verify( - s => s.GetProductReservationsByProductId(It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ListReservations_ScopeGrantsAccess_ReturnsGrid() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var reservations = new PagedList( - new List { NewReservation("r1", "p1") }, 0, 10); - _productReservationServiceMock - .Setup(s => s.GetProductReservationsByProductId("p1", null, null, 0, 10)) - .ReturnsAsync(reservations); - - var result = await _controller.ListReservations( - new Grand.Web.Common.DataSource.DataSourceRequest { Page = 1, PageSize = 10 }, "p1"); - - var json = result as JsonResult; - Assert.IsNotNull(json); - var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.AreEqual(1, gridModel.Total); - } - - // A product shared with other stores opens read-only on Store: the tab loads, but the - // reservations (other stores' orders) are not listed. - [TestMethod] - public async Task ListReservations_ViewableButNotOwned_ReturnsEmptyGrid_DoesNotQuery() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - var result = await _controller.ListReservations( - new Grand.Web.Common.DataSource.DataSourceRequest { Page = 1, PageSize = 10 }, "p1"); - - var gridModel = (result as JsonResult)?.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.AreEqual(0, gridModel.Total); - _translationServiceMock.Verify(t => t.GetResource(It.IsAny()), Times.Never); - _productReservationServiceMock.Verify( - s => s.GetProductReservationsByProductId(It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny()), Times.Never); - } - - // --- GenerateCalendar ------------------------------------------------------------------------------ - - private static ProductModel.GenerateCalendarModel ValidCalendarModel(IntervalUnit unit = IntervalUnit.Day) => new() { - StartDate = new DateTime(2026, 1, 1), - EndDate = new DateTime(2026, 1, 1), - StartTime = new DateTime(2026, 1, 1, 8, 0, 0), - EndTime = new DateTime(2026, 1, 1, 18, 0, 0), - Interval = 1, - IntervalUnit = (int)unit, - Quantity = 1, - Resource = "room1", - // 2026-01-01 is a Thursday. - Thursday = true - }; - - [TestMethod] - public async Task GenerateCalendar_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing")).ReturnsAsync((Product)null); - - await Assert.ThrowsExactlyAsync( - () => _controller.GenerateCalendar("missing", ValidCalendarModel())); - } - - [TestMethod] - public async Task GenerateCalendar_ScopeDeniesAccess_ReturnsErrorsJson_DoesNotUpdate() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - var result = await _controller.GenerateCalendar("p1", ValidCalendarModel()); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productReservationServiceMock.Verify( - s => s.InsertProductReservation(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task GenerateCalendar_InvalidModelState_ReturnsErrorsJson_DoesNotInsert() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _productReservationServiceMock - .Setup(s => s.GetProductReservationsByProductId("p1", null, null)) - .ReturnsAsync(new PagedList(new List(), 0, int.MaxValue)); - _controller.ModelState.AddModelError("Resource", "Required"); - - var result = await _controller.GenerateCalendar("p1", ValidCalendarModel()); - - Assert.IsInstanceOfType(result); - _productReservationServiceMock.Verify( - s => s.InsertProductReservation(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task GenerateCalendar_ScopeGrantsAccess_Valid_InsertsReservationAndUpdatesProductFields() - { - var product = new Product { Id = "p1", IntervalUnitId = IntervalUnit.Day }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _productReservationServiceMock - .Setup(s => s.GetProductReservationsByProductId("p1", null, null)) - .ReturnsAsync(new PagedList(new List(), 0, int.MaxValue)); - - var result = await _controller.GenerateCalendar("p1", ValidCalendarModel()); - - Assert.IsInstanceOfType(result); - _productReservationServiceMock.Verify( - s => s.InsertProductReservation(It.Is(r => - r.ProductId == "p1" && r.Resource == "room1")), Times.Once); - _productServiceMock.Verify( - s => s.UpdateProductField(product, It.IsAny>>(), 1), Times.Once); - } - - // --- ClearCalendar ---------------------------------------------------------------------------------- - - [TestMethod] - public async Task ClearCalendar_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing")).ReturnsAsync((Product)null); - - await Assert.ThrowsExactlyAsync(() => _controller.ClearCalendar("missing")); - } - - [TestMethod] - public async Task ClearCalendar_ScopeDeniesAccess_Throws_DoesNotDelete() - { - // Throws rather than returning Json({errors}): CreateOrUpdate.Calendar.cshtml's success - // callback for this action never reads the response body (no else branch) on any of the three - // hosts, so a 200 response would silently refresh the grid as if the clear succeeded. - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - await Assert.ThrowsExactlyAsync(() => _controller.ClearCalendar("p1")); - - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productReservationServiceMock.Verify( - s => s.DeleteProductReservation(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ClearCalendar_ScopeGrantsAccess_DeletesAllReservations() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var reservation = NewReservation("r1", "p1"); - _productReservationServiceMock - .Setup(s => s.GetProductReservationsByProductId("p1", true, null)) - .ReturnsAsync(new PagedList(new List { reservation }, 0, int.MaxValue)); - - var result = await _controller.ClearCalendar("p1"); - - Assert.IsInstanceOfType(result); - _productReservationServiceMock.Verify(s => s.DeleteProductReservation(reservation), Times.Once); - } - - // --- ClearOld --------------------------------------------------------------------------------------- - - [TestMethod] - public async Task ClearOld_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing")).ReturnsAsync((Product)null); - - await Assert.ThrowsExactlyAsync(() => _controller.ClearOld("missing")); - } - - [TestMethod] - public async Task ClearOld_ScopeDeniesAccess_Throws_DoesNotDelete() - { - // See the comment on ClearCalendar_ScopeDeniesAccess_Throws_DoesNotDelete above - same view-layer - // gap applies to this action's response handling. - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - await Assert.ThrowsExactlyAsync(() => _controller.ClearOld("p1")); - - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productReservationServiceMock.Verify( - s => s.DeleteProductReservation(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ClearOld_ScopeGrantsAccess_DeletesOnlyPastReservations() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var oldReservation = new ProductReservation { Id = "old", ProductId = "p1", Date = DateTime.UtcNow.AddDays(-1) }; - var futureReservation = new ProductReservation { Id = "future", ProductId = "p1", Date = DateTime.UtcNow.AddDays(1) }; - _productReservationServiceMock - .Setup(s => s.GetProductReservationsByProductId("p1", true, null)) - .ReturnsAsync(new PagedList( - new List { oldReservation, futureReservation }, 0, int.MaxValue)); - - var result = await _controller.ClearOld("p1"); - - Assert.IsInstanceOfType(result); - _productReservationServiceMock.Verify(s => s.DeleteProductReservation(oldReservation), Times.Once); - _productReservationServiceMock.Verify(s => s.DeleteProductReservation(futureReservation), Times.Never); - } - - // --- ProductReservationDelete ------------------------------------------------------------------------ - - [TestMethod] - public async Task ProductReservationDelete_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing")).ReturnsAsync((Product)null); - var model = new ProductModel.ReservationModel { ProductId = "missing", ReservationId = "r1" }; - - await Assert.ThrowsExactlyAsync(() => _controller.ProductReservationDelete(model)); - } - - [TestMethod] - public async Task ProductReservationDelete_ScopeDeniesAccess_ReturnsKendoGridError_DoesNotDelete() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.ReservationModel { ProductId = "p1", ReservationId = "r1" }; - - var result = await _controller.ProductReservationDelete(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productReservationServiceMock.Verify( - s => s.DeleteProductReservation(It.IsAny()), Times.Never); - } - - // Regression guard for a real cross-product IDOR found in Store's and Vendor's original code: the - // access check ran against model.ProductId while the delete ran against model.ReservationId, with no - // check that the two referred to the same product. A caller with access to *any* product could pass - // that product's id for the access check while supplying the ReservationId of a reservation belonging - // to a different, unowned product, deleting it. - [TestMethod] - public async Task ProductReservationDelete_ReservationBelongsToDifferentProduct_ReturnsKendoGridError_DoesNotDelete() - { - var ownedProduct = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(ownedProduct); - _scopeMock.Setup(s => s.HasAccess(ownedProduct)).ReturnsAsync(true); - var foreignReservation = NewReservation("r1", "p2"); - _productReservationServiceMock.Setup(s => s.GetProductReservation("r1")).ReturnsAsync(foreignReservation); - var model = new ProductModel.ReservationModel { ProductId = "p1", ReservationId = "r1" }; - - var result = await _controller.ProductReservationDelete(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productReservationServiceMock.Verify( - s => s.DeleteProductReservation(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductReservationDelete_ReservationHasOrder_ReturnsErrorsJson_DoesNotDelete() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var reservation = NewReservation("r1", "p1", "order1"); - _productReservationServiceMock.Setup(s => s.GetProductReservation("r1")).ReturnsAsync(reservation); - var model = new ProductModel.ReservationModel { ProductId = "p1", ReservationId = "r1" }; - - var result = await _controller.ProductReservationDelete(model); - - var json = result as JsonResult; - Assert.IsNotNull(json); - var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.IsNotNull(gridModel.Errors); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.ProductReservations.CantDeleteWithOrder"), Times.Once); - _productReservationServiceMock.Verify( - s => s.DeleteProductReservation(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductReservationDelete_ScopeGrantsAccess_SameProduct_Deletes() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var reservation = NewReservation("r1", "p1"); - _productReservationServiceMock.Setup(s => s.GetProductReservation("r1")).ReturnsAsync(reservation); - var model = new ProductModel.ReservationModel { ProductId = "p1", ReservationId = "r1" }; - - var result = await _controller.ProductReservationDelete(model); - - Assert.IsInstanceOfType(result); - _productReservationServiceMock.Verify(s => s.DeleteProductReservation(reservation), Times.Once); - } - - [TestMethod] - public async Task ProductReservationDelete_ReservationNotFound_ReturnsEmptyJson_NoOp() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _productReservationServiceMock.Setup(s => s.GetProductReservation("missing")).ReturnsAsync((ProductReservation)null); - var model = new ProductModel.ReservationModel { ProductId = "p1", ReservationId = "missing" }; - - var result = await _controller.ProductReservationDelete(model); - - Assert.IsInstanceOfType(result); - _productReservationServiceMock.Verify( - s => s.DeleteProductReservation(It.IsAny()), Times.Never); - } } diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductReservationControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductReservationControllerTests.cs new file mode 100644 index 000000000..ee5d6ab24 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductReservationControllerTests.cs @@ -0,0 +1,377 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Linq.Expressions; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class BaseProductReservationControllerTests : ProductControllerTestsBase +{ + // BaseProductReservationController is abstract. This minimal subclass exists only so the actions under test + // can be invoked directly. + public class TestProductReservationController( + IProductService productService, + IRequestTranslationService translationService, + IProductReservationService productReservationService, + IEnumTranslationService enumTranslationService, + IAdminDataScope scope) + : BaseProductReservationController(productService, translationService, productReservationService, enumTranslationService, scope); + + protected override TestProductReservationController CreateController() => new( + _productServiceMock.Object, + _translationServiceMock.Object, + _productReservationServiceMock.Object, + _enumTranslationServiceMock.Object, + _scopeMock.Object); + + private static ProductReservation NewReservation(string id, string productId, string orderId = "") => + new() { Id = id, ProductId = productId, OrderId = orderId, Date = DateTime.UtcNow }; + + // --- ListReservations ---------------------------------------------------------------------------- + + [TestMethod] + public async Task ListReservations_ScopeDeniesAccess_ReturnsKendoGridError_DoesNotQuery() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + + var result = await _controller.ListReservations( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productReservationServiceMock.Verify( + s => s.GetProductReservationsByProductId(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ListReservations_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var reservations = new PagedList( + new List { NewReservation("r1", "p1") }, 0, 10); + _productReservationServiceMock + .Setup(s => s.GetProductReservationsByProductId("p1", null, null, 0, 10)) + .ReturnsAsync(reservations); + + var result = await _controller.ListReservations( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 1, PageSize = 10 }, "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // A product shared with other stores opens read-only on Store: the tab loads, but the + // reservations (other stores' orders) are not listed. + [TestMethod] + public async Task ListReservations_ViewableButNotOwned_ReturnsEmptyGrid_DoesNotQuery() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ListReservations( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 1, PageSize = 10 }, "p1"); + + var gridModel = (result as JsonResult)?.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(0, gridModel.Total); + _translationServiceMock.Verify(t => t.GetResource(It.IsAny()), Times.Never); + _productReservationServiceMock.Verify( + s => s.GetProductReservationsByProductId(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + // --- GenerateCalendar ------------------------------------------------------------------------------ + + private static ProductModel.GenerateCalendarModel ValidCalendarModel(IntervalUnit unit = IntervalUnit.Day) => new() { + StartDate = new DateTime(2026, 1, 1), + EndDate = new DateTime(2026, 1, 1), + StartTime = new DateTime(2026, 1, 1, 8, 0, 0), + EndTime = new DateTime(2026, 1, 1, 18, 0, 0), + Interval = 1, + IntervalUnit = (int)unit, + Quantity = 1, + Resource = "room1", + // 2026-01-01 is a Thursday. + Thursday = true + }; + + [TestMethod] + public async Task GenerateCalendar_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing")).ReturnsAsync((Product)null); + + await Assert.ThrowsExactlyAsync( + () => _controller.GenerateCalendar("missing", ValidCalendarModel())); + } + + [TestMethod] + public async Task GenerateCalendar_ScopeDeniesAccess_ReturnsErrorsJson_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.GenerateCalendar("p1", ValidCalendarModel()); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productReservationServiceMock.Verify( + s => s.InsertProductReservation(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task GenerateCalendar_InvalidModelState_ReturnsErrorsJson_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productReservationServiceMock + .Setup(s => s.GetProductReservationsByProductId("p1", null, null)) + .ReturnsAsync(new PagedList(new List(), 0, int.MaxValue)); + _controller.ModelState.AddModelError("Resource", "Required"); + + var result = await _controller.GenerateCalendar("p1", ValidCalendarModel()); + + Assert.IsInstanceOfType(result); + _productReservationServiceMock.Verify( + s => s.InsertProductReservation(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task GenerateCalendar_ScopeGrantsAccess_Valid_InsertsReservationAndUpdatesProductFields() + { + var product = new Product { Id = "p1", IntervalUnitId = IntervalUnit.Day }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productReservationServiceMock + .Setup(s => s.GetProductReservationsByProductId("p1", null, null)) + .ReturnsAsync(new PagedList(new List(), 0, int.MaxValue)); + + var result = await _controller.GenerateCalendar("p1", ValidCalendarModel()); + + Assert.IsInstanceOfType(result); + _productReservationServiceMock.Verify( + s => s.InsertProductReservation(It.Is(r => + r.ProductId == "p1" && r.Resource == "room1")), Times.Once); + _productServiceMock.Verify( + s => s.UpdateProductField(product, It.IsAny>>(), 1), Times.Once); + } + + // --- ClearCalendar ---------------------------------------------------------------------------------- + + [TestMethod] + public async Task ClearCalendar_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing")).ReturnsAsync((Product)null); + + await Assert.ThrowsExactlyAsync(() => _controller.ClearCalendar("missing")); + } + + [TestMethod] + public async Task ClearCalendar_ScopeDeniesAccess_Throws_DoesNotDelete() + { + // Throws rather than returning Json({errors}): CreateOrUpdate.Calendar.cshtml's success + // callback for this action never reads the response body (no else branch) on any of the three + // hosts, so a 200 response would silently refresh the grid as if the clear succeeded. + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + await Assert.ThrowsExactlyAsync(() => _controller.ClearCalendar("p1")); + + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productReservationServiceMock.Verify( + s => s.DeleteProductReservation(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ClearCalendar_ScopeGrantsAccess_DeletesAllReservations() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var reservation = NewReservation("r1", "p1"); + _productReservationServiceMock + .Setup(s => s.GetProductReservationsByProductId("p1", true, null)) + .ReturnsAsync(new PagedList(new List { reservation }, 0, int.MaxValue)); + + var result = await _controller.ClearCalendar("p1"); + + Assert.IsInstanceOfType(result); + _productReservationServiceMock.Verify(s => s.DeleteProductReservation(reservation), Times.Once); + } + + // --- ClearOld --------------------------------------------------------------------------------------- + + [TestMethod] + public async Task ClearOld_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing")).ReturnsAsync((Product)null); + + await Assert.ThrowsExactlyAsync(() => _controller.ClearOld("missing")); + } + + [TestMethod] + public async Task ClearOld_ScopeDeniesAccess_Throws_DoesNotDelete() + { + // See the comment on ClearCalendar_ScopeDeniesAccess_Throws_DoesNotDelete above - same view-layer + // gap applies to this action's response handling. + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + await Assert.ThrowsExactlyAsync(() => _controller.ClearOld("p1")); + + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productReservationServiceMock.Verify( + s => s.DeleteProductReservation(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ClearOld_ScopeGrantsAccess_DeletesOnlyPastReservations() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var oldReservation = new ProductReservation { Id = "old", ProductId = "p1", Date = DateTime.UtcNow.AddDays(-1) }; + var futureReservation = new ProductReservation { Id = "future", ProductId = "p1", Date = DateTime.UtcNow.AddDays(1) }; + _productReservationServiceMock + .Setup(s => s.GetProductReservationsByProductId("p1", true, null)) + .ReturnsAsync(new PagedList( + new List { oldReservation, futureReservation }, 0, int.MaxValue)); + + var result = await _controller.ClearOld("p1"); + + Assert.IsInstanceOfType(result); + _productReservationServiceMock.Verify(s => s.DeleteProductReservation(oldReservation), Times.Once); + _productReservationServiceMock.Verify(s => s.DeleteProductReservation(futureReservation), Times.Never); + } + + // --- ProductReservationDelete ------------------------------------------------------------------------ + + [TestMethod] + public async Task ProductReservationDelete_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing")).ReturnsAsync((Product)null); + var model = new ProductModel.ReservationModel { ProductId = "missing", ReservationId = "r1" }; + + await Assert.ThrowsExactlyAsync(() => _controller.ProductReservationDelete(model)); + } + + [TestMethod] + public async Task ProductReservationDelete_ScopeDeniesAccess_ReturnsKendoGridError_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ReservationModel { ProductId = "p1", ReservationId = "r1" }; + + var result = await _controller.ProductReservationDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productReservationServiceMock.Verify( + s => s.DeleteProductReservation(It.IsAny()), Times.Never); + } + + // Regression guard for a real cross-product IDOR found in Store's and Vendor's original code: the + // access check ran against model.ProductId while the delete ran against model.ReservationId, with no + // check that the two referred to the same product. A caller with access to *any* product could pass + // that product's id for the access check while supplying the ReservationId of a reservation belonging + // to a different, unowned product, deleting it. + [TestMethod] + public async Task ProductReservationDelete_ReservationBelongsToDifferentProduct_ReturnsKendoGridError_DoesNotDelete() + { + var ownedProduct = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(ownedProduct); + _scopeMock.Setup(s => s.HasAccess(ownedProduct)).ReturnsAsync(true); + var foreignReservation = NewReservation("r1", "p2"); + _productReservationServiceMock.Setup(s => s.GetProductReservation("r1")).ReturnsAsync(foreignReservation); + var model = new ProductModel.ReservationModel { ProductId = "p1", ReservationId = "r1" }; + + var result = await _controller.ProductReservationDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productReservationServiceMock.Verify( + s => s.DeleteProductReservation(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductReservationDelete_ReservationHasOrder_ReturnsErrorsJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var reservation = NewReservation("r1", "p1", "order1"); + _productReservationServiceMock.Setup(s => s.GetProductReservation("r1")).ReturnsAsync(reservation); + var model = new ProductModel.ReservationModel { ProductId = "p1", ReservationId = "r1" }; + + var result = await _controller.ProductReservationDelete(model); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.IsNotNull(gridModel.Errors); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.ProductReservations.CantDeleteWithOrder"), Times.Once); + _productReservationServiceMock.Verify( + s => s.DeleteProductReservation(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductReservationDelete_ScopeGrantsAccess_SameProduct_Deletes() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var reservation = NewReservation("r1", "p1"); + _productReservationServiceMock.Setup(s => s.GetProductReservation("r1")).ReturnsAsync(reservation); + var model = new ProductModel.ReservationModel { ProductId = "p1", ReservationId = "r1" }; + + var result = await _controller.ProductReservationDelete(model); + + Assert.IsInstanceOfType(result); + _productReservationServiceMock.Verify(s => s.DeleteProductReservation(reservation), Times.Once); + } + + [TestMethod] + public async Task ProductReservationDelete_ReservationNotFound_ReturnsEmptyJson_NoOp() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productReservationServiceMock.Setup(s => s.GetProductReservation("missing")).ReturnsAsync((ProductReservation)null); + var model = new ProductModel.ReservationModel { ProductId = "p1", ReservationId = "missing" }; + + var result = await _controller.ProductReservationDelete(model); + + Assert.IsInstanceOfType(result); + _productReservationServiceMock.Verify( + s => s.DeleteProductReservation(It.IsAny()), Times.Never); + } +} diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductReservationController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductReservationController.cs new file mode 100644 index 000000000..0b178a096 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Controllers/ProductReservationController.cs @@ -0,0 +1,22 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.Admin.Extensions; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Admin.Controllers; + +[AuthorizeAdmin] +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaAdmin)] +[AuthorizeMenu] +public class ProductReservationController( + IProductService productService, + IRequestTranslationService translationService, + IProductReservationService productReservationService, + IEnumTranslationService enumTranslationService, + IAdminDataScope scope) + : BaseProductReservationController(productService, translationService, productReservationService, enumTranslationService, scope); diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index 45f7acf94..55de8c9d4 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -3069,256 +3069,4 @@ await productViewModelService.DeleteProductAttributeCombinationTierPrices(produc } #endregion - - #region Reservation - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ListReservations(DataSourceRequest command, string productId) - { - var product = await productService.GetProductById(productId); - - // CanView on this read: a product shared with other stores opens read-only on Store, so its - // tabs must load; the mutating actions below keep the strict HasAccess. History: - // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct checks; - // Admin's original had no check at all - normalized to scope.HasAccess for all three hosts. - if (!await scope.CanView(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - //a product shared with other stores is shown read-only; its bids and reservations carry - //other stores' customers and orders, so they are listed only for a product this store owns - if (!await scope.HasAccess(product)) - return Json(new DataSourceResult { Data = Array.Empty(), Total = 0 }); - - var reservations = - await productReservationService.GetProductReservationsByProductId(productId, null, null, - command.Page - 1, command.PageSize); - var reservationModel = reservations - .Select(x => new ProductModel.ReservationModel { - ReservationId = x.Id, - Date = x.Date, - OrderId = x.OrderId, - ProductId = x.ProductId, - Parameter = x.Parameter, - Resource = x.Resource, - Duration = x.Duration - }).ToList(); - - var gridModel = new DataSourceResult { - Data = reservationModel, - Total = reservations.TotalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task GenerateCalendar(string productId, ProductModel.GenerateCalendarModel model) - { - var product = await productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - // HasAccess (strict), returning the same {errors:...} JSON shape as Store's CanAccessProduct - // check (the client's ajax success handler reads data.errors on denial). Vendor's original threw - // ArgumentException for the combined null-or-access-denied case instead, which the client's - // Kendo/ajax error callback only surfaces as a generic "Error" alert - returning the JSON errors - // message here is strictly more informative and closes Admin's original gap (no check at all). - if (!await scope.HasAccess(product)) - return Json(new { errors = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions") }); - - var reservations = await productReservationService.GetProductReservationsByProductId(productId, null, null); - if (reservations.Any()) - if (((product.IntervalUnitId == IntervalUnit.Minute || product.IntervalUnitId == IntervalUnit.Hour) && - (IntervalUnit)model.Interval == IntervalUnit.Day) || - (product.IntervalUnitId == IntervalUnit.Day && - ((IntervalUnit)model.IntervalUnit == IntervalUnit.Minute || - (IntervalUnit)model.IntervalUnit == IntervalUnit.Hour))) - return Json(new { - errors = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Calendar.CannotChangeInterval") - }); - - if (!ModelState.IsValid) - { - var error = (Dictionary>)ModelState.SerializeErrors(); - var s = ""; - foreach (var error1 in error) - foreach (var error2 in error1.Value) - { - var v = (string[])error2.Value; - s += v[0] + "\n"; - } - - return Json(new { errors = s }); - } - - //update fields on product - await productService.UpdateProductField(product, x => x.Interval, model.Interval); - await productService.UpdateProductField(product, x => x.IntervalUnitId, (IntervalUnit)model.IntervalUnit); - await productService.UpdateProductField(product, x => x.IncBothDate, model.IncBothDate); - - var minutesToAdd = (IntervalUnit)model.IntervalUnit switch { - IntervalUnit.Minute => model.Interval, - IntervalUnit.Hour => model.Interval * 60, - IntervalUnit.Day => model.Interval * 60 * 24, - _ => 0 - }; - - var _hourFrom = model.StartTime.Hour; - var _minutesFrom = model.StartTime.Minute; - var _hourTo = model.EndTime.Hour; - var _minutesTo = model.EndTime.Minute; - var _dateFrom = new DateTime(model.StartDate.Value.Year, model.StartDate.Value.Month, model.StartDate.Value.Day, - 0, 0, 0, 0); - var _dateTo = new DateTime(model.EndDate.Value.Year, model.EndDate.Value.Month, model.EndDate.Value.Day, 23, 59, - 59, 999); - if ((IntervalUnit)model.IntervalUnit == IntervalUnit.Day) - { - model.Quantity = 1; - model.Parameter = ""; - } - else - { - model.Resource = ""; - } - - var dates = new List(); - var counter = 0; - for (var iterator = _dateFrom; iterator <= _dateTo; iterator += new TimeSpan(0, minutesToAdd, 0)) - { - if ((IntervalUnit)model.IntervalUnit != IntervalUnit.Day) - { - if (iterator.Hour >= _hourFrom && iterator.Hour <= _hourTo) - { - if (iterator.Hour == _hourTo) - if (iterator.Minute > _minutesTo) - continue; - if (iterator.Hour == _hourFrom) - if (iterator.Minute < _minutesFrom) - continue; - } - else - { - continue; - } - } - - if ((iterator.DayOfWeek == DayOfWeek.Monday && !model.Monday) || - (iterator.DayOfWeek == DayOfWeek.Tuesday && !model.Tuesday) || - (iterator.DayOfWeek == DayOfWeek.Wednesday && !model.Wednesday) || - (iterator.DayOfWeek == DayOfWeek.Thursday && !model.Thursday) || - (iterator.DayOfWeek == DayOfWeek.Friday && !model.Friday) || - (iterator.DayOfWeek == DayOfWeek.Saturday && !model.Saturday) || - (iterator.DayOfWeek == DayOfWeek.Sunday && !model.Sunday)) - continue; - - for (var i = 0; i < model.Quantity.MaxQuantity(); i++) - { - dates.Add(iterator); - try - { - var insert = true; - if ((IntervalUnit)model.IntervalUnit == IntervalUnit.Day) - if (reservations.Any(x => x.Resource == model.Resource && x.Date == iterator)) - insert = false; - if (insert) - { - if (counter++ > 1000) - break; - - await productReservationService.InsertProductReservation(new ProductReservation { - OrderId = "", - Date = iterator, - ProductId = productId, - Resource = model.Resource, - Parameter = model.Parameter, - Duration = model.Interval + " " + enumTranslationService.GetTranslationEnum((IntervalUnit)model.IntervalUnit) - }); - } - } - catch { } - } - } - - return Json(new { success = true }); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ClearCalendar(string productId) - { - var product = await productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - // Throw, not Json({errors}): unlike GenerateCalendar, this view's success callback never reads - // the response body at all (see CreateOrUpdate.Calendar.cshtml on all three hosts - identical, - // no else branch) - a 200 here would silently refresh the grid as if the clear had succeeded. A - // thrown exception at least surfaces a generic error via the ajax error callback. The view not - // reading a denial message is a pre-existing gap, out of scope for this migration. - if (!await scope.HasAccess(product)) - throw new ArgumentException(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var toDelete = await productReservationService.GetProductReservationsByProductId(productId, true, null); - foreach (var record in toDelete) await productReservationService.DeleteProductReservation(record); - - return Json(""); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ClearOld(string productId) - { - var product = await productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - // Throw, not Json({errors}): see the comment on ClearCalendar above - this view's success - // callback doesn't read the response body either. - if (!await scope.HasAccess(product)) - throw new ArgumentException(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var toDelete = - (await productReservationService.GetProductReservationsByProductId(productId, true, null)).Where(x => - x.Date < DateTime.UtcNow); - foreach (var record in toDelete) await productReservationService.DeleteProductReservation(record); - - return Json(""); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductReservationDelete(ProductModel.ReservationModel model) - { - var product = await productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var toDelete = await productReservationService.GetProductReservation(model.ReservationId); - - // Cross-product IDOR closed here: none of the three original hosts verified that the reservation - // being deleted (looked up purely by model.ReservationId) actually belongs to the product just - // access-checked (model.ProductId). Both are independent, attacker-supplied POST fields - Store - // and Vendor's original code let a caller who owns/has-access-to *any* product pass the access - // check with that product's id while supplying a ReservationId belonging to a different, unowned - // product, deleting a reservation on a product they have no access to. - if (toDelete != null && toDelete.ProductId != product.Id) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (toDelete != null) - { - if (string.IsNullOrEmpty(toDelete.OrderId)) - await productReservationService.DeleteProductReservation(toDelete); - else - return Json(new DataSourceResult { - Errors = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.ProductReservations.CantDeleteWithOrder") - }); - } - - return Json(""); - } - - #endregion } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductReservationController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductReservationController.cs new file mode 100644 index 000000000..28b36b856 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductReservationController.cs @@ -0,0 +1,280 @@ +using Grand.Business.Core.Extensions; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Domain.Permissions; +using Grand.SharedKernel.Extensions; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Extensions; +using Grand.Web.Common.Helpers; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.AdminShared.Controllers; + +[PermissionAuthorize(PermissionSystemName.Products)] +[AutoValidateAntiforgeryToken] +public abstract class BaseProductReservationController( + IProductService productService, + IRequestTranslationService translationService, + IProductReservationService productReservationService, + IEnumTranslationService enumTranslationService, + IAdminDataScope scope) + : BaseController +{ + #region Reservation + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ListReservations(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // CanView on this read: a product shared with other stores opens read-only on Store, so its + // tabs must load; the mutating actions below keep the strict HasAccess. History: + // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct checks; + // Admin's original had no check at all - normalized to scope.HasAccess for all three hosts. + if (!await scope.CanView(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + //a product shared with other stores is shown read-only; its bids and reservations carry + //other stores' customers and orders, so they are listed only for a product this store owns + if (!await scope.HasAccess(product)) + return Json(new DataSourceResult { Data = Array.Empty(), Total = 0 }); + + var reservations = + await productReservationService.GetProductReservationsByProductId(productId, null, null, + command.Page - 1, command.PageSize); + var reservationModel = reservations + .Select(x => new ProductModel.ReservationModel { + ReservationId = x.Id, + Date = x.Date, + OrderId = x.OrderId, + ProductId = x.ProductId, + Parameter = x.Parameter, + Resource = x.Resource, + Duration = x.Duration + }).ToList(); + + var gridModel = new DataSourceResult { + Data = reservationModel, + Total = reservations.TotalCount + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task GenerateCalendar(string productId, ProductModel.GenerateCalendarModel model) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // HasAccess (strict), returning the same {errors:...} JSON shape as Store's CanAccessProduct + // check (the client's ajax success handler reads data.errors on denial). Vendor's original threw + // ArgumentException for the combined null-or-access-denied case instead, which the client's + // Kendo/ajax error callback only surfaces as a generic "Error" alert - returning the JSON errors + // message here is strictly more informative and closes Admin's original gap (no check at all). + if (!await scope.HasAccess(product)) + return Json(new { errors = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions") }); + + var reservations = await productReservationService.GetProductReservationsByProductId(productId, null, null); + if (reservations.Any()) + if (((product.IntervalUnitId == IntervalUnit.Minute || product.IntervalUnitId == IntervalUnit.Hour) && + (IntervalUnit)model.Interval == IntervalUnit.Day) || + (product.IntervalUnitId == IntervalUnit.Day && + ((IntervalUnit)model.IntervalUnit == IntervalUnit.Minute || + (IntervalUnit)model.IntervalUnit == IntervalUnit.Hour))) + return Json(new { + errors = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Calendar.CannotChangeInterval") + }); + + if (!ModelState.IsValid) + { + var error = (Dictionary>)ModelState.SerializeErrors(); + var s = ""; + foreach (var error1 in error) + foreach (var error2 in error1.Value) + { + var v = (string[])error2.Value; + s += v[0] + "\n"; + } + + return Json(new { errors = s }); + } + + //update fields on product + await productService.UpdateProductField(product, x => x.Interval, model.Interval); + await productService.UpdateProductField(product, x => x.IntervalUnitId, (IntervalUnit)model.IntervalUnit); + await productService.UpdateProductField(product, x => x.IncBothDate, model.IncBothDate); + + var minutesToAdd = (IntervalUnit)model.IntervalUnit switch { + IntervalUnit.Minute => model.Interval, + IntervalUnit.Hour => model.Interval * 60, + IntervalUnit.Day => model.Interval * 60 * 24, + _ => 0 + }; + + var _hourFrom = model.StartTime.Hour; + var _minutesFrom = model.StartTime.Minute; + var _hourTo = model.EndTime.Hour; + var _minutesTo = model.EndTime.Minute; + var _dateFrom = new DateTime(model.StartDate.Value.Year, model.StartDate.Value.Month, model.StartDate.Value.Day, + 0, 0, 0, 0); + var _dateTo = new DateTime(model.EndDate.Value.Year, model.EndDate.Value.Month, model.EndDate.Value.Day, 23, 59, + 59, 999); + if ((IntervalUnit)model.IntervalUnit == IntervalUnit.Day) + { + model.Quantity = 1; + model.Parameter = ""; + } + else + { + model.Resource = ""; + } + + var dates = new List(); + var counter = 0; + for (var iterator = _dateFrom; iterator <= _dateTo; iterator += new TimeSpan(0, minutesToAdd, 0)) + { + if ((IntervalUnit)model.IntervalUnit != IntervalUnit.Day) + { + if (iterator.Hour >= _hourFrom && iterator.Hour <= _hourTo) + { + if (iterator.Hour == _hourTo) + if (iterator.Minute > _minutesTo) + continue; + if (iterator.Hour == _hourFrom) + if (iterator.Minute < _minutesFrom) + continue; + } + else + { + continue; + } + } + + if ((iterator.DayOfWeek == DayOfWeek.Monday && !model.Monday) || + (iterator.DayOfWeek == DayOfWeek.Tuesday && !model.Tuesday) || + (iterator.DayOfWeek == DayOfWeek.Wednesday && !model.Wednesday) || + (iterator.DayOfWeek == DayOfWeek.Thursday && !model.Thursday) || + (iterator.DayOfWeek == DayOfWeek.Friday && !model.Friday) || + (iterator.DayOfWeek == DayOfWeek.Saturday && !model.Saturday) || + (iterator.DayOfWeek == DayOfWeek.Sunday && !model.Sunday)) + continue; + + for (var i = 0; i < model.Quantity.MaxQuantity(); i++) + { + dates.Add(iterator); + try + { + var insert = true; + if ((IntervalUnit)model.IntervalUnit == IntervalUnit.Day) + if (reservations.Any(x => x.Resource == model.Resource && x.Date == iterator)) + insert = false; + if (insert) + { + if (counter++ > 1000) + break; + + await productReservationService.InsertProductReservation(new ProductReservation { + OrderId = "", + Date = iterator, + ProductId = productId, + Resource = model.Resource, + Parameter = model.Parameter, + Duration = model.Interval + " " + enumTranslationService.GetTranslationEnum((IntervalUnit)model.IntervalUnit) + }); + } + } + catch { } + } + } + + return Json(new { success = true }); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ClearCalendar(string productId) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // Throw, not Json({errors}): unlike GenerateCalendar, this view's success callback never reads + // the response body at all (see CreateOrUpdate.Calendar.cshtml on all three hosts - identical, + // no else branch) - a 200 here would silently refresh the grid as if the clear had succeeded. A + // thrown exception at least surfaces a generic error via the ajax error callback. The view not + // reading a denial message is a pre-existing gap, out of scope for this migration. + if (!await scope.HasAccess(product)) + throw new ArgumentException(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var toDelete = await productReservationService.GetProductReservationsByProductId(productId, true, null); + foreach (var record in toDelete) await productReservationService.DeleteProductReservation(record); + + return Json(""); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ClearOld(string productId) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // Throw, not Json({errors}): see the comment on ClearCalendar above - this view's success + // callback doesn't read the response body either. + if (!await scope.HasAccess(product)) + throw new ArgumentException(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var toDelete = + (await productReservationService.GetProductReservationsByProductId(productId, true, null)).Where(x => + x.Date < DateTime.UtcNow); + foreach (var record in toDelete) await productReservationService.DeleteProductReservation(record); + + return Json(""); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductReservationDelete(ProductModel.ReservationModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var toDelete = await productReservationService.GetProductReservation(model.ReservationId); + + // Cross-product IDOR closed here: none of the three original hosts verified that the reservation + // being deleted (looked up purely by model.ReservationId) actually belongs to the product just + // access-checked (model.ProductId). Both are independent, attacker-supplied POST fields - Store + // and Vendor's original code let a caller who owns/has-access-to *any* product pass the access + // check with that product's id while supplying a ReservationId belonging to a different, unowned + // product, deleting a reservation on a product they have no access to. + if (toDelete != null && toDelete.ProductId != product.Id) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (toDelete != null) + { + if (string.IsNullOrEmpty(toDelete.OrderId)) + await productReservationService.DeleteProductReservation(toDelete); + else + return Json(new DataSourceResult { + Errors = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.ProductReservations.CantDeleteWithOrder") + }); + } + + return Json(""); + } + + #endregion +} diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Calendar.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Calendar.cshtml index 2666b46b2..b2cbccbc4 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Calendar.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Calendar.cshtml @@ -72,8 +72,8 @@ } @@ -174,7 +174,7 @@ $.ajax({ cache: false, type: "POST", - url: "@(Url.Action("GenerateCalendar", "Product", new { area = area }))", + url: "@(Url.Action("GenerateCalendar", "ProductReservation", new { area = area }))", dataType: 'json', data: postData, success: function (data) { @@ -207,7 +207,7 @@ $.ajax({ cache: false, type: "POST", - url: "@(Url.Action("ClearCalendar", "Product", new { area = area }))", + url: "@(Url.Action("ClearCalendar", "ProductReservation", new { area = area }))", dataType: 'json', data: postData, success: function (data) { @@ -231,7 +231,7 @@ $.ajax({ cache: false, type: "POST", - url: "@(Url.Action("ClearOld", "Product", new { area = area }))", + url: "@(Url.Action("ClearOld", "ProductReservation", new { area = area }))", dataType: 'json', data: postData, success: function (data) { diff --git a/src/Web/Grand.Web.Store/Controllers/ProductReservationController.cs b/src/Web/Grand.Web.Store/Controllers/ProductReservationController.cs new file mode 100644 index 000000000..ffa41f747 --- /dev/null +++ b/src/Web/Grand.Web.Store/Controllers/ProductReservationController.cs @@ -0,0 +1,22 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Store.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Store.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaStore)] +[AuthorizeStore] +[AuthorizeMenu] +public class ProductReservationController( + IProductService productService, + IRequestTranslationService translationService, + IProductReservationService productReservationService, + IEnumTranslationService enumTranslationService, + IAdminDataScope scope) + : BaseProductReservationController(productService, translationService, productReservationService, enumTranslationService, scope); diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductReservationController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductReservationController.cs new file mode 100644 index 000000000..509ed4d1f --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductReservationController.cs @@ -0,0 +1,22 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Vendor.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Vendor.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaVendor)] +[AuthorizeVendor] +[AuthorizeMenu] +public class ProductReservationController( + IProductService productService, + IRequestTranslationService translationService, + IProductReservationService productReservationService, + IEnumTranslationService enumTranslationService, + IAdminDataScope scope) + : BaseProductReservationController(productService, translationService, productReservationService, enumTranslationService, scope); From 3e570febcd4945d83e3dec3753b200c48cc1c640 Mon Sep 17 00:00:00 2001 From: support Date: Thu, 22 Oct 2026 06:25:22 +0200 Subject: [PATCH 04/27] Move the product pictures tab into its own controller --- .../Controllers/BaseProductControllerTests.cs | 212 --------------- .../BaseProductPictureControllerTests.cs | 255 ++++++++++++++++++ .../Controllers/ProductPictureController.cs | 25 ++ .../Controllers/BaseProductController.cs | 167 ------------ .../BaseProductPictureController.cs | 201 ++++++++++++++ .../Partials/CreateOrUpdate.Pictures.cshtml | 8 +- .../ProductPicturePopup.cshtml | 2 +- .../Controllers/ProductPictureController.cs | 25 ++ .../Controllers/ProductPictureController.cs | 25 ++ 9 files changed, 536 insertions(+), 384 deletions(-) create mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductPictureControllerTests.cs create mode 100644 src/Web/Grand.Web.Admin/Controllers/ProductPictureController.cs create mode 100644 src/Web/Grand.Web.AdminShared/Controllers/BaseProductPictureController.cs rename src/Web/Grand.Web.AdminShared/Views/AdminShared/{Product => ProductPicture}/ProductPicturePopup.cshtml (97%) create mode 100644 src/Web/Grand.Web.Store/Controllers/ProductPictureController.cs create mode 100644 src/Web/Grand.Web.Vendor/Controllers/ProductPictureController.cs diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index 6dba20655..ebdb7f746 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -2179,218 +2179,6 @@ public async Task AssociatedProductAddPopupPost_ScopeGrantsAccess_InvalidModel_R // a prior Pictures-permission check via IRequestPermissionService, disproportionate setup for what is // otherwise the same one-line HasAccess condition covered everywhere else in this region. - // --- ProductPictureList -------------------------------------------------------------------------- - - [TestMethod] - public async Task ProductPictureList_ScopeDeniesAccess_ReturnsErrorJson() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - - var result = await _controller.ProductPictureList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.PrepareProductPicturesModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductPictureList_ScopeGrantsAccess_ReturnsGrid() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - _productViewModelServiceMock.Setup(s => s.PrepareProductPicturesModel(product)) - .ReturnsAsync(new List { new() { Id = "pic1" } }); - - var result = await _controller.ProductPictureList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - var json = result as JsonResult; - Assert.IsNotNull(json); - var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.AreEqual(1, gridModel.Total); - } - - // --- ProductPicturePopup (GET) ------------------------------------------------------------------- - - [TestMethod] - public async Task ProductPicturePopupGet_ProductNotFound_ReturnsContent() - { - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync((Product)null); - - var result = await _controller.ProductPicturePopup("p1", "pic1"); - - var content = result as ContentResult; - Assert.IsNotNull(content); - Assert.AreEqual("Product not exist", content.Content); - _scopeMock.Verify(s => s.HasAccess(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductPicturePopupGet_ScopeDeniesAccess_ReturnsErrorJson() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - var result = await _controller.ProductPicturePopup("p1", "pic1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - } - - [TestMethod] - public async Task ProductPicturePopupGet_ScopeGrantsAccess_PictureNotFound_ReturnsContent() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - - var result = await _controller.ProductPicturePopup("p1", "pic1"); - - var content = result as ContentResult; - Assert.IsNotNull(content); - Assert.AreEqual("Product picture not exist", content.Content); - } - - [TestMethod] - public async Task ProductPicturePopupGet_ScopeGrantsAccess_PictureFound_ReturnsView() - { - var pp = new ProductPicture { Id = "pic1" }; - var product = new Product { Id = "p1" }; - product.ProductPictures.Add(pp); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductPictureModel { Id = "pic1" }; - _productViewModelServiceMock.Setup(s => s.PrepareProductPictureModel(product, pp)) - .ReturnsAsync((model, (Picture)null)); - - var result = await _controller.ProductPicturePopup("p1", "pic1"); - - var view = result as ViewResult; - Assert.IsNotNull(view); - Assert.AreSame(model, view.Model); - } - - // --- ProductPicturePopup (POST) ------------------------------------------------------------------ - - [TestMethod] - public async Task ProductPicturePopupPost_ProductNotFound_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync((Product)null); - var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; - - await Assert.ThrowsExactlyAsync(() => _controller.ProductPicturePopup(model)); - } - - [TestMethod] - public async Task ProductPicturePopupPost_ScopeDeniesAccess_Throws_DoesNotUpdate() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; - - // Regression guard: Vendor's original ProductPicturePopup(POST) had no access check at all, - // letting any vendor rename/re-alt-text another vendor's product picture by posting its - // productId/model.Id. - await Assert.ThrowsExactlyAsync(() => _controller.ProductPicturePopup(model)); - _productViewModelServiceMock.Verify(s => s.UpdateProductPicture(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductPicturePopupPost_ScopeGrantsAccess_PictureNotFound_Throws() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; - - await Assert.ThrowsExactlyAsync(() => _controller.ProductPicturePopup(model)); - } - - [TestMethod] - public async Task ProductPicturePopupPost_ScopeGrantsAccess_ValidModel_Updates() - { - var pp = new ProductPicture { Id = "pic1" }; - var product = new Product { Id = "p1" }; - product.ProductPictures.Add(pp); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; - - var result = await _controller.ProductPicturePopup(model); - - var content = result as ContentResult; - Assert.IsNotNull(content); - Assert.AreEqual("", content.Content); - _productViewModelServiceMock.Verify(s => s.UpdateProductPicture(model), Times.Once); - } - - [TestMethod] - public async Task ProductPicturePopupPost_InvalidModelState_ReturnsView() - { - var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; - _controller.ModelState.AddModelError("x", "error"); - - var result = await _controller.ProductPicturePopup(model); - - var view = result as ViewResult; - Assert.IsNotNull(view); - Assert.AreSame(model, view.Model); - _productServiceMock.Verify(p => p.GetProductById(It.IsAny(), It.IsAny()), Times.Never); - } - - // --- ProductPictureDelete ------------------------------------------------------------------------- - - [TestMethod] - public async Task ProductPictureDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; - - // Regression guard: Vendor's original ProductPictureDelete had no access check at all, letting - // any vendor delete another vendor's product picture by posting its productId/model.Id. - var result = await _controller.ProductPictureDelete(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.DeleteProductPicture(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductPictureDelete_ScopeGrantsAccess_ValidModel_Deletes() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; - - var result = await _controller.ProductPictureDelete(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.DeleteProductPicture(model), Times.Once); - } - - [TestMethod] - public async Task ProductPictureDelete_ScopeGrantsAccess_InvalidModelState_ReturnsKendoGridError_DoesNotDelete() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; - _controller.ModelState.AddModelError("x", "error"); - - var result = await _controller.ProductPictureDelete(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.DeleteProductPicture(It.IsAny()), Times.Never); - } - // --- Product specification attributes ----------------------------------------------------------- // GetOptionsByAttributeId is deliberately not covered here (same rationale as Product pictures' // ProductPictureAdd note): identical, unscoped across all three hosts, no access check involved. diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductPictureControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductPictureControllerTests.cs new file mode 100644 index 000000000..74a940ccb --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductPictureControllerTests.cs @@ -0,0 +1,255 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Domain.Catalog; +using Grand.Domain.Media; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Linq.Expressions; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class BaseProductPictureControllerTests : ProductControllerTestsBase +{ + // BaseProductPictureController is abstract. This minimal subclass exists only so the actions under test + // can be invoked directly. + public class TestProductPictureController( + IProductViewModelService productViewModelService, + IProductService productService, + ILanguageService languageService, + IRequestTranslationService translationService, + IRequestPermissionService permissionService, + IAdminDataScope scope) + : BaseProductPictureController(productViewModelService, productService, languageService, translationService, permissionService, scope); + + protected override TestProductPictureController CreateController() => new( + _productViewModelServiceMock.Object, + _productServiceMock.Object, + _languageServiceMock.Object, + _translationServiceMock.Object, + _permissionServiceMock.Object, + _scopeMock.Object); + + // --- ProductPictureList -------------------------------------------------------------------------- + + [TestMethod] + public async Task ProductPictureList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + + var result = await _controller.ProductPictureList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.PrepareProductPicturesModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductPictureList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductPicturesModel(product)) + .ReturnsAsync(new List { new() { Id = "pic1" } }); + + var result = await _controller.ProductPictureList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- ProductPicturePopup (GET) ------------------------------------------------------------------- + + [TestMethod] + public async Task ProductPicturePopupGet_ProductNotFound_ReturnsContent() + { + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync((Product)null); + + var result = await _controller.ProductPicturePopup("p1", "pic1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("Product not exist", content.Content); + _scopeMock.Verify(s => s.HasAccess(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductPicturePopupGet_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductPicturePopup("p1", "pic1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task ProductPicturePopupGet_ScopeGrantsAccess_PictureNotFound_ReturnsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.ProductPicturePopup("p1", "pic1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("Product picture not exist", content.Content); + } + + [TestMethod] + public async Task ProductPicturePopupGet_ScopeGrantsAccess_PictureFound_ReturnsView() + { + var pp = new ProductPicture { Id = "pic1" }; + var product = new Product { Id = "p1" }; + product.ProductPictures.Add(pp); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductPictureModel { Id = "pic1" }; + _productViewModelServiceMock.Setup(s => s.PrepareProductPictureModel(product, pp)) + .ReturnsAsync((model, (Picture)null)); + + var result = await _controller.ProductPicturePopup("p1", "pic1"); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreSame(model, view.Model); + } + + // --- ProductPicturePopup (POST) ------------------------------------------------------------------ + + [TestMethod] + public async Task ProductPicturePopupPost_ProductNotFound_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync((Product)null); + var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; + + await Assert.ThrowsExactlyAsync(() => _controller.ProductPicturePopup(model)); + } + + [TestMethod] + public async Task ProductPicturePopupPost_ScopeDeniesAccess_Throws_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; + + // Regression guard: Vendor's original ProductPicturePopup(POST) had no access check at all, + // letting any vendor rename/re-alt-text another vendor's product picture by posting its + // productId/model.Id. + await Assert.ThrowsExactlyAsync(() => _controller.ProductPicturePopup(model)); + _productViewModelServiceMock.Verify(s => s.UpdateProductPicture(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductPicturePopupPost_ScopeGrantsAccess_PictureNotFound_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; + + await Assert.ThrowsExactlyAsync(() => _controller.ProductPicturePopup(model)); + } + + [TestMethod] + public async Task ProductPicturePopupPost_ScopeGrantsAccess_ValidModel_Updates() + { + var pp = new ProductPicture { Id = "pic1" }; + var product = new Product { Id = "p1" }; + product.ProductPictures.Add(pp); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; + + var result = await _controller.ProductPicturePopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("", content.Content); + _productViewModelServiceMock.Verify(s => s.UpdateProductPicture(model), Times.Once); + } + + [TestMethod] + public async Task ProductPicturePopupPost_InvalidModelState_ReturnsView() + { + var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; + _controller.ModelState.AddModelError("x", "error"); + + var result = await _controller.ProductPicturePopup(model); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreSame(model, view.Model); + _productServiceMock.Verify(p => p.GetProductById(It.IsAny(), It.IsAny()), Times.Never); + } + + // --- ProductPictureDelete ------------------------------------------------------------------------- + + [TestMethod] + public async Task ProductPictureDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; + + // Regression guard: Vendor's original ProductPictureDelete had no access check at all, letting + // any vendor delete another vendor's product picture by posting its productId/model.Id. + var result = await _controller.ProductPictureDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.DeleteProductPicture(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductPictureDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; + + var result = await _controller.ProductPictureDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteProductPicture(model), Times.Once); + } + + [TestMethod] + public async Task ProductPictureDelete_ScopeGrantsAccess_InvalidModelState_ReturnsKendoGridError_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; + _controller.ModelState.AddModelError("x", "error"); + + var result = await _controller.ProductPictureDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteProductPicture(It.IsAny()), Times.Never); + } +} diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductPictureController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductPictureController.cs new file mode 100644 index 000000000..392d4d2bb --- /dev/null +++ b/src/Web/Grand.Web.Admin/Controllers/ProductPictureController.cs @@ -0,0 +1,25 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Domain.Catalog; +using Grand.Web.Admin.Extensions; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Admin.Controllers; + +[AuthorizeAdmin] +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaAdmin)] +[AuthorizeMenu] +public class ProductPictureController( + IProductViewModelService productViewModelService, + IProductService productService, + ILanguageService languageService, + IRequestTranslationService translationService, + IRequestPermissionService permissionService, + IAdminDataScope scope) + : BaseProductPictureController(productViewModelService, productService, languageService, translationService, permissionService, scope); diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index 55de8c9d4..8159ec92f 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -1383,173 +1383,6 @@ public async Task AssociatedProductAddPopup(ProductModel.AddAssoc #endregion - #region Product pictures - - [HttpPost] - public async Task ProductPictureAdd( - IFormFileCollection files, - Reference reference, string objectId, - [FromServices] IPictureService pictureService, - [FromServices] MediaSettings mediaSettings) - { - if (!await permissionService.Authorize(PermissionSystemName.Pictures)) - return Json(new { - success = false, - message = "Access denied - picture permissions" - }); - - if (reference != Reference.Product || string.IsNullOrEmpty(objectId)) - return Json(new { - success = false, - message = "Please save form before upload new pictures" - }); - - if (!files.Any()) - return Json(new { - success = false, - message = "No files uploaded" - }); - - var product = await productService.GetProductById(objectId); - - if (!await scope.HasAccess(product)) - return Json(new { - success = false, - message = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions") - }); - - var values = new List<(string pictureUrl, string pictureId)>(); - foreach (var file in files) - { - var fileName = Path.GetFileName(file.FileName); - var contentType = file.ContentType; - var fileExtension = Path.GetExtension(fileName); - if (string.IsNullOrEmpty(contentType)) - _ = new FileExtensionContentTypeProvider().TryGetContentType(fileName, out contentType); - - if (FileExtensions.GetAllowedMediaFileTypes(mediaSettings.AllowedFileTypes).IsAllowedMediaFileType(fileExtension)) - { - var fileBinary = file.GetDownloadBits(); - //insert picture - var picture = await pictureService.InsertPicture(fileBinary, contentType, null, reference: reference, - objectId: objectId); - var pictureUrl = await pictureService.GetPictureUrl(picture); - - values.Add((pictureUrl, picture.Id)); - //assign picture to the product - await productViewModelService.InsertProductPicture(product, picture, 0); - } - } - - return Json(new { success = values.Any(), data = values }); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductPictureList(DataSourceRequest command, string productId) - { - var product = await productService.GetProductById(productId); - - // CanView on this read: a product shared with other stores opens read-only on Store, so its - // tabs must load; the mutating actions below keep the strict HasAccess. History: - // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct gating - // this action on both hosts. Admin's original had no check at all. Vendor's original signature - // also lacked the unused `DataSourceRequest command` parameter that Admin/Store both bind (Kendo - // posts it, but no host ever reads it) - kept here to match the two-of-three shape; harmless for - // Vendor since an unused extra bound parameter changes nothing about the response. - if (!await scope.CanView(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var productPicturesModel = await productViewModelService.PrepareProductPicturesModel(product); - var gridModel = new DataSourceResult { - Data = productPicturesModel, - Total = productPicturesModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task ProductPicturePopup(string productId, string id) - { - var product = await productService.GetProductById(productId); - if (product == null) - return Content("Product not exist"); - - // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct gating - // this action on both hosts. Admin's original had no check at all. - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var pp = product.ProductPictures.FirstOrDefault(x => x.Id == id); - if (pp == null) - return Content("Product picture not exist"); - - var (model, picture) = await productViewModelService.PrepareProductPictureModel(product, pp); - //locales - await AddLocales(languageService, model.Locales, (locale, languageId) => - { - locale.AltAttribute = picture?.GetTranslation(x => x.AltAttribute, languageId, false); - locale.TitleAttribute = picture?.GetTranslation(x => x.TitleAttribute, languageId, false); - }); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductPicturePopup(ProductModel.ProductPictureModel model) - { - if (ModelState.IsValid) - { - var product = await productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original - // ProductPicturePopup(POST) had no check at all, letting any vendor rename/re-alt-text another - // vendor's product picture by posting its productId/model.Id - closed here the same way as the - // other rows in this task. - if (!await scope.HasAccess(product)) - throw new ArgumentException(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (product.ProductPictures.FirstOrDefault(x => x.Id == model.Id) == null) - throw new ArgumentException("No product picture found with the specified id"); - - await productViewModelService.UpdateProductPicture(model); - - return Content(""); - } - - Error(ModelState); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductPictureDelete(ProductModel.ProductPictureModel model) - { - var product = await productService.GetProductById(model.ProductId); - - // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original - // ProductPictureDelete had no check at all, letting any vendor delete another vendor's product - // picture by posting its productId/model.Id - closed here the same way as ProductPicturePopup(POST) - // above. - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await productViewModelService.DeleteProductPicture(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - #region Product specification attributes //ajax diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductPictureController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductPictureController.cs new file mode 100644 index 000000000..6c7d9d936 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductPictureController.cs @@ -0,0 +1,201 @@ +using Grand.Business.Core.Extensions; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Storage; +using Grand.Domain.Catalog; +using Grand.Domain.Common; +using Grand.Domain.Media; +using Grand.Domain.Permissions; +using Grand.SharedKernel.Extensions; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Extensions; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.StaticFiles; + +namespace Grand.Web.AdminShared.Controllers; + +[PermissionAuthorize(PermissionSystemName.Products)] +[AutoValidateAntiforgeryToken] +public abstract class BaseProductPictureController( + IProductViewModelService productViewModelService, + IProductService productService, + ILanguageService languageService, + IRequestTranslationService translationService, + IRequestPermissionService permissionService, + IAdminDataScope scope) + : BaseController +{ + #region Product pictures + + [HttpPost] + public async Task ProductPictureAdd( + IFormFileCollection files, + Reference reference, string objectId, + [FromServices] IPictureService pictureService, + [FromServices] MediaSettings mediaSettings) + { + if (!await permissionService.Authorize(PermissionSystemName.Pictures)) + return Json(new { + success = false, + message = "Access denied - picture permissions" + }); + + if (reference != Reference.Product || string.IsNullOrEmpty(objectId)) + return Json(new { + success = false, + message = "Please save form before upload new pictures" + }); + + if (!files.Any()) + return Json(new { + success = false, + message = "No files uploaded" + }); + + var product = await productService.GetProductById(objectId); + + if (!await scope.HasAccess(product)) + return Json(new { + success = false, + message = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions") + }); + + var values = new List<(string pictureUrl, string pictureId)>(); + foreach (var file in files) + { + var fileName = Path.GetFileName(file.FileName); + var contentType = file.ContentType; + var fileExtension = Path.GetExtension(fileName); + if (string.IsNullOrEmpty(contentType)) + _ = new FileExtensionContentTypeProvider().TryGetContentType(fileName, out contentType); + + if (FileExtensions.GetAllowedMediaFileTypes(mediaSettings.AllowedFileTypes).IsAllowedMediaFileType(fileExtension)) + { + var fileBinary = file.GetDownloadBits(); + //insert picture + var picture = await pictureService.InsertPicture(fileBinary, contentType, null, reference: reference, + objectId: objectId); + var pictureUrl = await pictureService.GetPictureUrl(picture); + + values.Add((pictureUrl, picture.Id)); + //assign picture to the product + await productViewModelService.InsertProductPicture(product, picture, 0); + } + } + + return Json(new { success = values.Any(), data = values }); + } + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ProductPictureList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // CanView on this read: a product shared with other stores opens read-only on Store, so its + // tabs must load; the mutating actions below keep the strict HasAccess. History: + // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct gating + // this action on both hosts. Admin's original had no check at all. Vendor's original signature + // also lacked the unused `DataSourceRequest command` parameter that Admin/Store both bind (Kendo + // posts it, but no host ever reads it) - kept here to match the two-of-three shape; harmless for + // Vendor since an unused extra bound parameter changes nothing about the response. + if (!await scope.CanView(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productPicturesModel = await productViewModelService.PrepareProductPicturesModel(product); + var gridModel = new DataSourceResult { + Data = productPicturesModel, + Total = productPicturesModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + public async Task ProductPicturePopup(string productId, string id) + { + var product = await productService.GetProductById(productId); + if (product == null) + return Content("Product not exist"); + + // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct gating + // this action on both hosts. Admin's original had no check at all. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var pp = product.ProductPictures.FirstOrDefault(x => x.Id == id); + if (pp == null) + return Content("Product picture not exist"); + + var (model, picture) = await productViewModelService.PrepareProductPictureModel(product, pp); + //locales + await AddLocales(languageService, model.Locales, (locale, languageId) => + { + locale.AltAttribute = picture?.GetTranslation(x => x.AltAttribute, languageId, false); + locale.TitleAttribute = picture?.GetTranslation(x => x.TitleAttribute, languageId, false); + }); + + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductPicturePopup(ProductModel.ProductPictureModel model) + { + if (ModelState.IsValid) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // ProductPicturePopup(POST) had no check at all, letting any vendor rename/re-alt-text another + // vendor's product picture by posting its productId/model.Id - closed here the same way as the + // other rows in this task. + if (!await scope.HasAccess(product)) + throw new ArgumentException(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (product.ProductPictures.FirstOrDefault(x => x.Id == model.Id) == null) + throw new ArgumentException("No product picture found with the specified id"); + + await productViewModelService.UpdateProductPicture(model); + + return Content(""); + } + + Error(ModelState); + + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductPictureDelete(ProductModel.ProductPictureModel model) + { + var product = await productService.GetProductById(model.ProductId); + + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // ProductPictureDelete had no check at all, letting any vendor delete another vendor's product + // picture by posting its productId/model.Id - closed here the same way as ProductPicturePopup(POST) + // above. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.DeleteProductPicture(model); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + #endregion +} diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Pictures.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Pictures.cshtml index d6845da12..3e5271e55 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Pictures.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Pictures.cshtml @@ -11,8 +11,8 @@
{{ PictureId }} + {{ PictureId }} } @@ -69,7 +69,7 @@ @{ ViewData["Reference"] = "Product"; ViewData["ObjectId"] = Model.Id; - ViewData["Endpoint"] = Url.Action("ProductPictureAdd", "Product", new { area = area }); + ViewData["Endpoint"] = Url.Action("ProductPictureAdd", "ProductPicture", new { area = area }); ViewData["Click"] = "btnRefreshProductPictures"; } diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductPicturePopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductPicture/ProductPicturePopup.cshtml similarity index 97% rename from src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductPicturePopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductPicture/ProductPicturePopup.cshtml index ea7bbdac2..a5ae6d80e 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductPicturePopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductPicture/ProductPicturePopup.cshtml @@ -4,7 +4,7 @@ Layout = ""; var area = ViewContext.RouteData.Values["area"]?.ToString(); } -
scope) + : BaseProductPictureController(productViewModelService, productService, languageService, translationService, permissionService, scope); diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductPictureController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductPictureController.cs new file mode 100644 index 000000000..dde7eeca7 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductPictureController.cs @@ -0,0 +1,25 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Grand.Web.Vendor.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Vendor.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaVendor)] +[AuthorizeVendor] +[AuthorizeMenu] +public class ProductPictureController( + IProductViewModelService productViewModelService, + IProductService productService, + ILanguageService languageService, + IRequestTranslationService translationService, + IRequestPermissionService permissionService, + IAdminDataScope scope) + : BaseProductPictureController(productViewModelService, productService, languageService, translationService, permissionService, scope); From 07a419ea01198583ae3af7570a4ec485b1734ba9 Mon Sep 17 00:00:00 2001 From: support Date: Thu, 22 Oct 2026 06:27:05 +0200 Subject: [PATCH 05/27] Move the product specification attributes tab into its own controller --- .../Controllers/BaseProductControllerTests.cs | 245 --------------- ...BaseProductSpecificationControllerTests.cs | 282 ++++++++++++++++++ .../ProductSpecificationController.cs | 21 ++ .../Controllers/BaseProductController.cs | 147 --------- .../BaseProductSpecificationController.cs | 173 +++++++++++ ...ateOrUpdate.SpecificationAttributes.cshtml | 10 +- .../ProductSpecAttrPopup.cshtml | 4 +- .../ProductSpecificationController.cs | 21 ++ .../ProductSpecificationController.cs | 21 ++ 9 files changed, 525 insertions(+), 399 deletions(-) create mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductSpecificationControllerTests.cs create mode 100644 src/Web/Grand.Web.Admin/Controllers/ProductSpecificationController.cs create mode 100644 src/Web/Grand.Web.AdminShared/Controllers/BaseProductSpecificationController.cs rename src/Web/Grand.Web.AdminShared/Views/AdminShared/{Product => ProductSpecification}/ProductSpecAttrPopup.cshtml (97%) create mode 100644 src/Web/Grand.Web.Store/Controllers/ProductSpecificationController.cs create mode 100644 src/Web/Grand.Web.Vendor/Controllers/ProductSpecificationController.cs diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index ebdb7f746..a7509a054 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -2183,251 +2183,6 @@ public async Task AssociatedProductAddPopupPost_ScopeGrantsAccess_InvalidModel_R // GetOptionsByAttributeId is deliberately not covered here (same rationale as Product pictures' // ProductPictureAdd note): identical, unscoped across all three hosts, no access check involved. - // --- ProductSpecAttrList -------------------------------------------------------------------------- - - [TestMethod] - public async Task ProductSpecAttrList_ScopeDeniesAccess_ReturnsErrorJson() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - - var result = await _controller.ProductSpecAttrList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.PrepareProductSpecificationAttributeModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductSpecAttrList_ScopeGrantsAccess_ReturnsGrid() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - _productViewModelServiceMock.Setup(s => s.PrepareProductSpecificationAttributeModel(product)) - .ReturnsAsync(new List { new() { Id = "psa1" } }); - - var result = await _controller.ProductSpecAttrList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - Assert.IsInstanceOfType(result); - var json = (JsonResult)result; - var grid = (Grand.Web.Common.DataSource.DataSourceResult)json.Value; - Assert.AreEqual(1, grid.Total); - } - - // --- ProductSpecAttrPopup (GET) ----------------------------------------------------------------- - - [TestMethod] - public async Task ProductSpecAttrPopupGet_ScopeDeniesAccess_ReturnsContent() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - var result = await _controller.ProductSpecAttrPopup(new Mock().Object, "p1", "psa1"); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( - s => s.PrepareProductSpecificationAttributeModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductSpecAttrPopupGet_ScopeGrantsAccess_NewAttribute_ReturnsView() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var specAttrServiceMock = new Mock(); - specAttrServiceMock.Setup(s => s.GetSpecificationAttributes(It.IsAny(), 0, int.MaxValue)) - .ReturnsAsync(new PagedList(new List(), 0, int.MaxValue)); - - var result = await _controller.ProductSpecAttrPopup(specAttrServiceMock.Object, "p1", ""); - - var view = result as ViewResult; - Assert.IsNotNull(view); - var model = view.Model as ProductModel.AddProductSpecificationAttributeModel; - Assert.IsNotNull(model); - Assert.IsTrue(model.ShowOnProductPage); - } - - [TestMethod] - public async Task ProductSpecAttrPopupGet_ScopeGrantsAccess_ExistingAttribute_ReturnsPopulatedView() - { - var psa = new ProductSpecificationAttribute { Id = "psa1", SpecificationAttributeOptionId = "opt1" }; - var product = new Product { Id = "p1" }; - product.ProductSpecificationAttributes.Add(psa); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var specAttrServiceMock = new Mock(); - specAttrServiceMock.Setup(s => s.GetSpecificationAttributes(It.IsAny(), 0, int.MaxValue)) - .ReturnsAsync(new PagedList(new List(), 0, int.MaxValue)); - - var result = await _controller.ProductSpecAttrPopup(specAttrServiceMock.Object, "p1", "psa1"); - - var view = result as ViewResult; - Assert.IsNotNull(view); - var model = view.Model as ProductModel.AddProductSpecificationAttributeModel; - Assert.IsNotNull(model); - Assert.AreEqual("psa1", model.Id); - } - - // --- ProductSpecAttrPopup (POST) ---------------------------------------------------------------- - - [TestMethod] - public async Task ProductSpecAttrPopupPost_ProductNotFound_ReturnsContent() - { - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync((Product)null); - var model = new ProductModel.AddProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; - - var result = await _controller.ProductSpecAttrPopup(new Mock().Object, model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( - s => s.UpdateProductSpecificationAttributeModel(It.IsAny(), - It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductSpecAttrPopupPost_ScopeDeniesAccess_ReturnsContent_DoesNotInsertOrUpdate() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.AddProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; - - // Regression guard: Vendor's original ProductSpecAttrPopup(POST) had no access check at all, - // letting any vendor add/edit specification attributes on another vendor's product by posting - // its id. - var result = await _controller.ProductSpecAttrPopup(new Mock().Object, model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( - s => s.InsertProductSpecificationAttributeModel(It.IsAny(), - It.IsAny()), Times.Never); - _productViewModelServiceMock.Verify( - s => s.UpdateProductSpecificationAttributeModel(It.IsAny(), - It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductSpecAttrPopupPost_ScopeGrantsAccess_NewAttribute_Inserts() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.AddProductSpecificationAttributeModel { ProductId = "p1", Id = "psa-new" }; - - var result = await _controller.ProductSpecAttrPopup(new Mock().Object, model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.InsertProductSpecificationAttributeModel(model, product), Times.Once); - } - - [TestMethod] - public async Task ProductSpecAttrPopupPost_ScopeGrantsAccess_ExistingAttribute_Updates() - { - var psa = new ProductSpecificationAttribute { Id = "psa1" }; - var product = new Product { Id = "p1" }; - product.ProductSpecificationAttributes.Add(psa); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.AddProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; - - var result = await _controller.ProductSpecAttrPopup(new Mock().Object, model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.UpdateProductSpecificationAttributeModel(psa, model), Times.Once); - } - - [TestMethod] - public async Task ProductSpecAttrPopupPost_InvalidModelState_ReturnsView_DoesNotAccessProduct() - { - var model = new ProductModel.AddProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; - _controller.ModelState.AddModelError("x", "error"); - var specAttrServiceMock = new Mock(); - specAttrServiceMock.Setup(s => s.GetSpecificationAttributes(It.IsAny(), 0, int.MaxValue)) - .ReturnsAsync(new PagedList(new List(), 0, int.MaxValue)); - - var result = await _controller.ProductSpecAttrPopup(specAttrServiceMock.Object, model); - - Assert.IsInstanceOfType(result); - _productServiceMock.Verify(p => p.GetProductById(It.IsAny(), It.IsAny()), Times.Never); - } - - // --- ProductSpecAttrDelete ------------------------------------------------------------------------ - - [TestMethod] - public async Task ProductSpecAttrDelete_ProductNotFound_ReturnsContent() - { - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync((Product)null); - var model = new ProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; - - var result = await _controller.ProductSpecAttrDelete(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( - s => s.DeleteProductSpecificationAttribute(It.IsAny(), It.IsAny()), - Times.Never); - } - - [TestMethod] - public async Task ProductSpecAttrDelete_ScopeDeniesAccess_ReturnsContent_DoesNotDelete() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; - - // Regression guard: Vendor's original ProductSpecAttrDelete had no access check at all, letting - // any vendor delete another vendor's specification attribute mapping by posting its - // productId/model.Id. - var result = await _controller.ProductSpecAttrDelete(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( - s => s.DeleteProductSpecificationAttribute(It.IsAny(), It.IsAny()), - Times.Never); - } - - [TestMethod] - public async Task ProductSpecAttrDelete_ScopeGrantsAccess_ValidModel_Deletes() - { - var psa = new ProductSpecificationAttribute { Id = "psa1" }; - var product = new Product { Id = "p1" }; - product.ProductSpecificationAttributes.Add(psa); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; - - var result = await _controller.ProductSpecAttrDelete(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.DeleteProductSpecificationAttribute(product, psa), Times.Once); - } - - [TestMethod] - public async Task ProductSpecAttrDelete_ScopeGrantsAccess_AttributeNotFound_Throws() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductSpecificationAttributeModel { ProductId = "p1", Id = "missing" }; - - await Assert.ThrowsExactlyAsync(() => _controller.ProductSpecAttrDelete(model)); - } - - [TestMethod] - public async Task ProductSpecAttrDelete_InvalidModelState_ReturnsKendoGridError_DoesNotAccessProduct() - { - var model = new ProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; - _controller.ModelState.AddModelError("x", "error"); - - var result = await _controller.ProductSpecAttrDelete(model); - - Assert.IsInstanceOfType(result); - _productServiceMock.Verify(p => p.GetProductById(It.IsAny(), It.IsAny()), Times.Never); - } - // --- Purchased with order ------------------------------------------------------------------------ // Covers Admin and Store only - Vendor cannot bind to this signature (see the region comment in // BaseProductController.cs: Vendor has its own, structurally different IOrderViewModelService and diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductSpecificationControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductSpecificationControllerTests.cs new file mode 100644 index 000000000..6bc7d0311 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductSpecificationControllerTests.cs @@ -0,0 +1,282 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Linq.Expressions; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class BaseProductSpecificationControllerTests : ProductControllerTestsBase +{ + // BaseProductSpecificationController is abstract. This minimal subclass exists only so the actions under test + // can be invoked directly. + public class TestProductSpecificationController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductSpecificationController(productViewModelService, productService, translationService, scope); + + protected override TestProductSpecificationController CreateController() => new( + _productViewModelServiceMock.Object, + _productServiceMock.Object, + _translationServiceMock.Object, + _scopeMock.Object); + + // --- ProductSpecAttrList -------------------------------------------------------------------------- + + [TestMethod] + public async Task ProductSpecAttrList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + + var result = await _controller.ProductSpecAttrList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.PrepareProductSpecificationAttributeModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductSpecAttrList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductSpecificationAttributeModel(product)) + .ReturnsAsync(new List { new() { Id = "psa1" } }); + + var result = await _controller.ProductSpecAttrList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + var json = (JsonResult)result; + var grid = (Grand.Web.Common.DataSource.DataSourceResult)json.Value; + Assert.AreEqual(1, grid.Total); + } + + // --- ProductSpecAttrPopup (GET) ----------------------------------------------------------------- + + [TestMethod] + public async Task ProductSpecAttrPopupGet_ScopeDeniesAccess_ReturnsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductSpecAttrPopup(new Mock().Object, "p1", "psa1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.PrepareProductSpecificationAttributeModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductSpecAttrPopupGet_ScopeGrantsAccess_NewAttribute_ReturnsView() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var specAttrServiceMock = new Mock(); + specAttrServiceMock.Setup(s => s.GetSpecificationAttributes(It.IsAny(), 0, int.MaxValue)) + .ReturnsAsync(new PagedList(new List(), 0, int.MaxValue)); + + var result = await _controller.ProductSpecAttrPopup(specAttrServiceMock.Object, "p1", ""); + + var view = result as ViewResult; + Assert.IsNotNull(view); + var model = view.Model as ProductModel.AddProductSpecificationAttributeModel; + Assert.IsNotNull(model); + Assert.IsTrue(model.ShowOnProductPage); + } + + [TestMethod] + public async Task ProductSpecAttrPopupGet_ScopeGrantsAccess_ExistingAttribute_ReturnsPopulatedView() + { + var psa = new ProductSpecificationAttribute { Id = "psa1", SpecificationAttributeOptionId = "opt1" }; + var product = new Product { Id = "p1" }; + product.ProductSpecificationAttributes.Add(psa); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var specAttrServiceMock = new Mock(); + specAttrServiceMock.Setup(s => s.GetSpecificationAttributes(It.IsAny(), 0, int.MaxValue)) + .ReturnsAsync(new PagedList(new List(), 0, int.MaxValue)); + + var result = await _controller.ProductSpecAttrPopup(specAttrServiceMock.Object, "p1", "psa1"); + + var view = result as ViewResult; + Assert.IsNotNull(view); + var model = view.Model as ProductModel.AddProductSpecificationAttributeModel; + Assert.IsNotNull(model); + Assert.AreEqual("psa1", model.Id); + } + + // --- ProductSpecAttrPopup (POST) ---------------------------------------------------------------- + + [TestMethod] + public async Task ProductSpecAttrPopupPost_ProductNotFound_ReturnsContent() + { + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync((Product)null); + var model = new ProductModel.AddProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; + + var result = await _controller.ProductSpecAttrPopup(new Mock().Object, model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.UpdateProductSpecificationAttributeModel(It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductSpecAttrPopupPost_ScopeDeniesAccess_ReturnsContent_DoesNotInsertOrUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.AddProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; + + // Regression guard: Vendor's original ProductSpecAttrPopup(POST) had no access check at all, + // letting any vendor add/edit specification attributes on another vendor's product by posting + // its id. + var result = await _controller.ProductSpecAttrPopup(new Mock().Object, model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.InsertProductSpecificationAttributeModel(It.IsAny(), + It.IsAny()), Times.Never); + _productViewModelServiceMock.Verify( + s => s.UpdateProductSpecificationAttributeModel(It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductSpecAttrPopupPost_ScopeGrantsAccess_NewAttribute_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.AddProductSpecificationAttributeModel { ProductId = "p1", Id = "psa-new" }; + + var result = await _controller.ProductSpecAttrPopup(new Mock().Object, model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.InsertProductSpecificationAttributeModel(model, product), Times.Once); + } + + [TestMethod] + public async Task ProductSpecAttrPopupPost_ScopeGrantsAccess_ExistingAttribute_Updates() + { + var psa = new ProductSpecificationAttribute { Id = "psa1" }; + var product = new Product { Id = "p1" }; + product.ProductSpecificationAttributes.Add(psa); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.AddProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; + + var result = await _controller.ProductSpecAttrPopup(new Mock().Object, model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.UpdateProductSpecificationAttributeModel(psa, model), Times.Once); + } + + [TestMethod] + public async Task ProductSpecAttrPopupPost_InvalidModelState_ReturnsView_DoesNotAccessProduct() + { + var model = new ProductModel.AddProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; + _controller.ModelState.AddModelError("x", "error"); + var specAttrServiceMock = new Mock(); + specAttrServiceMock.Setup(s => s.GetSpecificationAttributes(It.IsAny(), 0, int.MaxValue)) + .ReturnsAsync(new PagedList(new List(), 0, int.MaxValue)); + + var result = await _controller.ProductSpecAttrPopup(specAttrServiceMock.Object, model); + + Assert.IsInstanceOfType(result); + _productServiceMock.Verify(p => p.GetProductById(It.IsAny(), It.IsAny()), Times.Never); + } + + // --- ProductSpecAttrDelete ------------------------------------------------------------------------ + + [TestMethod] + public async Task ProductSpecAttrDelete_ProductNotFound_ReturnsContent() + { + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync((Product)null); + var model = new ProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; + + var result = await _controller.ProductSpecAttrDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.DeleteProductSpecificationAttribute(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductSpecAttrDelete_ScopeDeniesAccess_ReturnsContent_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; + + // Regression guard: Vendor's original ProductSpecAttrDelete had no access check at all, letting + // any vendor delete another vendor's specification attribute mapping by posting its + // productId/model.Id. + var result = await _controller.ProductSpecAttrDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.DeleteProductSpecificationAttribute(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductSpecAttrDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var psa = new ProductSpecificationAttribute { Id = "psa1" }; + var product = new Product { Id = "p1" }; + product.ProductSpecificationAttributes.Add(psa); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; + + var result = await _controller.ProductSpecAttrDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteProductSpecificationAttribute(product, psa), Times.Once); + } + + [TestMethod] + public async Task ProductSpecAttrDelete_ScopeGrantsAccess_AttributeNotFound_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductSpecificationAttributeModel { ProductId = "p1", Id = "missing" }; + + await Assert.ThrowsExactlyAsync(() => _controller.ProductSpecAttrDelete(model)); + } + + [TestMethod] + public async Task ProductSpecAttrDelete_InvalidModelState_ReturnsKendoGridError_DoesNotAccessProduct() + { + var model = new ProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; + _controller.ModelState.AddModelError("x", "error"); + + var result = await _controller.ProductSpecAttrDelete(model); + + Assert.IsInstanceOfType(result); + _productServiceMock.Verify(p => p.GetProductById(It.IsAny(), It.IsAny()), Times.Never); + } +} diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductSpecificationController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductSpecificationController.cs new file mode 100644 index 000000000..696e219f9 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Controllers/ProductSpecificationController.cs @@ -0,0 +1,21 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.Admin.Extensions; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Admin.Controllers; + +[AuthorizeAdmin] +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaAdmin)] +[AuthorizeMenu] +public class ProductSpecificationController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductSpecificationController(productViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index 8159ec92f..83d415d21 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -1383,153 +1383,6 @@ public async Task AssociatedProductAddPopup(ProductModel.AddAssoc #endregion - #region Product specification attributes - - //ajax - [AcceptVerbs("GET")] - public async Task GetOptionsByAttributeId(string attributeId, - [FromServices] ISpecificationAttributeService specificationAttributeService) - { - if (string.IsNullOrEmpty(attributeId)) - return Json(""); - - var options = - (await specificationAttributeService.GetSpecificationAttributeById(attributeId)) - .SpecificationAttributeOptions.OrderBy(x => x.DisplayOrder); - var result = (from o in options - select new { id = o.Id, name = o.Name }).ToList(); - return Json(result); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductSpecAttrList(DataSourceRequest command, string productId) - { - var product = await productService.GetProductById(productId); - - // CanView on this read: a product shared with other stores opens read-only on Store, so its - // tabs must load; the mutating actions below keep the strict HasAccess. History: - // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct gating - // this action on both hosts. Admin's original ProductSpecAttrList had no check at all - applying - // HasAccess uniformly also closes that gap without changing Admin's superuser behaviour (HasAccess - // is a no-op for Admin's scope). Vendor's original signature also dropped the DataSourceRequest - // command parameter entirely (unused by the body on any host either way) - kept here for parity - // with Admin/Store. - if (!await scope.CanView(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var productrSpecsModel = await productViewModelService.PrepareProductSpecificationAttributeModel(product); - var gridModel = new DataSourceResult { - Data = productrSpecsModel, - Total = productrSpecsModel.Count - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductSpecAttrPopup( - [FromServices] ISpecificationAttributeService specificationAttributeService, - string productId, string id) - { - var product = await productService.GetProductById(productId); - - // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct check on - // this action. Admin's original ProductSpecAttrPopup(GET) had no check at all - closed the same - // way as ProductSpecAttrList above. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var model = new ProductModel.AddProductSpecificationAttributeModel { - //default specs values - ShowOnProductPage = true - }; - - if (!string.IsNullOrEmpty(id)) - { - var specification = product.ProductSpecificationAttributes.FirstOrDefault(x => x.Id == id); - if (specification != null) model = specification.ToModel(); - } - - model.AvailableAttributes = await PrepareAvailableAttributes(specificationAttributeService); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductSpecAttrPopup( - [FromServices] ISpecificationAttributeService specificationAttributeService, - ProductModel.AddProductSpecificationAttributeModel model) - { - if (ModelState.IsValid) - { - var product = await productService.GetProductById(model.ProductId); - if (product == null) - return Content("Product not exists"); - - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var psa = product.ProductSpecificationAttributes.FirstOrDefault(x => x.Id == model.Id); - if (psa == null) - await productViewModelService.InsertProductSpecificationAttributeModel(model, product); - else - await productViewModelService.UpdateProductSpecificationAttributeModel(psa, model); - - return new JsonResult(""); - } - - Error(ModelState); - model.AvailableAttributes = await PrepareAvailableAttributes(specificationAttributeService); - - return View(model); - } - - /// scope.DefaultStoreId ?? "" matches Store's PrepareAvailableAttributes(StaffStoreId) call - /// and Admin/Vendor's parameterless call (both pass an empty storeId, seeing every specification - /// attribute regardless of store). - private async Task> PrepareAvailableAttributes( - ISpecificationAttributeService specificationAttributeService) - { - var availableSpecificationAttributes = new List(); - foreach (var sa in await specificationAttributeService.GetSpecificationAttributes(scope.DefaultStoreId ?? "")) - availableSpecificationAttributes.Add(new SelectListItem { - Text = sa.Name, - Value = sa.Id - }); - return availableSpecificationAttributes; - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductSpecAttrDelete(ProductSpecificationAttributeModel model) - { - if (ModelState.IsValid) - { - var product = await productService.GetProductById(model.ProductId); - if (product == null) - return Content("Product not exists"); - - // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original - // ProductSpecAttrDelete had no check at all, letting any vendor delete another vendor's - // specification attribute mapping by posting its productId/model.Id - closed here the same - // way as ProductSpecAttrPopup(POST) above. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var psa = product.ProductSpecificationAttributes.FirstOrDefault(x => x.Id == model.Id); - if (psa == null) - throw new ArgumentException("No specification attribute found with the specified id"); - - await productViewModelService.DeleteProductSpecificationAttribute(product, psa); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - #region Purchased with order // Type note: this method covers Admin and Store only. Both already share diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductSpecificationController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductSpecificationController.cs new file mode 100644 index 000000000..fb2dc160e --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductSpecificationController.cs @@ -0,0 +1,173 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Domain.Permissions; +using Grand.SharedKernel.Extensions; +using Grand.Web.AdminShared.Extensions.Mapping; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace Grand.Web.AdminShared.Controllers; + +[PermissionAuthorize(PermissionSystemName.Products)] +[AutoValidateAntiforgeryToken] +public abstract class BaseProductSpecificationController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseController +{ + #region Product specification attributes + + //ajax + [AcceptVerbs("GET")] + public async Task GetOptionsByAttributeId(string attributeId, + [FromServices] ISpecificationAttributeService specificationAttributeService) + { + if (string.IsNullOrEmpty(attributeId)) + return Json(""); + + var options = + (await specificationAttributeService.GetSpecificationAttributeById(attributeId)) + .SpecificationAttributeOptions.OrderBy(x => x.DisplayOrder); + var result = (from o in options + select new { id = o.Id, name = o.Name }).ToList(); + return Json(result); + } + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ProductSpecAttrList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // CanView on this read: a product shared with other stores opens read-only on Store, so its + // tabs must load; the mutating actions below keep the strict HasAccess. History: + // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct gating + // this action on both hosts. Admin's original ProductSpecAttrList had no check at all - applying + // HasAccess uniformly also closes that gap without changing Admin's superuser behaviour (HasAccess + // is a no-op for Admin's scope). Vendor's original signature also dropped the DataSourceRequest + // command parameter entirely (unused by the body on any host either way) - kept here for parity + // with Admin/Store. + if (!await scope.CanView(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productrSpecsModel = await productViewModelService.PrepareProductSpecificationAttributeModel(product); + var gridModel = new DataSourceResult { + Data = productrSpecsModel, + Total = productrSpecsModel.Count + }; + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ProductSpecAttrPopup( + [FromServices] ISpecificationAttributeService specificationAttributeService, + string productId, string id) + { + var product = await productService.GetProductById(productId); + + // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct check on + // this action. Admin's original ProductSpecAttrPopup(GET) had no check at all - closed the same + // way as ProductSpecAttrList above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var model = new ProductModel.AddProductSpecificationAttributeModel { + //default specs values + ShowOnProductPage = true + }; + + if (!string.IsNullOrEmpty(id)) + { + var specification = product.ProductSpecificationAttributes.FirstOrDefault(x => x.Id == id); + if (specification != null) model = specification.ToModel(); + } + + model.AvailableAttributes = await PrepareAvailableAttributes(specificationAttributeService); + + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductSpecAttrPopup( + [FromServices] ISpecificationAttributeService specificationAttributeService, + ProductModel.AddProductSpecificationAttributeModel model) + { + if (ModelState.IsValid) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + return Content("Product not exists"); + + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var psa = product.ProductSpecificationAttributes.FirstOrDefault(x => x.Id == model.Id); + if (psa == null) + await productViewModelService.InsertProductSpecificationAttributeModel(model, product); + else + await productViewModelService.UpdateProductSpecificationAttributeModel(psa, model); + + return new JsonResult(""); + } + + Error(ModelState); + model.AvailableAttributes = await PrepareAvailableAttributes(specificationAttributeService); + + return View(model); + } + + /// scope.DefaultStoreId ?? "" matches Store's PrepareAvailableAttributes(StaffStoreId) call + /// and Admin/Vendor's parameterless call (both pass an empty storeId, seeing every specification + /// attribute regardless of store). + private async Task> PrepareAvailableAttributes( + ISpecificationAttributeService specificationAttributeService) + { + var availableSpecificationAttributes = new List(); + foreach (var sa in await specificationAttributeService.GetSpecificationAttributes(scope.DefaultStoreId ?? "")) + availableSpecificationAttributes.Add(new SelectListItem { + Text = sa.Name, + Value = sa.Id + }); + return availableSpecificationAttributes; + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductSpecAttrDelete(ProductSpecificationAttributeModel model) + { + if (ModelState.IsValid) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + return Content("Product not exists"); + + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // ProductSpecAttrDelete had no check at all, letting any vendor delete another vendor's + // specification attribute mapping by posting its productId/model.Id - closed here the same + // way as ProductSpecAttrPopup(POST) above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var psa = product.ProductSpecificationAttributes.FirstOrDefault(x => x.Id == model.Id); + if (psa == null) + throw new ArgumentException("No specification attribute found with the specified id"); + + await productViewModelService.DeleteProductSpecificationAttribute(product, psa); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + #endregion +} diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.SpecificationAttributes.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.SpecificationAttributes.cshtml index 31f64fbd4..9d59ec78d 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.SpecificationAttributes.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.SpecificationAttributes.cshtml @@ -12,15 +12,15 @@

@if (!readOnly) { - @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.SpecificationAttributes.AddButton"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.SpecificationAttributes.AddButton"] }
@@ -32,7 +32,7 @@ } else { - {{ AttributeTypeName }} + {{ AttributeTypeName }} } @@ -45,7 +45,7 @@ } else { - {{ AttributeName }} : {{{ ValueRaw }}} + {{ AttributeName }} : {{{ ValueRaw }}} } diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductSpecAttrPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductSpecification/ProductSpecAttrPopup.cshtml similarity index 97% rename from src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductSpecAttrPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductSpecification/ProductSpecAttrPopup.cshtml index cf417ce9d..b8194cc31 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductSpecAttrPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductSpecification/ProductSpecAttrPopup.cshtml @@ -4,7 +4,7 @@ var area = ViewContext.RouteData.Values["area"]?.ToString(); } @@ -68,7 +68,7 @@ $.ajax({ cache: false, type: "GET", - url: "@(Url.Action("GetOptionsByAttributeId", "Product", new { area = area }))", + url: "@(Url.Action("GetOptionsByAttributeId", "ProductSpecification", new { area = area }))", data: { "attributeId": selectedAttributeId }, success: function (data) { var ddlSpecOptions = $("#@Html.IdFor(model => model.SpecificationAttributeOptionId)"); diff --git a/src/Web/Grand.Web.Store/Controllers/ProductSpecificationController.cs b/src/Web/Grand.Web.Store/Controllers/ProductSpecificationController.cs new file mode 100644 index 000000000..37b1f1f72 --- /dev/null +++ b/src/Web/Grand.Web.Store/Controllers/ProductSpecificationController.cs @@ -0,0 +1,21 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Store.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Store.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaStore)] +[AuthorizeStore] +[AuthorizeMenu] +public class ProductSpecificationController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductSpecificationController(productViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductSpecificationController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductSpecificationController.cs new file mode 100644 index 000000000..58aad6a4e --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductSpecificationController.cs @@ -0,0 +1,21 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Vendor.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Vendor.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaVendor)] +[AuthorizeVendor] +[AuthorizeMenu] +public class ProductSpecificationController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductSpecificationController(productViewModelService, productService, translationService, scope); From 6971589e70fc042dbd07124c5f6cf467e62ad135 Mon Sep 17 00:00:00 2001 From: support Date: Thu, 22 Oct 2026 06:28:45 +0200 Subject: [PATCH 06/27] Move the product categories and collections tab into their own controllers --- .../BaseProductCategoryControllerTests.cs | 224 ++++++++++++ .../BaseProductCollectionControllerTests.cs | 186 ++++++++++ .../Controllers/BaseProductControllerTests.cs | 337 ------------------ .../Controllers/ProductCategoryController.cs | 21 ++ .../ProductCollectionController.cs | 21 ++ .../BaseProductCategoryController.cs | 121 +++++++ .../BaseProductCollectionController.cs | 114 ++++++ .../Controllers/BaseProductController.cs | 188 ---------- .../Partials/CreateOrUpdate.Categories.cshtml | 8 +- .../CreateOrUpdate.Collections.cshtml | 8 +- .../Controllers/ProductCategoryController.cs | 21 ++ .../ProductCollectionController.cs | 21 ++ .../Controllers/ProductCategoryController.cs | 21 ++ .../ProductCollectionController.cs | 21 ++ 14 files changed, 779 insertions(+), 533 deletions(-) create mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductCategoryControllerTests.cs create mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductCollectionControllerTests.cs create mode 100644 src/Web/Grand.Web.Admin/Controllers/ProductCategoryController.cs create mode 100644 src/Web/Grand.Web.Admin/Controllers/ProductCollectionController.cs create mode 100644 src/Web/Grand.Web.AdminShared/Controllers/BaseProductCategoryController.cs create mode 100644 src/Web/Grand.Web.AdminShared/Controllers/BaseProductCollectionController.cs create mode 100644 src/Web/Grand.Web.Store/Controllers/ProductCategoryController.cs create mode 100644 src/Web/Grand.Web.Store/Controllers/ProductCollectionController.cs create mode 100644 src/Web/Grand.Web.Vendor/Controllers/ProductCategoryController.cs create mode 100644 src/Web/Grand.Web.Vendor/Controllers/ProductCollectionController.cs diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductCategoryControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductCategoryControllerTests.cs new file mode 100644 index 000000000..dbb7d145b --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductCategoryControllerTests.cs @@ -0,0 +1,224 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Linq.Expressions; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class BaseProductCategoryControllerTests : ProductControllerTestsBase +{ + // BaseProductCategoryController is abstract. This minimal subclass exists only so the actions under test + // can be invoked directly. + public class TestProductCategoryController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductCategoryController(productViewModelService, productService, translationService, scope); + + protected override TestProductCategoryController CreateController() => new( + _productViewModelServiceMock.Object, + _productServiceMock.Object, + _translationServiceMock.Object, + _scopeMock.Object); + + // --- ProductCategoryList ---------------------------------------------------------------------- + // HasAccess (strict), not CanView: mirrors Store's CanAccessProduct (AccessToEntityByStore) and + // Vendor's CheckAccessToProduct (VendorId equality) gating this action on both hosts. + + [TestMethod] + public async Task ProductCategoryList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + + var result = await _controller.ProductCategoryList( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.PrepareProductCategoryModel(It.IsAny()), Times.Never); + } + + // Guards against a real regression found in review: the denial message must be templated via + // scope.ResourceKeyPrefix, not hardcoded to "Admin." - "Vendor.Catalog.Products.Permissions" exists + // at the XML resource layer (en_220.xml) even though Task 6's narrower file-scoped audit never saw a + // Vendor call site for it. + [TestMethod] + public async Task ProductCategoryList_ScopeDeniesAccess_UsesScopeResourceKeyPrefix() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); + + var result = await _controller.ProductCategoryList( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); + } + + [TestMethod] + public async Task ProductCategoryList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductCategoryModel(product)) + .ReturnsAsync(new List { new() { Id = "c1", ProductId = "p1" } }); + + var result = await _controller.ProductCategoryList( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // The tabs of a product the Store staff member can see but not edit (shared with other stores) + // must load: reads are gated by CanView, only the mutations by HasAccess. + [TestMethod] + public async Task ProductCategoryList_ViewableButNotOwned_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + _productViewModelServiceMock.Setup(s => s.PrepareProductCategoryModel(product)) + .ReturnsAsync(new List { new() { Id = "c1", ProductId = "p1" } }); + + var result = await _controller.ProductCategoryList( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var gridModel = (result as JsonResult)?.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + [TestMethod] + public async Task ProductCategoryInsert_ViewableButNotOwned_IsDenied() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductCategoryInsert(new ProductModel.ProductCategoryModel { ProductId = "p1" }); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.InsertProductCategoryModel(It.IsAny()), Times.Never); + } + + // --- ProductCategoryInsert --------------------------------------------------------------------- + + [TestMethod] + public async Task ProductCategoryInsert_ScopeDeniesAccess_ReturnsErrorJson_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductCategoryModel { ProductId = "p1" }; + + var result = await _controller.ProductCategoryInsert(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.InsertProductCategoryModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductCategoryInsert_ScopeGrantsAccess_ValidModel_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductCategoryModel { ProductId = "p1" }; + + var result = await _controller.ProductCategoryInsert(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.InsertProductCategoryModel(model), Times.Once); + } + + // --- ProductCategoryUpdate --------------------------------------------------------------------- + + [TestMethod] + public async Task ProductCategoryUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductCategoryModel { ProductId = "p1" }; + + var result = await _controller.ProductCategoryUpdate(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.UpdateProductCategoryModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductCategoryUpdate_ScopeGrantsAccess_ValidModel_Updates() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductCategoryModel { ProductId = "p1" }; + + var result = await _controller.ProductCategoryUpdate(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.UpdateProductCategoryModel(model), Times.Once); + } + + // --- ProductCategoryDelete --------------------------------------------------------------------- + + [TestMethod] + public async Task ProductCategoryDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductCategoryModel { Id = "c1", ProductId = "p1" }; + + var result = await _controller.ProductCategoryDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.DeleteProductCategory(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductCategoryDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductCategoryModel { Id = "c1", ProductId = "p1" }; + + var result = await _controller.ProductCategoryDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteProductCategory("c1", "p1"), Times.Once); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductCollectionControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductCollectionControllerTests.cs new file mode 100644 index 000000000..e62e34398 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductCollectionControllerTests.cs @@ -0,0 +1,186 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Linq.Expressions; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class BaseProductCollectionControllerTests : ProductControllerTestsBase +{ + // BaseProductCollectionController is abstract. This minimal subclass exists only so the actions under test + // can be invoked directly. + public class TestProductCollectionController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductCollectionController(productViewModelService, productService, translationService, scope); + + protected override TestProductCollectionController CreateController() => new( + _productViewModelServiceMock.Object, + _productServiceMock.Object, + _translationServiceMock.Object, + _scopeMock.Object); + + // --- ProductCollectionList ------------------------------------------------------------------ + // Same shape as ProductCategoryList above: HasAccess (strict) mirrors Store's CanAccessProduct and + // Vendor's CheckAccessToProduct gating this action on both hosts. + + [TestMethod] + public async Task ProductCollectionList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + + var result = await _controller.ProductCollectionList( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.PrepareProductCollectionModel(It.IsAny()), Times.Never); + } + + // Guards against the same regression class found in "Product categories": the denial message must be + // templated via scope.ResourceKeyPrefix, not hardcoded to "Admin." - "Vendor.Catalog.Products.Permissions" + // exists at the XML resource layer (en_220.xml) even though it has no Vendor call site in this region. + [TestMethod] + public async Task ProductCollectionList_ScopeDeniesAccess_UsesScopeResourceKeyPrefix() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); + + var result = await _controller.ProductCollectionList( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); + } + + [TestMethod] + public async Task ProductCollectionList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductCollectionModel(product)) + .ReturnsAsync(new List { new() { Id = "c1", ProductId = "p1" } }); + + var result = await _controller.ProductCollectionList( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- ProductCollectionInsert ---------------------------------------------------------------- + + [TestMethod] + public async Task ProductCollectionInsert_ScopeDeniesAccess_ReturnsErrorJson_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductCollectionModel { ProductId = "p1" }; + + var result = await _controller.ProductCollectionInsert(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.InsertProductCollection(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductCollectionInsert_ScopeGrantsAccess_ValidModel_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductCollectionModel { ProductId = "p1" }; + + var result = await _controller.ProductCollectionInsert(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.InsertProductCollection(model), Times.Once); + } + + // --- ProductCollectionUpdate ---------------------------------------------------------------- + + [TestMethod] + public async Task ProductCollectionUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductCollectionModel { ProductId = "p1" }; + + var result = await _controller.ProductCollectionUpdate(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.UpdateProductCollection(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductCollectionUpdate_ScopeGrantsAccess_ValidModel_Updates() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductCollectionModel { ProductId = "p1" }; + + var result = await _controller.ProductCollectionUpdate(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.UpdateProductCollection(model), Times.Once); + } + + // --- ProductCollectionDelete ---------------------------------------------------------------- + + [TestMethod] + public async Task ProductCollectionDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductCollectionModel { Id = "c1", ProductId = "p1" }; + + var result = await _controller.ProductCollectionDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.DeleteProductCollection(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductCollectionDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductCollectionModel { Id = "c1", ProductId = "p1" }; + + var result = await _controller.ProductCollectionDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteProductCollection("c1", "p1"), Times.Once); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index a7509a054..ad56cdbba 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -560,343 +560,6 @@ public async Task RequiredProductAddPopupList_NoDefaultStoreId_DoesNotOverrideMo Assert.AreEqual("explicit", model.SearchStoreId); } - // --- ProductCategoryList ---------------------------------------------------------------------- - // HasAccess (strict), not CanView: mirrors Store's CanAccessProduct (AccessToEntityByStore) and - // Vendor's CheckAccessToProduct (VendorId equality) gating this action on both hosts. - - [TestMethod] - public async Task ProductCategoryList_ScopeDeniesAccess_ReturnsErrorJson() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - - var result = await _controller.ProductCategoryList( - new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.PrepareProductCategoryModel(It.IsAny()), Times.Never); - } - - // Guards against a real regression found in review: the denial message must be templated via - // scope.ResourceKeyPrefix, not hardcoded to "Admin." - "Vendor.Catalog.Products.Permissions" exists - // at the XML resource layer (en_220.xml) even though Task 6's narrower file-scoped audit never saw a - // Vendor call site for it. - [TestMethod] - public async Task ProductCategoryList_ScopeDeniesAccess_UsesScopeResourceKeyPrefix() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); - - var result = await _controller.ProductCategoryList( - new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); - } - - [TestMethod] - public async Task ProductCategoryList_ScopeGrantsAccess_ReturnsGrid() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - _productViewModelServiceMock.Setup(s => s.PrepareProductCategoryModel(product)) - .ReturnsAsync(new List { new() { Id = "c1", ProductId = "p1" } }); - - var result = await _controller.ProductCategoryList( - new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - var json = result as JsonResult; - Assert.IsNotNull(json); - var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.AreEqual(1, gridModel.Total); - } - - // The tabs of a product the Store staff member can see but not edit (shared with other stores) - // must load: reads are gated by CanView, only the mutations by HasAccess. - [TestMethod] - public async Task ProductCategoryList_ViewableButNotOwned_ReturnsGrid() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - _productViewModelServiceMock.Setup(s => s.PrepareProductCategoryModel(product)) - .ReturnsAsync(new List { new() { Id = "c1", ProductId = "p1" } }); - - var result = await _controller.ProductCategoryList( - new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - var gridModel = (result as JsonResult)?.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.AreEqual(1, gridModel.Total); - } - - [TestMethod] - public async Task ProductCategoryInsert_ViewableButNotOwned_IsDenied() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - var result = await _controller.ProductCategoryInsert(new ProductModel.ProductCategoryModel { ProductId = "p1" }); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( - s => s.InsertProductCategoryModel(It.IsAny()), Times.Never); - } - - // --- ProductCategoryInsert --------------------------------------------------------------------- - - [TestMethod] - public async Task ProductCategoryInsert_ScopeDeniesAccess_ReturnsErrorJson_DoesNotInsert() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.ProductCategoryModel { ProductId = "p1" }; - - var result = await _controller.ProductCategoryInsert(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.InsertProductCategoryModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductCategoryInsert_ScopeGrantsAccess_ValidModel_Inserts() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductCategoryModel { ProductId = "p1" }; - - var result = await _controller.ProductCategoryInsert(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.InsertProductCategoryModel(model), Times.Once); - } - - // --- ProductCategoryUpdate --------------------------------------------------------------------- - - [TestMethod] - public async Task ProductCategoryUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.ProductCategoryModel { ProductId = "p1" }; - - var result = await _controller.ProductCategoryUpdate(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.UpdateProductCategoryModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductCategoryUpdate_ScopeGrantsAccess_ValidModel_Updates() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductCategoryModel { ProductId = "p1" }; - - var result = await _controller.ProductCategoryUpdate(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.UpdateProductCategoryModel(model), Times.Once); - } - - // --- ProductCategoryDelete --------------------------------------------------------------------- - - [TestMethod] - public async Task ProductCategoryDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.ProductCategoryModel { Id = "c1", ProductId = "p1" }; - - var result = await _controller.ProductCategoryDelete(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.DeleteProductCategory(It.IsAny(), It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductCategoryDelete_ScopeGrantsAccess_ValidModel_Deletes() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductCategoryModel { Id = "c1", ProductId = "p1" }; - - var result = await _controller.ProductCategoryDelete(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.DeleteProductCategory("c1", "p1"), Times.Once); - } - - // --- ProductCollectionList ------------------------------------------------------------------ - // Same shape as ProductCategoryList above: HasAccess (strict) mirrors Store's CanAccessProduct and - // Vendor's CheckAccessToProduct gating this action on both hosts. - - [TestMethod] - public async Task ProductCollectionList_ScopeDeniesAccess_ReturnsErrorJson() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - - var result = await _controller.ProductCollectionList( - new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.PrepareProductCollectionModel(It.IsAny()), Times.Never); - } - - // Guards against the same regression class found in "Product categories": the denial message must be - // templated via scope.ResourceKeyPrefix, not hardcoded to "Admin." - "Vendor.Catalog.Products.Permissions" - // exists at the XML resource layer (en_220.xml) even though it has no Vendor call site in this region. - [TestMethod] - public async Task ProductCollectionList_ScopeDeniesAccess_UsesScopeResourceKeyPrefix() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); - - var result = await _controller.ProductCollectionList( - new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); - } - - [TestMethod] - public async Task ProductCollectionList_ScopeGrantsAccess_ReturnsGrid() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - _productViewModelServiceMock.Setup(s => s.PrepareProductCollectionModel(product)) - .ReturnsAsync(new List { new() { Id = "c1", ProductId = "p1" } }); - - var result = await _controller.ProductCollectionList( - new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - var json = result as JsonResult; - Assert.IsNotNull(json); - var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.AreEqual(1, gridModel.Total); - } - - // --- ProductCollectionInsert ---------------------------------------------------------------- - - [TestMethod] - public async Task ProductCollectionInsert_ScopeDeniesAccess_ReturnsErrorJson_DoesNotInsert() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.ProductCollectionModel { ProductId = "p1" }; - - var result = await _controller.ProductCollectionInsert(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.InsertProductCollection(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductCollectionInsert_ScopeGrantsAccess_ValidModel_Inserts() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductCollectionModel { ProductId = "p1" }; - - var result = await _controller.ProductCollectionInsert(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.InsertProductCollection(model), Times.Once); - } - - // --- ProductCollectionUpdate ---------------------------------------------------------------- - - [TestMethod] - public async Task ProductCollectionUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.ProductCollectionModel { ProductId = "p1" }; - - var result = await _controller.ProductCollectionUpdate(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.UpdateProductCollection(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductCollectionUpdate_ScopeGrantsAccess_ValidModel_Updates() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductCollectionModel { ProductId = "p1" }; - - var result = await _controller.ProductCollectionUpdate(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.UpdateProductCollection(model), Times.Once); - } - - // --- ProductCollectionDelete ---------------------------------------------------------------- - - [TestMethod] - public async Task ProductCollectionDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.ProductCollectionModel { Id = "c1", ProductId = "p1" }; - - var result = await _controller.ProductCollectionDelete(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.DeleteProductCollection(It.IsAny(), It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductCollectionDelete_ScopeGrantsAccess_ValidModel_Deletes() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductCollectionModel { Id = "c1", ProductId = "p1" }; - - var result = await _controller.ProductCollectionDelete(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.DeleteProductCollection("c1", "p1"), Times.Once); - } - // --- RelatedProductList ------------------------------------------------------------------------ // HasAccess (strict), not CanView: same shape as ProductCategoryList/ProductCollectionList above. diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductCategoryController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductCategoryController.cs new file mode 100644 index 000000000..07976a66a --- /dev/null +++ b/src/Web/Grand.Web.Admin/Controllers/ProductCategoryController.cs @@ -0,0 +1,21 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.Admin.Extensions; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Admin.Controllers; + +[AuthorizeAdmin] +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaAdmin)] +[AuthorizeMenu] +public class ProductCategoryController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductCategoryController(productViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductCollectionController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductCollectionController.cs new file mode 100644 index 000000000..c5c9fb3a0 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Controllers/ProductCollectionController.cs @@ -0,0 +1,21 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.Admin.Extensions; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Admin.Controllers; + +[AuthorizeAdmin] +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaAdmin)] +[AuthorizeMenu] +public class ProductCollectionController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductCollectionController(productViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductCategoryController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductCategoryController.cs new file mode 100644 index 000000000..1c9eba016 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductCategoryController.cs @@ -0,0 +1,121 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Domain.Permissions; +using Grand.SharedKernel.Extensions; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.AdminShared.Controllers; + +[PermissionAuthorize(PermissionSystemName.Products)] +[AutoValidateAntiforgeryToken] +public abstract class BaseProductCategoryController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseController +{ + #region Product categories + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ProductCategoryList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // CanView on this read: a product shared with other stores opens read-only on Store, so its + // tabs must load; the mutating actions below keep the strict HasAccess. History: + // HasAccess (strict) mirrors Store's CanAccessProduct (AccessToEntityByStore) and + // Vendor's CheckAccessToProduct (VendorId equality) - both strict rules, both gate this same + // action on their respective hosts. Applying it uniformly also closes a real gap: Vendor's + // original ProductCategoryInsert/Update/Delete (below) had no ownership check at all, letting + // any vendor mutate another vendor's product-category mappings by id. + if (!await scope.CanView(product)) + // Templated, not the literal "Admin.Catalog.Products.Permissions": Task 6's audit (the header + // comment above) only covered the files under migration (the 2 ProductControllers + 2 + // ProductViewModelServices) and found no "Permissions"-suffixed GetResource call in Vendor's + // copies of *those* files - but "Vendor.Catalog.Products.Permissions" genuinely exists at the + // XML resource layer (src/Web/Grand.Web/App_Data/Resources/Upgrade/en_220.xml, consumed by + // Grand.Web.Vendor's ProductValidVendor/BundleProductModelValidator). That audit's scope was + // narrower than "Admin-only" - don't cite it as precedent for skipping templating elsewhere. + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productCategoriesModel = await productViewModelService.PrepareProductCategoryModel(product); + var gridModel = new DataSourceResult { + Data = productCategoriesModel, + Total = productCategoriesModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductCategoryInsert(ProductModel.ProductCategoryModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + try + { + await productViewModelService.InsertProductCategoryModel(model); + return new JsonResult(""); + } + catch (Exception ex) + { + return ErrorForKendoGridJson(ex.Message); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductCategoryUpdate(ProductModel.ProductCategoryModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + try + { + await productViewModelService.UpdateProductCategoryModel(model); + return new JsonResult(""); + } + catch (Exception ex) + { + return ErrorForKendoGridJson(ex.Message); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductCategoryDelete(ProductModel.ProductCategoryModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.DeleteProductCategory(model.Id, model.ProductId); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + #endregion +} diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductCollectionController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductCollectionController.cs new file mode 100644 index 000000000..25de437be --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductCollectionController.cs @@ -0,0 +1,114 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Domain.Permissions; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.AdminShared.Controllers; + +[PermissionAuthorize(PermissionSystemName.Products)] +[AutoValidateAntiforgeryToken] +public abstract class BaseProductCollectionController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseController +{ + #region Product collections + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ProductCollectionList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // CanView on this read: a product shared with other stores opens read-only on Store, so its + // tabs must load; the mutating actions below keep the strict HasAccess. History: + // HasAccess (strict) has the same shape as "Product categories" above - mirrors Store's + // CanAccessProduct and Vendor's CheckAccessToProduct gating this action on both hosts. Applying + // it uniformly also closes the same kind of gap found in "Product categories": Store's and + // Vendor's original ProductCollectionInsert/Update/Delete (below) had no ownership check at all + // - only List checked - letting any store manager or vendor mutate another party's + // product-collection mappings by id. + if (!await scope.CanView(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productCollectionsModel = await productViewModelService.PrepareProductCollectionModel(product); + var gridModel = new DataSourceResult { + Data = productCollectionsModel, + Total = productCollectionsModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductCollectionInsert(ProductModel.ProductCollectionModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + try + { + await productViewModelService.InsertProductCollection(model); + return new JsonResult(""); + } + catch (Exception ex) + { + return ErrorForKendoGridJson(ex.Message); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductCollectionUpdate(ProductModel.ProductCollectionModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + try + { + await productViewModelService.UpdateProductCollection(model); + return new JsonResult(""); + } + catch (Exception ex) + { + return ErrorForKendoGridJson(ex.Message); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductCollectionDelete(ProductModel.ProductCollectionModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.DeleteProductCollection(model.Id, model.ProductId); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + #endregion +} diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index 83d415d21..245e1e3c0 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -362,194 +362,6 @@ public async Task RequiredProductAddPopupList(DataSourceRequest c #endregion - #region Product categories - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductCategoryList(DataSourceRequest command, string productId) - { - var product = await productService.GetProductById(productId); - - // CanView on this read: a product shared with other stores opens read-only on Store, so its - // tabs must load; the mutating actions below keep the strict HasAccess. History: - // HasAccess (strict) mirrors Store's CanAccessProduct (AccessToEntityByStore) and - // Vendor's CheckAccessToProduct (VendorId equality) - both strict rules, both gate this same - // action on their respective hosts. Applying it uniformly also closes a real gap: Vendor's - // original ProductCategoryInsert/Update/Delete (below) had no ownership check at all, letting - // any vendor mutate another vendor's product-category mappings by id. - if (!await scope.CanView(product)) - // Templated, not the literal "Admin.Catalog.Products.Permissions": Task 6's audit (the header - // comment above) only covered the files under migration (the 2 ProductControllers + 2 - // ProductViewModelServices) and found no "Permissions"-suffixed GetResource call in Vendor's - // copies of *those* files - but "Vendor.Catalog.Products.Permissions" genuinely exists at the - // XML resource layer (src/Web/Grand.Web/App_Data/Resources/Upgrade/en_220.xml, consumed by - // Grand.Web.Vendor's ProductValidVendor/BundleProductModelValidator). That audit's scope was - // narrower than "Admin-only" - don't cite it as precedent for skipping templating elsewhere. - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var productCategoriesModel = await productViewModelService.PrepareProductCategoryModel(product); - var gridModel = new DataSourceResult { - Data = productCategoriesModel, - Total = productCategoriesModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCategoryInsert(ProductModel.ProductCategoryModel model) - { - var product = await productService.GetProductById(model.ProductId); - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - try - { - await productViewModelService.InsertProductCategoryModel(model); - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCategoryUpdate(ProductModel.ProductCategoryModel model) - { - var product = await productService.GetProductById(model.ProductId); - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - try - { - await productViewModelService.UpdateProductCategoryModel(model); - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCategoryDelete(ProductModel.ProductCategoryModel model) - { - var product = await productService.GetProductById(model.ProductId); - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await productViewModelService.DeleteProductCategory(model.Id, model.ProductId); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Product collections - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductCollectionList(DataSourceRequest command, string productId) - { - var product = await productService.GetProductById(productId); - - // CanView on this read: a product shared with other stores opens read-only on Store, so its - // tabs must load; the mutating actions below keep the strict HasAccess. History: - // HasAccess (strict) has the same shape as "Product categories" above - mirrors Store's - // CanAccessProduct and Vendor's CheckAccessToProduct gating this action on both hosts. Applying - // it uniformly also closes the same kind of gap found in "Product categories": Store's and - // Vendor's original ProductCollectionInsert/Update/Delete (below) had no ownership check at all - // - only List checked - letting any store manager or vendor mutate another party's - // product-collection mappings by id. - if (!await scope.CanView(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var productCollectionsModel = await productViewModelService.PrepareProductCollectionModel(product); - var gridModel = new DataSourceResult { - Data = productCollectionsModel, - Total = productCollectionsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCollectionInsert(ProductModel.ProductCollectionModel model) - { - var product = await productService.GetProductById(model.ProductId); - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - try - { - await productViewModelService.InsertProductCollection(model); - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCollectionUpdate(ProductModel.ProductCollectionModel model) - { - var product = await productService.GetProductById(model.ProductId); - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - try - { - await productViewModelService.UpdateProductCollection(model); - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCollectionDelete(ProductModel.ProductCollectionModel model) - { - var product = await productService.GetProductById(model.ProductId); - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await productViewModelService.DeleteProductCollection(model.Id, model.ProductId); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - #region Related products [PermissionAuthorizeAction(PermissionActionName.Preview)] diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Categories.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Categories.cshtml index c6fab4274..f20e16941 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Categories.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Categories.cshtml @@ -13,10 +13,10 @@
scope) + : BaseProductCategoryController(productViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.Store/Controllers/ProductCollectionController.cs b/src/Web/Grand.Web.Store/Controllers/ProductCollectionController.cs new file mode 100644 index 000000000..b2bdbfb34 --- /dev/null +++ b/src/Web/Grand.Web.Store/Controllers/ProductCollectionController.cs @@ -0,0 +1,21 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Store.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Store.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaStore)] +[AuthorizeStore] +[AuthorizeMenu] +public class ProductCollectionController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductCollectionController(productViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductCategoryController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductCategoryController.cs new file mode 100644 index 000000000..9e3c44605 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductCategoryController.cs @@ -0,0 +1,21 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Vendor.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Vendor.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaVendor)] +[AuthorizeVendor] +[AuthorizeMenu] +public class ProductCategoryController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductCategoryController(productViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductCollectionController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductCollectionController.cs new file mode 100644 index 000000000..be6571f24 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductCollectionController.cs @@ -0,0 +1,21 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Vendor.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Vendor.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaVendor)] +[AuthorizeVendor] +[AuthorizeMenu] +public class ProductCollectionController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductCollectionController(productViewModelService, productService, translationService, scope); From fe53fc632da86a495b11205091cce673cfdede17 Mon Sep 17 00:00:00 2001 From: support Date: Thu, 22 Oct 2026 06:31:04 +0200 Subject: [PATCH 07/27] Move the related products tab into its own controller --- .../Controllers/BaseProductControllerTests.cs | 917 ++---------------- .../BaseProductRelatedControllerTests.cs | 838 ++++++++++++++++ .../Controllers/ProductRelatedController.cs | 21 + .../Controllers/BaseProductController.cs | 519 ---------- .../BaseProductRelatedController.cs | 543 +++++++++++ .../Partials/CreateOrUpdate.CrossSells.cshtml | 6 +- .../CreateOrUpdate.Recommended.cshtml | 6 +- .../CreateOrUpdate.RelatedProducts.cshtml | 8 +- .../CreateOrUpdate.SimilarProducts.cshtml | 8 +- .../CrossSellProductAddPopup.cshtml | 4 +- .../RecommendedProductAddPopup.cshtml | 4 +- .../RelatedProductAddPopup.cshtml | 4 +- .../SimilarProductAddPopup.cshtml | 4 +- .../Controllers/ProductRelatedController.cs | 21 + .../Controllers/ProductController.cs | 12 - .../Controllers/ProductRelatedController.cs | 38 + 16 files changed, 1541 insertions(+), 1412 deletions(-) create mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductRelatedControllerTests.cs create mode 100644 src/Web/Grand.Web.Admin/Controllers/ProductRelatedController.cs create mode 100644 src/Web/Grand.Web.AdminShared/Controllers/BaseProductRelatedController.cs rename src/Web/Grand.Web.AdminShared/Views/AdminShared/{Product => ProductRelated}/CrossSellProductAddPopup.cshtml (97%) rename src/Web/Grand.Web.AdminShared/Views/AdminShared/{Product => ProductRelated}/RecommendedProductAddPopup.cshtml (97%) rename src/Web/Grand.Web.AdminShared/Views/AdminShared/{Product => ProductRelated}/RelatedProductAddPopup.cshtml (97%) rename src/Web/Grand.Web.AdminShared/Views/AdminShared/{Product => ProductRelated}/SimilarProductAddPopup.cshtml (97%) create mode 100644 src/Web/Grand.Web.Store/Controllers/ProductRelatedController.cs create mode 100644 src/Web/Grand.Web.Vendor/Controllers/ProductRelatedController.cs diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index ad56cdbba..d2fdd84fe 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -560,235 +560,32 @@ public async Task RequiredProductAddPopupList_NoDefaultStoreId_DoesNotOverrideMo Assert.AreEqual("explicit", model.SearchStoreId); } - // --- RelatedProductList ------------------------------------------------------------------------ - // HasAccess (strict), not CanView: same shape as ProductCategoryList/ProductCollectionList above. - - [TestMethod] - public async Task RelatedProductList_ScopeDeniesAccess_ReturnsErrorJson() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - - var result = await _controller.RelatedProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - } - - [TestMethod] - public async Task RelatedProductList_ScopeGrantsAccess_ReturnsGrid() - { - var product = new Product { Id = "p1" }; - product.RelatedProducts.Add(new RelatedProduct { Id = "r1", ProductId2 = "p2", DisplayOrder = 0 }); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _productServiceMock.Setup(p => p.GetProductById("p2", false)).ReturnsAsync(new Product { Id = "p2", Name = "Second" }); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - - var result = await _controller.RelatedProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - var json = result as JsonResult; - Assert.IsNotNull(json); - var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.AreEqual(1, gridModel.Total); - } - - // --- RelatedProductUpdate ---------------------------------------------------------------------- - - [TestMethod] - public async Task RelatedProductUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.RelatedProductModel { ProductId1 = "p1" }; - - var result = await _controller.RelatedProductUpdate(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.UpdateRelatedProductModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task RelatedProductUpdate_ScopeGrantsAccess_ValidModel_Updates() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.RelatedProductModel { ProductId1 = "p1" }; - - var result = await _controller.RelatedProductUpdate(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.UpdateRelatedProductModel(model), Times.Once); - } - - // --- RelatedProductDelete ---------------------------------------------------------------------- - - [TestMethod] - public async Task RelatedProductDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.RelatedProductModel { ProductId1 = "p1" }; - - var result = await _controller.RelatedProductDelete(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.DeleteRelatedProductModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task RelatedProductDelete_ScopeGrantsAccess_ValidModel_Deletes() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.RelatedProductModel { ProductId1 = "p1" }; - - var result = await _controller.RelatedProductDelete(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.DeleteRelatedProductModel(model), Times.Once); - } - - // --- RelatedProductAddPopup (GET) -------------------------------------------------------------- - - [TestMethod] - public async Task RelatedProductAddPopupGet_CallsPrepareRelatedProductModel() - { - // PrepareRelatedProductModel() now reads scope internally (Task 9). - _productViewModelServiceMock.Setup(s => s.PrepareRelatedProductModel()) - .ReturnsAsync(new ProductModel.AddRelatedProductModel()); - - var result = await _controller.RelatedProductAddPopup("p1") as ViewResult; - - Assert.IsNotNull(result); - var model = result.Model as ProductModel.AddRelatedProductModel; - Assert.IsNotNull(model); - Assert.AreEqual("p1", model.ProductId); - _productViewModelServiceMock.Verify(s => s.PrepareRelatedProductModel(), Times.Once); - } - - // --- RelatedProductAddPopupList ----------------------------------------------------------------- - - [TestMethod] - public async Task RelatedProductAddPopupList_UsesScopeDefaultStoreId() - { - _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); - _productViewModelServiceMock - .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) - .ReturnsAsync((new List(), 0)); - - var model = new ProductModel.AddRelatedProductModel(); - var result = await _controller.RelatedProductAddPopupList( - new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); - - Assert.IsInstanceOfType(result); - Assert.AreEqual("store-1", model.SearchStoreId); - } - - [TestMethod] - public async Task RelatedProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() - { - _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); - _productViewModelServiceMock - .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) - .ReturnsAsync((new List(), 0)); - - var model = new ProductModel.AddRelatedProductModel { SearchStoreId = "explicit" }; - var result = await _controller.RelatedProductAddPopupList( - new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); - - Assert.IsInstanceOfType(result); - Assert.AreEqual("explicit", model.SearchStoreId); - } - - // --- RelatedProductAddPopup (POST) -------------------------------------------------------------- - // HasAccess (strict): closes a real gap - Vendor's original RelatedProductAddPopup(POST) had no - // ownership check at all, letting any vendor attach related-product mappings onto another vendor's - // product by posting its id. - - [TestMethod] - public async Task RelatedProductAddPopupPost_ScopeDeniesAccess_ReturnsContentMessage_DoesNotInsert() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.AddRelatedProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; - - var result = await _controller.RelatedProductAddPopup(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.InsertRelatedProductModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task RelatedProductAddPopupPost_ScopeGrantsAccess_ValidModel_Inserts() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.AddRelatedProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; - - var result = await _controller.RelatedProductAddPopup(model); - - var content = result as ContentResult; - Assert.IsNotNull(content); - Assert.AreEqual("", content.Content); - _productViewModelServiceMock.Verify(s => s.InsertRelatedProductModel(model), Times.Once); - } - - [TestMethod] - public async Task RelatedProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ReturnsViewWithRepreparedModel() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var reprepared = new ProductModel.AddRelatedProductModel(); - _productViewModelServiceMock.Setup(s => s.PrepareRelatedProductModel()).ReturnsAsync(reprepared); - var model = new ProductModel.AddRelatedProductModel { ProductId = "p1" }; - _controller.ModelState.AddModelError("x", "error"); - - var result = await _controller.RelatedProductAddPopup(model); - - var view = result as ViewResult; - Assert.IsNotNull(view); - Assert.AreSame(reprepared, view.Model); - _productViewModelServiceMock.Verify(s => s.InsertRelatedProductModel(It.IsAny()), Times.Never); - } - - // --- SimilarProductList ------------------------------------------------------------------------ - // HasAccess (strict), not CanView: same shape as RelatedProductList above. + // --- BundleProductList ------------------------------------------------------------------------ + // HasAccess (strict), not CanView: same shape as RelatedProductList/SimilarProductList above. [TestMethod] - public async Task SimilarProductList_ScopeDeniesAccess_ReturnsErrorJson() + public async Task BundleProductList_ScopeDeniesAccess_ReturnsErrorJson() { var product = new Product { Id = "p1" }; _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - var result = await _controller.SimilarProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + var result = await _controller.BundleProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); } [TestMethod] - public async Task SimilarProductList_ScopeGrantsAccess_ReturnsGrid() + public async Task BundleProductList_ScopeGrantsAccess_ReturnsGrid() { var product = new Product { Id = "p1" }; - product.SimilarProducts.Add(new SimilarProduct { Id = "r1", ProductId2 = "p2", DisplayOrder = 0 }); + product.BundleProducts.Add(new BundleProduct { Id = "r1", ProductId = "p2", DisplayOrder = 0, Quantity = 3 }); _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _productServiceMock.Setup(p => p.GetProductById("p2", false)).ReturnsAsync(new Product { Id = "p2", Name = "Second" }); _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - var result = await _controller.SimilarProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + var result = await _controller.BundleProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); var json = result as JsonResult; Assert.IsNotNull(json); @@ -797,98 +594,98 @@ public async Task SimilarProductList_ScopeGrantsAccess_ReturnsGrid() Assert.AreEqual(1, gridModel.Total); } - // --- SimilarProductUpdate ---------------------------------------------------------------------- + // --- BundleProductUpdate ---------------------------------------------------------------------- [TestMethod] - public async Task SimilarProductUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() + public async Task BundleProductUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() { var product = new Product { Id = "p1" }; _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.SimilarProductModel { ProductId1 = "p1" }; + var model = new ProductModel.BundleProductModel { ProductBundleId = "p1" }; - var result = await _controller.SimilarProductUpdate(model); + var result = await _controller.BundleProductUpdate(model); Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.UpdateSimilarProductModel(It.IsAny()), Times.Never); + _productViewModelServiceMock.Verify(s => s.UpdateBundleProductModel(It.IsAny()), Times.Never); } [TestMethod] - public async Task SimilarProductUpdate_ScopeGrantsAccess_ValidModel_Updates() + public async Task BundleProductUpdate_ScopeGrantsAccess_ValidModel_Updates() { var product = new Product { Id = "p1" }; _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.SimilarProductModel { ProductId1 = "p1" }; + var model = new ProductModel.BundleProductModel { ProductBundleId = "p1" }; - var result = await _controller.SimilarProductUpdate(model); + var result = await _controller.BundleProductUpdate(model); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.UpdateSimilarProductModel(model), Times.Once); + _productViewModelServiceMock.Verify(s => s.UpdateBundleProductModel(model), Times.Once); } - // --- SimilarProductDelete ---------------------------------------------------------------------- + // --- BundleProductDelete ---------------------------------------------------------------------- [TestMethod] - public async Task SimilarProductDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + public async Task BundleProductDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() { var product = new Product { Id = "p1" }; _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.SimilarProductModel { ProductId1 = "p1" }; + var model = new ProductModel.BundleProductModel { ProductBundleId = "p1" }; - var result = await _controller.SimilarProductDelete(model); + var result = await _controller.BundleProductDelete(model); Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.DeleteSimilarProductModel(It.IsAny()), Times.Never); + _productViewModelServiceMock.Verify(s => s.DeleteBundleProductModel(It.IsAny()), Times.Never); } [TestMethod] - public async Task SimilarProductDelete_ScopeGrantsAccess_ValidModel_Deletes() + public async Task BundleProductDelete_ScopeGrantsAccess_ValidModel_Deletes() { var product = new Product { Id = "p1" }; _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.SimilarProductModel { ProductId1 = "p1" }; + var model = new ProductModel.BundleProductModel { ProductBundleId = "p1" }; - var result = await _controller.SimilarProductDelete(model); + var result = await _controller.BundleProductDelete(model); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.DeleteSimilarProductModel(model), Times.Once); + _productViewModelServiceMock.Verify(s => s.DeleteBundleProductModel(model), Times.Once); } - // --- SimilarProductAddPopup (GET) -------------------------------------------------------------- + // --- BundleProductAddPopup (GET) -------------------------------------------------------------- [TestMethod] - public async Task SimilarProductAddPopupGet_CallsPrepareSimilarProductModel() + public async Task BundleProductAddPopupGet_CallsPrepareBundleProductModel() { - // PrepareSimilarProductModel() now reads scope internally (Task 9). - _productViewModelServiceMock.Setup(s => s.PrepareSimilarProductModel()) - .ReturnsAsync(new ProductModel.AddSimilarProductModel()); + // PrepareBundleProductModel() now reads scope internally (Task 9). + _productViewModelServiceMock.Setup(s => s.PrepareBundleProductModel()) + .ReturnsAsync(new ProductModel.AddBundleProductModel()); - var result = await _controller.SimilarProductAddPopup("p1") as ViewResult; + var result = await _controller.BundleProductAddPopup("p1") as ViewResult; Assert.IsNotNull(result); - var model = result.Model as ProductModel.AddSimilarProductModel; + var model = result.Model as ProductModel.AddBundleProductModel; Assert.IsNotNull(model); Assert.AreEqual("p1", model.ProductId); - _productViewModelServiceMock.Verify(s => s.PrepareSimilarProductModel(), Times.Once); + _productViewModelServiceMock.Verify(s => s.PrepareBundleProductModel(), Times.Once); } - // --- SimilarProductAddPopupList ----------------------------------------------------------------- + // --- BundleProductAddPopupList ----------------------------------------------------------------- [TestMethod] - public async Task SimilarProductAddPopupList_UsesScopeDefaultStoreId() + public async Task BundleProductAddPopupList_UsesScopeDefaultStoreId() { _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); _productViewModelServiceMock - .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) .ReturnsAsync((new List(), 0)); - var model = new ProductModel.AddSimilarProductModel(); - var result = await _controller.SimilarProductAddPopupList( + var model = new ProductModel.AddBundleProductModel(); + var result = await _controller.BundleProductAddPopupList( new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); Assert.IsInstanceOfType(result); @@ -896,272 +693,69 @@ public async Task SimilarProductAddPopupList_UsesScopeDefaultStoreId() } [TestMethod] - public async Task SimilarProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() + public async Task BundleProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() { _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); _productViewModelServiceMock - .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) .ReturnsAsync((new List(), 0)); - var model = new ProductModel.AddSimilarProductModel { SearchStoreId = "explicit" }; - var result = await _controller.SimilarProductAddPopupList( + var model = new ProductModel.AddBundleProductModel { SearchStoreId = "explicit" }; + var result = await _controller.BundleProductAddPopupList( new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); Assert.IsInstanceOfType(result); Assert.AreEqual("explicit", model.SearchStoreId); } - // --- SimilarProductAddPopup (POST) -------------------------------------------------------------- - // HasAccess (strict): closes a real gap - Vendor's original SimilarProductAddPopup(POST) had no - // ownership check at all, letting any vendor attach similar-product mappings onto another vendor's + // --- BundleProductAddPopup (POST) -------------------------------------------------------------- + // HasAccess (strict): closes a real gap - Vendor's original BundleProductAddPopup(POST) had no + // ownership check at all, letting any vendor attach bundle-product mappings onto another vendor's // product by posting its id. [TestMethod] - public async Task SimilarProductAddPopupPost_ScopeDeniesAccess_ReturnsContentMessage_DoesNotInsert() + public async Task BundleProductAddPopupPost_ScopeDeniesAccess_ReturnsContentMessage_DoesNotInsert() { var product = new Product { Id = "p1" }; _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.AddSimilarProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + var model = new ProductModel.AddBundleProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; - var result = await _controller.SimilarProductAddPopup(model); + var result = await _controller.BundleProductAddPopup(model); Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.InsertSimilarProductModel(It.IsAny()), Times.Never); + _productViewModelServiceMock.Verify(s => s.InsertBundleProductModel(It.IsAny()), Times.Never); } [TestMethod] - public async Task SimilarProductAddPopupPost_ScopeGrantsAccess_ValidModel_Inserts() + public async Task BundleProductAddPopupPost_ScopeGrantsAccess_ValidModel_Inserts() { var product = new Product { Id = "p1" }; _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.AddSimilarProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + var model = new ProductModel.AddBundleProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; - var result = await _controller.SimilarProductAddPopup(model); + var result = await _controller.BundleProductAddPopup(model); var content = result as ContentResult; Assert.IsNotNull(content); Assert.AreEqual("", content.Content); - _productViewModelServiceMock.Verify(s => s.InsertSimilarProductModel(model), Times.Once); + _productViewModelServiceMock.Verify(s => s.InsertBundleProductModel(model), Times.Once); } [TestMethod] - public async Task SimilarProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ReturnsViewWithRepreparedModel() + public async Task BundleProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ReturnsViewWithRepreparedModel() { var product = new Product { Id = "p1" }; _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var reprepared = new ProductModel.AddSimilarProductModel(); - _productViewModelServiceMock.Setup(s => s.PrepareSimilarProductModel()).ReturnsAsync(reprepared); - var model = new ProductModel.AddSimilarProductModel { ProductId = "p1" }; + var reprepared = new ProductModel.AddBundleProductModel(); + _productViewModelServiceMock.Setup(s => s.PrepareBundleProductModel()).ReturnsAsync(reprepared); + var model = new ProductModel.AddBundleProductModel { ProductId = "p1" }; _controller.ModelState.AddModelError("x", "error"); - var result = await _controller.SimilarProductAddPopup(model); - - var view = result as ViewResult; - Assert.IsNotNull(view); - Assert.AreSame(reprepared, view.Model); - _productViewModelServiceMock.Verify(s => s.InsertSimilarProductModel(It.IsAny()), Times.Never); - } - - // --- BundleProductList ------------------------------------------------------------------------ - // HasAccess (strict), not CanView: same shape as RelatedProductList/SimilarProductList above. - - [TestMethod] - public async Task BundleProductList_ScopeDeniesAccess_ReturnsErrorJson() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - - var result = await _controller.BundleProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - } - - [TestMethod] - public async Task BundleProductList_ScopeGrantsAccess_ReturnsGrid() - { - var product = new Product { Id = "p1" }; - product.BundleProducts.Add(new BundleProduct { Id = "r1", ProductId = "p2", DisplayOrder = 0, Quantity = 3 }); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _productServiceMock.Setup(p => p.GetProductById("p2", false)).ReturnsAsync(new Product { Id = "p2", Name = "Second" }); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - - var result = await _controller.BundleProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - var json = result as JsonResult; - Assert.IsNotNull(json); - var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.AreEqual(1, gridModel.Total); - } - - // --- BundleProductUpdate ---------------------------------------------------------------------- - - [TestMethod] - public async Task BundleProductUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.BundleProductModel { ProductBundleId = "p1" }; - - var result = await _controller.BundleProductUpdate(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.UpdateBundleProductModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task BundleProductUpdate_ScopeGrantsAccess_ValidModel_Updates() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.BundleProductModel { ProductBundleId = "p1" }; - - var result = await _controller.BundleProductUpdate(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.UpdateBundleProductModel(model), Times.Once); - } - - // --- BundleProductDelete ---------------------------------------------------------------------- - - [TestMethod] - public async Task BundleProductDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.BundleProductModel { ProductBundleId = "p1" }; - - var result = await _controller.BundleProductDelete(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.DeleteBundleProductModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task BundleProductDelete_ScopeGrantsAccess_ValidModel_Deletes() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.BundleProductModel { ProductBundleId = "p1" }; - - var result = await _controller.BundleProductDelete(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.DeleteBundleProductModel(model), Times.Once); - } - - // --- BundleProductAddPopup (GET) -------------------------------------------------------------- - - [TestMethod] - public async Task BundleProductAddPopupGet_CallsPrepareBundleProductModel() - { - // PrepareBundleProductModel() now reads scope internally (Task 9). - _productViewModelServiceMock.Setup(s => s.PrepareBundleProductModel()) - .ReturnsAsync(new ProductModel.AddBundleProductModel()); - - var result = await _controller.BundleProductAddPopup("p1") as ViewResult; - - Assert.IsNotNull(result); - var model = result.Model as ProductModel.AddBundleProductModel; - Assert.IsNotNull(model); - Assert.AreEqual("p1", model.ProductId); - _productViewModelServiceMock.Verify(s => s.PrepareBundleProductModel(), Times.Once); - } - - // --- BundleProductAddPopupList ----------------------------------------------------------------- - - [TestMethod] - public async Task BundleProductAddPopupList_UsesScopeDefaultStoreId() - { - _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); - _productViewModelServiceMock - .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) - .ReturnsAsync((new List(), 0)); - - var model = new ProductModel.AddBundleProductModel(); - var result = await _controller.BundleProductAddPopupList( - new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); - - Assert.IsInstanceOfType(result); - Assert.AreEqual("store-1", model.SearchStoreId); - } - - [TestMethod] - public async Task BundleProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() - { - _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); - _productViewModelServiceMock - .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) - .ReturnsAsync((new List(), 0)); - - var model = new ProductModel.AddBundleProductModel { SearchStoreId = "explicit" }; - var result = await _controller.BundleProductAddPopupList( - new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); - - Assert.IsInstanceOfType(result); - Assert.AreEqual("explicit", model.SearchStoreId); - } - - // --- BundleProductAddPopup (POST) -------------------------------------------------------------- - // HasAccess (strict): closes a real gap - Vendor's original BundleProductAddPopup(POST) had no - // ownership check at all, letting any vendor attach bundle-product mappings onto another vendor's - // product by posting its id. - - [TestMethod] - public async Task BundleProductAddPopupPost_ScopeDeniesAccess_ReturnsContentMessage_DoesNotInsert() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.AddBundleProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; - - var result = await _controller.BundleProductAddPopup(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.InsertBundleProductModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task BundleProductAddPopupPost_ScopeGrantsAccess_ValidModel_Inserts() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.AddBundleProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; - - var result = await _controller.BundleProductAddPopup(model); - - var content = result as ContentResult; - Assert.IsNotNull(content); - Assert.AreEqual("", content.Content); - _productViewModelServiceMock.Verify(s => s.InsertBundleProductModel(model), Times.Once); - } - - [TestMethod] - public async Task BundleProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ReturnsViewWithRepreparedModel() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var reprepared = new ProductModel.AddBundleProductModel(); - _productViewModelServiceMock.Setup(s => s.PrepareBundleProductModel()).ReturnsAsync(reprepared); - var model = new ProductModel.AddBundleProductModel { ProductId = "p1" }; - _controller.ModelState.AddModelError("x", "error"); - - var result = await _controller.BundleProductAddPopup(model); + var result = await _controller.BundleProductAddPopup(model); var view = result as ViewResult; Assert.IsNotNull(view); @@ -1169,401 +763,6 @@ public async Task BundleProductAddPopupPost_ScopeGrantsAccess_InvalidModel_Retur _productViewModelServiceMock.Verify(s => s.InsertBundleProductModel(It.IsAny()), Times.Never); } - // --- CrossSellProductList --------------------------------------------------------------------- - // HasAccess (strict), not CanView: same shape as RelatedProductList/BundleProductList above. Admin's - // original CrossSellProductList had no check at all. - - [TestMethod] - public async Task CrossSellProductList_ScopeDeniesAccess_ReturnsErrorJson() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - - var result = await _controller.CrossSellProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - } - - [TestMethod] - public async Task CrossSellProductList_ScopeGrantsAccess_ReturnsGrid() - { - var product = new Product { Id = "p1" }; - product.CrossSellProduct.Add("p2"); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _productServiceMock.Setup(p => p.GetProductById("p2", false)).ReturnsAsync(new Product { Id = "p2", Name = "Second" }); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - - var result = await _controller.CrossSellProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - var json = result as JsonResult; - Assert.IsNotNull(json); - var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.AreEqual(1, gridModel.Total); - } - - // --- CrossSellProductDelete ------------------------------------------------------------------- - // Admin/Store both throw ArgumentException when the product does not exist; Vendor's original - // CrossSellProductDelete had no ownership check at all - closed here the same way as List above. - - [TestMethod] - public async Task CrossSellProductDelete_ProductNotFound_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync((Product)null); - var model = new ProductModel.CrossSellProductModel { ProductId = "p1", Id = "p2" }; - - await Assert.ThrowsExactlyAsync(() => _controller.CrossSellProductDelete(model)); - } - - [TestMethod] - public async Task CrossSellProductDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() - { - var product = new Product { Id = "p1" }; - product.CrossSellProduct.Add("p2"); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.CrossSellProductModel { ProductId = "p1", Id = "p2" }; - - var result = await _controller.CrossSellProductDelete(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.DeleteCrossSellProduct(It.IsAny(), It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task CrossSellProductDelete_NoMatchingCrossSellProduct_Throws() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.CrossSellProductModel { ProductId = "p1", Id = "p2" }; - - await Assert.ThrowsExactlyAsync(() => _controller.CrossSellProductDelete(model)); - } - - [TestMethod] - public async Task CrossSellProductDelete_ScopeGrantsAccess_ValidModel_Deletes() - { - var product = new Product { Id = "p1" }; - product.CrossSellProduct.Add("p2"); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.CrossSellProductModel { ProductId = "p1", Id = "p2" }; - - var result = await _controller.CrossSellProductDelete(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.DeleteCrossSellProduct("p1", "p2"), Times.Once); - } - - // --- CrossSellProductAddPopup (GET) ------------------------------------------------------------- - - [TestMethod] - public async Task CrossSellProductAddPopupGet_CallsPrepareCrossSellProductModel() - { - // PrepareCrossSellProductModel() now reads scope internally (Task 9). - _productViewModelServiceMock.Setup(s => s.PrepareCrossSellProductModel()) - .ReturnsAsync(new ProductModel.AddCrossSellProductModel()); - - var result = await _controller.CrossSellProductAddPopup("p1") as ViewResult; - - Assert.IsNotNull(result); - var model = result.Model as ProductModel.AddCrossSellProductModel; - Assert.IsNotNull(model); - Assert.AreEqual("p1", model.ProductId); - _productViewModelServiceMock.Verify(s => s.PrepareCrossSellProductModel(), Times.Once); - } - - // --- CrossSellProductAddPopupList --------------------------------------------------------------- - - [TestMethod] - public async Task CrossSellProductAddPopupList_UsesScopeDefaultStoreId() - { - _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); - _productViewModelServiceMock - .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) - .ReturnsAsync((new List(), 0)); - - var model = new ProductModel.AddCrossSellProductModel(); - var result = await _controller.CrossSellProductAddPopupList( - new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); - - Assert.IsInstanceOfType(result); - Assert.AreEqual("store-1", model.SearchStoreId); - } - - [TestMethod] - public async Task CrossSellProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() - { - _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); - _productViewModelServiceMock - .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) - .ReturnsAsync((new List(), 0)); - - var model = new ProductModel.AddCrossSellProductModel { SearchStoreId = "explicit" }; - var result = await _controller.CrossSellProductAddPopupList( - new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); - - Assert.IsInstanceOfType(result); - Assert.AreEqual("explicit", model.SearchStoreId); - } - - // --- CrossSellProductAddPopup (POST) -------------------------------------------------------------- - // HasAccess (strict): closes a real gap - Vendor's original CrossSellProductAddPopup(POST) had no - // ownership check at all, letting any vendor attach cross-sell-product mappings onto another - // vendor's product by posting its id. - - [TestMethod] - public async Task CrossSellProductAddPopupPost_ScopeDeniesAccess_ReturnsContentMessage_DoesNotInsert() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.AddCrossSellProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; - - var result = await _controller.CrossSellProductAddPopup(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.InsertCrossSellProductModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task CrossSellProductAddPopupPost_ScopeGrantsAccess_ValidModel_Inserts() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.AddCrossSellProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; - - var result = await _controller.CrossSellProductAddPopup(model); - - var content = result as ContentResult; - Assert.IsNotNull(content); - Assert.AreEqual("", content.Content); - _productViewModelServiceMock.Verify(s => s.InsertCrossSellProductModel(model), Times.Once); - } - - [TestMethod] - public async Task CrossSellProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ReturnsViewWithRepreparedModel() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var reprepared = new ProductModel.AddCrossSellProductModel(); - _productViewModelServiceMock.Setup(s => s.PrepareCrossSellProductModel()).ReturnsAsync(reprepared); - var model = new ProductModel.AddCrossSellProductModel { ProductId = "p1" }; - _controller.ModelState.AddModelError("x", "error"); - - var result = await _controller.CrossSellProductAddPopup(model); - - var view = result as ViewResult; - Assert.IsNotNull(view); - Assert.AreSame(reprepared, view.Model); - _productViewModelServiceMock.Verify(s => s.InsertCrossSellProductModel(It.IsAny()), Times.Never); - } - - // --- RecommendedProductList --------------------------------------------------------------------- - // HasAccess (strict), not CanView: same shape as CrossSellProductList above. Admin's original - // RecommendedProductList had no check at all. Vendor's original signature also dropped the - // DataSourceRequest command parameter entirely - kept here for parity with Admin/Store. - - [TestMethod] - public async Task RecommendedProductList_ScopeDeniesAccess_ReturnsErrorJson() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - - var result = await _controller.RecommendedProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - } - - [TestMethod] - public async Task RecommendedProductList_ScopeGrantsAccess_ReturnsGrid() - { - var product = new Product { Id = "p1" }; - product.RecommendedProduct.Add("p2"); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _productServiceMock.Setup(p => p.GetProductById("p2", false)).ReturnsAsync(new Product { Id = "p2", Name = "Second" }); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - - var result = await _controller.RecommendedProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - var json = result as JsonResult; - Assert.IsNotNull(json); - var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.AreEqual(1, gridModel.Total); - } - - // --- RecommendedProductDelete ------------------------------------------------------------------- - // Admin/Store/Vendor all throw ArgumentException when the product does not exist; Vendor's original - // RecommendedProductDelete had no ownership check at all - closed here the same way as List above. - - [TestMethod] - public async Task RecommendedProductDelete_ProductNotFound_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync((Product)null); - var model = new ProductModel.RecommendedProductModel { ProductId = "p1", Id = "p2" }; - - await Assert.ThrowsExactlyAsync(() => _controller.RecommendedProductDelete(model)); - } - - [TestMethod] - public async Task RecommendedProductDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() - { - var product = new Product { Id = "p1" }; - product.RecommendedProduct.Add("p2"); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.RecommendedProductModel { ProductId = "p1", Id = "p2" }; - - var result = await _controller.RecommendedProductDelete(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.DeleteRecommendedProduct(It.IsAny(), It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task RecommendedProductDelete_NoMatchingRecommendedProduct_Throws() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.RecommendedProductModel { ProductId = "p1", Id = "p2" }; - - await Assert.ThrowsExactlyAsync(() => _controller.RecommendedProductDelete(model)); - } - - [TestMethod] - public async Task RecommendedProductDelete_ScopeGrantsAccess_ValidModel_Deletes() - { - var product = new Product { Id = "p1" }; - product.RecommendedProduct.Add("p2"); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.RecommendedProductModel { ProductId = "p1", Id = "p2" }; - - var result = await _controller.RecommendedProductDelete(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.DeleteRecommendedProduct("p1", "p2"), Times.Once); - } - - // --- RecommendedProductAddPopup (GET) ------------------------------------------------------------- - - [TestMethod] - public async Task RecommendedProductAddPopupGet_CallsPrepareRecommendedProductModel() - { - // PrepareRecommendedProductModel() now reads scope internally (Task 9). - _productViewModelServiceMock.Setup(s => s.PrepareRecommendedProductModel()) - .ReturnsAsync(new ProductModel.AddRecommendedProductModel()); - - var result = await _controller.RecommendedProductAddPopup("p1") as ViewResult; - - Assert.IsNotNull(result); - var model = result.Model as ProductModel.AddRecommendedProductModel; - Assert.IsNotNull(model); - Assert.AreEqual("p1", model.ProductId); - _productViewModelServiceMock.Verify(s => s.PrepareRecommendedProductModel(), Times.Once); - } - - // --- RecommendedProductAddPopupList --------------------------------------------------------------- - - [TestMethod] - public async Task RecommendedProductAddPopupList_UsesScopeDefaultStoreId() - { - _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); - _productViewModelServiceMock - .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) - .ReturnsAsync((new List(), 0)); - - var model = new ProductModel.AddRecommendedProductModel(); - var result = await _controller.RecommendedProductAddPopupList( - new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); - - Assert.IsInstanceOfType(result); - Assert.AreEqual("store-1", model.SearchStoreId); - } - - [TestMethod] - public async Task RecommendedProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() - { - _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); - _productViewModelServiceMock - .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) - .ReturnsAsync((new List(), 0)); - - var model = new ProductModel.AddRecommendedProductModel { SearchStoreId = "explicit" }; - var result = await _controller.RecommendedProductAddPopupList( - new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); - - Assert.IsInstanceOfType(result); - Assert.AreEqual("explicit", model.SearchStoreId); - } - - // --- RecommendedProductAddPopup (POST) -------------------------------------------------------------- - // HasAccess (strict): closes a real gap - Vendor's original RecommendedProductAddPopup(POST) had no - // ownership check at all, letting any vendor attach recommended-product mappings onto another - // vendor's product by posting its id. - - [TestMethod] - public async Task RecommendedProductAddPopupPost_ScopeDeniesAccess_ReturnsContentMessage_DoesNotInsert() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.AddRecommendedProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; - - var result = await _controller.RecommendedProductAddPopup(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.InsertRecommendedProductModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task RecommendedProductAddPopupPost_ScopeGrantsAccess_ValidModel_Inserts() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.AddRecommendedProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; - - var result = await _controller.RecommendedProductAddPopup(model); - - var content = result as ContentResult; - Assert.IsNotNull(content); - Assert.AreEqual("", content.Content); - _productViewModelServiceMock.Verify(s => s.InsertRecommendedProductModel(model), Times.Once); - } - - [TestMethod] - public async Task RecommendedProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ReturnsViewWithRepreparedModel() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var reprepared = new ProductModel.AddRecommendedProductModel(); - _productViewModelServiceMock.Setup(s => s.PrepareRecommendedProductModel()).ReturnsAsync(reprepared); - var model = new ProductModel.AddRecommendedProductModel { ProductId = "p1" }; - _controller.ModelState.AddModelError("x", "error"); - - var result = await _controller.RecommendedProductAddPopup(model); - - var view = result as ViewResult; - Assert.IsNotNull(view); - Assert.AreSame(reprepared, view.Model); - _productViewModelServiceMock.Verify(s => s.InsertRecommendedProductModel(It.IsAny()), Times.Never); - } - // --- AssociatedProductList ----------------------------------------------------------------------- // HasAccess (strict), not CanView: same shape as RelatedProductList above. Admin's original had no // check at all. diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductRelatedControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductRelatedControllerTests.cs new file mode 100644 index 000000000..5d8269fd9 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductRelatedControllerTests.cs @@ -0,0 +1,838 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Linq.Expressions; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class BaseProductRelatedControllerTests : ProductControllerTestsBase +{ + // BaseProductRelatedController is abstract. This minimal subclass exists only so the actions under test + // can be invoked directly. + public class TestProductRelatedController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductRelatedController(productViewModelService, productService, translationService, scope); + + protected override TestProductRelatedController CreateController() => new( + _productViewModelServiceMock.Object, + _productServiceMock.Object, + _translationServiceMock.Object, + _scopeMock.Object); + + // --- RelatedProductList ------------------------------------------------------------------------ + // HasAccess (strict), not CanView: same shape as ProductCategoryList/ProductCollectionList above. + + [TestMethod] + public async Task RelatedProductList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + + var result = await _controller.RelatedProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task RelatedProductList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + product.RelatedProducts.Add(new RelatedProduct { Id = "r1", ProductId2 = "p2", DisplayOrder = 0 }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _productServiceMock.Setup(p => p.GetProductById("p2", false)).ReturnsAsync(new Product { Id = "p2", Name = "Second" }); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + + var result = await _controller.RelatedProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- RelatedProductUpdate ---------------------------------------------------------------------- + + [TestMethod] + public async Task RelatedProductUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.RelatedProductModel { ProductId1 = "p1" }; + + var result = await _controller.RelatedProductUpdate(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.UpdateRelatedProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task RelatedProductUpdate_ScopeGrantsAccess_ValidModel_Updates() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.RelatedProductModel { ProductId1 = "p1" }; + + var result = await _controller.RelatedProductUpdate(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.UpdateRelatedProductModel(model), Times.Once); + } + + // --- RelatedProductDelete ---------------------------------------------------------------------- + + [TestMethod] + public async Task RelatedProductDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.RelatedProductModel { ProductId1 = "p1" }; + + var result = await _controller.RelatedProductDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.DeleteRelatedProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task RelatedProductDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.RelatedProductModel { ProductId1 = "p1" }; + + var result = await _controller.RelatedProductDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteRelatedProductModel(model), Times.Once); + } + + // --- RelatedProductAddPopup (GET) -------------------------------------------------------------- + + [TestMethod] + public async Task RelatedProductAddPopupGet_CallsPrepareRelatedProductModel() + { + // PrepareRelatedProductModel() now reads scope internally (Task 9). + _productViewModelServiceMock.Setup(s => s.PrepareRelatedProductModel()) + .ReturnsAsync(new ProductModel.AddRelatedProductModel()); + + var result = await _controller.RelatedProductAddPopup("p1") as ViewResult; + + Assert.IsNotNull(result); + var model = result.Model as ProductModel.AddRelatedProductModel; + Assert.IsNotNull(model); + Assert.AreEqual("p1", model.ProductId); + _productViewModelServiceMock.Verify(s => s.PrepareRelatedProductModel(), Times.Once); + } + + // --- RelatedProductAddPopupList ----------------------------------------------------------------- + + [TestMethod] + public async Task RelatedProductAddPopupList_UsesScopeDefaultStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddRelatedProductModel(); + var result = await _controller.RelatedProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("store-1", model.SearchStoreId); + } + + [TestMethod] + public async Task RelatedProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddRelatedProductModel { SearchStoreId = "explicit" }; + var result = await _controller.RelatedProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("explicit", model.SearchStoreId); + } + + // --- RelatedProductAddPopup (POST) -------------------------------------------------------------- + // HasAccess (strict): closes a real gap - Vendor's original RelatedProductAddPopup(POST) had no + // ownership check at all, letting any vendor attach related-product mappings onto another vendor's + // product by posting its id. + + [TestMethod] + public async Task RelatedProductAddPopupPost_ScopeDeniesAccess_ReturnsContentMessage_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.AddRelatedProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + + var result = await _controller.RelatedProductAddPopup(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.InsertRelatedProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task RelatedProductAddPopupPost_ScopeGrantsAccess_ValidModel_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.AddRelatedProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + + var result = await _controller.RelatedProductAddPopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("", content.Content); + _productViewModelServiceMock.Verify(s => s.InsertRelatedProductModel(model), Times.Once); + } + + [TestMethod] + public async Task RelatedProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ReturnsViewWithRepreparedModel() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var reprepared = new ProductModel.AddRelatedProductModel(); + _productViewModelServiceMock.Setup(s => s.PrepareRelatedProductModel()).ReturnsAsync(reprepared); + var model = new ProductModel.AddRelatedProductModel { ProductId = "p1" }; + _controller.ModelState.AddModelError("x", "error"); + + var result = await _controller.RelatedProductAddPopup(model); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreSame(reprepared, view.Model); + _productViewModelServiceMock.Verify(s => s.InsertRelatedProductModel(It.IsAny()), Times.Never); + } + + // --- SimilarProductList ------------------------------------------------------------------------ + // HasAccess (strict), not CanView: same shape as RelatedProductList above. + + [TestMethod] + public async Task SimilarProductList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + + var result = await _controller.SimilarProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task SimilarProductList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + product.SimilarProducts.Add(new SimilarProduct { Id = "r1", ProductId2 = "p2", DisplayOrder = 0 }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _productServiceMock.Setup(p => p.GetProductById("p2", false)).ReturnsAsync(new Product { Id = "p2", Name = "Second" }); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + + var result = await _controller.SimilarProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- SimilarProductUpdate ---------------------------------------------------------------------- + + [TestMethod] + public async Task SimilarProductUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.SimilarProductModel { ProductId1 = "p1" }; + + var result = await _controller.SimilarProductUpdate(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.UpdateSimilarProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task SimilarProductUpdate_ScopeGrantsAccess_ValidModel_Updates() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.SimilarProductModel { ProductId1 = "p1" }; + + var result = await _controller.SimilarProductUpdate(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.UpdateSimilarProductModel(model), Times.Once); + } + + // --- SimilarProductDelete ---------------------------------------------------------------------- + + [TestMethod] + public async Task SimilarProductDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.SimilarProductModel { ProductId1 = "p1" }; + + var result = await _controller.SimilarProductDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.DeleteSimilarProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task SimilarProductDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.SimilarProductModel { ProductId1 = "p1" }; + + var result = await _controller.SimilarProductDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteSimilarProductModel(model), Times.Once); + } + + // --- SimilarProductAddPopup (GET) -------------------------------------------------------------- + + [TestMethod] + public async Task SimilarProductAddPopupGet_CallsPrepareSimilarProductModel() + { + // PrepareSimilarProductModel() now reads scope internally (Task 9). + _productViewModelServiceMock.Setup(s => s.PrepareSimilarProductModel()) + .ReturnsAsync(new ProductModel.AddSimilarProductModel()); + + var result = await _controller.SimilarProductAddPopup("p1") as ViewResult; + + Assert.IsNotNull(result); + var model = result.Model as ProductModel.AddSimilarProductModel; + Assert.IsNotNull(model); + Assert.AreEqual("p1", model.ProductId); + _productViewModelServiceMock.Verify(s => s.PrepareSimilarProductModel(), Times.Once); + } + + // --- SimilarProductAddPopupList ----------------------------------------------------------------- + + [TestMethod] + public async Task SimilarProductAddPopupList_UsesScopeDefaultStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddSimilarProductModel(); + var result = await _controller.SimilarProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("store-1", model.SearchStoreId); + } + + [TestMethod] + public async Task SimilarProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddSimilarProductModel { SearchStoreId = "explicit" }; + var result = await _controller.SimilarProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("explicit", model.SearchStoreId); + } + + // --- SimilarProductAddPopup (POST) -------------------------------------------------------------- + // HasAccess (strict): closes a real gap - Vendor's original SimilarProductAddPopup(POST) had no + // ownership check at all, letting any vendor attach similar-product mappings onto another vendor's + // product by posting its id. + + [TestMethod] + public async Task SimilarProductAddPopupPost_ScopeDeniesAccess_ReturnsContentMessage_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.AddSimilarProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + + var result = await _controller.SimilarProductAddPopup(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.InsertSimilarProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task SimilarProductAddPopupPost_ScopeGrantsAccess_ValidModel_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.AddSimilarProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + + var result = await _controller.SimilarProductAddPopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("", content.Content); + _productViewModelServiceMock.Verify(s => s.InsertSimilarProductModel(model), Times.Once); + } + + [TestMethod] + public async Task SimilarProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ReturnsViewWithRepreparedModel() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var reprepared = new ProductModel.AddSimilarProductModel(); + _productViewModelServiceMock.Setup(s => s.PrepareSimilarProductModel()).ReturnsAsync(reprepared); + var model = new ProductModel.AddSimilarProductModel { ProductId = "p1" }; + _controller.ModelState.AddModelError("x", "error"); + + var result = await _controller.SimilarProductAddPopup(model); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreSame(reprepared, view.Model); + _productViewModelServiceMock.Verify(s => s.InsertSimilarProductModel(It.IsAny()), Times.Never); + } + + // --- CrossSellProductList --------------------------------------------------------------------- + // HasAccess (strict), not CanView: same shape as RelatedProductList/BundleProductList above. Admin's + // original CrossSellProductList had no check at all. + + [TestMethod] + public async Task CrossSellProductList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + + var result = await _controller.CrossSellProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task CrossSellProductList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + product.CrossSellProduct.Add("p2"); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _productServiceMock.Setup(p => p.GetProductById("p2", false)).ReturnsAsync(new Product { Id = "p2", Name = "Second" }); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + + var result = await _controller.CrossSellProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- CrossSellProductDelete ------------------------------------------------------------------- + // Admin/Store both throw ArgumentException when the product does not exist; Vendor's original + // CrossSellProductDelete had no ownership check at all - closed here the same way as List above. + + [TestMethod] + public async Task CrossSellProductDelete_ProductNotFound_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync((Product)null); + var model = new ProductModel.CrossSellProductModel { ProductId = "p1", Id = "p2" }; + + await Assert.ThrowsExactlyAsync(() => _controller.CrossSellProductDelete(model)); + } + + [TestMethod] + public async Task CrossSellProductDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + product.CrossSellProduct.Add("p2"); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.CrossSellProductModel { ProductId = "p1", Id = "p2" }; + + var result = await _controller.CrossSellProductDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.DeleteCrossSellProduct(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task CrossSellProductDelete_NoMatchingCrossSellProduct_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.CrossSellProductModel { ProductId = "p1", Id = "p2" }; + + await Assert.ThrowsExactlyAsync(() => _controller.CrossSellProductDelete(model)); + } + + [TestMethod] + public async Task CrossSellProductDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "p1" }; + product.CrossSellProduct.Add("p2"); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.CrossSellProductModel { ProductId = "p1", Id = "p2" }; + + var result = await _controller.CrossSellProductDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteCrossSellProduct("p1", "p2"), Times.Once); + } + + // --- CrossSellProductAddPopup (GET) ------------------------------------------------------------- + + [TestMethod] + public async Task CrossSellProductAddPopupGet_CallsPrepareCrossSellProductModel() + { + // PrepareCrossSellProductModel() now reads scope internally (Task 9). + _productViewModelServiceMock.Setup(s => s.PrepareCrossSellProductModel()) + .ReturnsAsync(new ProductModel.AddCrossSellProductModel()); + + var result = await _controller.CrossSellProductAddPopup("p1") as ViewResult; + + Assert.IsNotNull(result); + var model = result.Model as ProductModel.AddCrossSellProductModel; + Assert.IsNotNull(model); + Assert.AreEqual("p1", model.ProductId); + _productViewModelServiceMock.Verify(s => s.PrepareCrossSellProductModel(), Times.Once); + } + + // --- CrossSellProductAddPopupList --------------------------------------------------------------- + + [TestMethod] + public async Task CrossSellProductAddPopupList_UsesScopeDefaultStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddCrossSellProductModel(); + var result = await _controller.CrossSellProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("store-1", model.SearchStoreId); + } + + [TestMethod] + public async Task CrossSellProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddCrossSellProductModel { SearchStoreId = "explicit" }; + var result = await _controller.CrossSellProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("explicit", model.SearchStoreId); + } + + // --- CrossSellProductAddPopup (POST) -------------------------------------------------------------- + // HasAccess (strict): closes a real gap - Vendor's original CrossSellProductAddPopup(POST) had no + // ownership check at all, letting any vendor attach cross-sell-product mappings onto another + // vendor's product by posting its id. + + [TestMethod] + public async Task CrossSellProductAddPopupPost_ScopeDeniesAccess_ReturnsContentMessage_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.AddCrossSellProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + + var result = await _controller.CrossSellProductAddPopup(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.InsertCrossSellProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task CrossSellProductAddPopupPost_ScopeGrantsAccess_ValidModel_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.AddCrossSellProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + + var result = await _controller.CrossSellProductAddPopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("", content.Content); + _productViewModelServiceMock.Verify(s => s.InsertCrossSellProductModel(model), Times.Once); + } + + [TestMethod] + public async Task CrossSellProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ReturnsViewWithRepreparedModel() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var reprepared = new ProductModel.AddCrossSellProductModel(); + _productViewModelServiceMock.Setup(s => s.PrepareCrossSellProductModel()).ReturnsAsync(reprepared); + var model = new ProductModel.AddCrossSellProductModel { ProductId = "p1" }; + _controller.ModelState.AddModelError("x", "error"); + + var result = await _controller.CrossSellProductAddPopup(model); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreSame(reprepared, view.Model); + _productViewModelServiceMock.Verify(s => s.InsertCrossSellProductModel(It.IsAny()), Times.Never); + } + + // --- RecommendedProductList --------------------------------------------------------------------- + // HasAccess (strict), not CanView: same shape as CrossSellProductList above. Admin's original + // RecommendedProductList had no check at all. Vendor's original signature also dropped the + // DataSourceRequest command parameter entirely - kept here for parity with Admin/Store. + + [TestMethod] + public async Task RecommendedProductList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + + var result = await _controller.RecommendedProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task RecommendedProductList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + product.RecommendedProduct.Add("p2"); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _productServiceMock.Setup(p => p.GetProductById("p2", false)).ReturnsAsync(new Product { Id = "p2", Name = "Second" }); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + + var result = await _controller.RecommendedProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- RecommendedProductDelete ------------------------------------------------------------------- + // Admin/Store/Vendor all throw ArgumentException when the product does not exist; Vendor's original + // RecommendedProductDelete had no ownership check at all - closed here the same way as List above. + + [TestMethod] + public async Task RecommendedProductDelete_ProductNotFound_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync((Product)null); + var model = new ProductModel.RecommendedProductModel { ProductId = "p1", Id = "p2" }; + + await Assert.ThrowsExactlyAsync(() => _controller.RecommendedProductDelete(model)); + } + + [TestMethod] + public async Task RecommendedProductDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + product.RecommendedProduct.Add("p2"); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.RecommendedProductModel { ProductId = "p1", Id = "p2" }; + + var result = await _controller.RecommendedProductDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.DeleteRecommendedProduct(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task RecommendedProductDelete_NoMatchingRecommendedProduct_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.RecommendedProductModel { ProductId = "p1", Id = "p2" }; + + await Assert.ThrowsExactlyAsync(() => _controller.RecommendedProductDelete(model)); + } + + [TestMethod] + public async Task RecommendedProductDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "p1" }; + product.RecommendedProduct.Add("p2"); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.RecommendedProductModel { ProductId = "p1", Id = "p2" }; + + var result = await _controller.RecommendedProductDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteRecommendedProduct("p1", "p2"), Times.Once); + } + + // --- RecommendedProductAddPopup (GET) ------------------------------------------------------------- + + [TestMethod] + public async Task RecommendedProductAddPopupGet_CallsPrepareRecommendedProductModel() + { + // PrepareRecommendedProductModel() now reads scope internally (Task 9). + _productViewModelServiceMock.Setup(s => s.PrepareRecommendedProductModel()) + .ReturnsAsync(new ProductModel.AddRecommendedProductModel()); + + var result = await _controller.RecommendedProductAddPopup("p1") as ViewResult; + + Assert.IsNotNull(result); + var model = result.Model as ProductModel.AddRecommendedProductModel; + Assert.IsNotNull(model); + Assert.AreEqual("p1", model.ProductId); + _productViewModelServiceMock.Verify(s => s.PrepareRecommendedProductModel(), Times.Once); + } + + // --- RecommendedProductAddPopupList --------------------------------------------------------------- + + [TestMethod] + public async Task RecommendedProductAddPopupList_UsesScopeDefaultStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddRecommendedProductModel(); + var result = await _controller.RecommendedProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("store-1", model.SearchStoreId); + } + + [TestMethod] + public async Task RecommendedProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddRecommendedProductModel { SearchStoreId = "explicit" }; + var result = await _controller.RecommendedProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("explicit", model.SearchStoreId); + } + + // --- RecommendedProductAddPopup (POST) -------------------------------------------------------------- + // HasAccess (strict): closes a real gap - Vendor's original RecommendedProductAddPopup(POST) had no + // ownership check at all, letting any vendor attach recommended-product mappings onto another + // vendor's product by posting its id. + + [TestMethod] + public async Task RecommendedProductAddPopupPost_ScopeDeniesAccess_ReturnsContentMessage_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.AddRecommendedProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + + var result = await _controller.RecommendedProductAddPopup(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.InsertRecommendedProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task RecommendedProductAddPopupPost_ScopeGrantsAccess_ValidModel_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.AddRecommendedProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + + var result = await _controller.RecommendedProductAddPopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("", content.Content); + _productViewModelServiceMock.Verify(s => s.InsertRecommendedProductModel(model), Times.Once); + } + + [TestMethod] + public async Task RecommendedProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ReturnsViewWithRepreparedModel() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var reprepared = new ProductModel.AddRecommendedProductModel(); + _productViewModelServiceMock.Setup(s => s.PrepareRecommendedProductModel()).ReturnsAsync(reprepared); + var model = new ProductModel.AddRecommendedProductModel { ProductId = "p1" }; + _controller.ModelState.AddModelError("x", "error"); + + var result = await _controller.RecommendedProductAddPopup(model); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreSame(reprepared, view.Model); + _productViewModelServiceMock.Verify(s => s.InsertRecommendedProductModel(It.IsAny()), Times.Never); + } +} diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductRelatedController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductRelatedController.cs new file mode 100644 index 000000000..7aba3b247 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Controllers/ProductRelatedController.cs @@ -0,0 +1,21 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.Admin.Extensions; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Admin.Controllers; + +[AuthorizeAdmin] +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaAdmin)] +[AuthorizeMenu] +public class ProductRelatedController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductRelatedController(productViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index 245e1e3c0..c819fd440 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -362,277 +362,6 @@ public async Task RequiredProductAddPopupList(DataSourceRequest c #endregion - #region Related products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task RelatedProductList(DataSourceRequest command, string productId) - { - var product = await productService.GetProductById(productId); - - // CanView on this read: a product shared with other stores opens read-only on Store, so its - // tabs must load; the mutating actions below keep the strict HasAccess. History: - // HasAccess (strict) has the same shape as "Product categories"/"Product collections" - - // mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct gating this action on both - // hosts. Applying it uniformly also closes the same kind of gap found in those two regions: - // Vendor's original RelatedProductUpdate/Delete/AddPopup(POST) (below) had no ownership check at - // all - only List checked - letting any vendor mutate another vendor's related-product mappings - // by id. - if (!await scope.CanView(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var relatedProducts = product.RelatedProducts.OrderBy(x => x.DisplayOrder); - var relatedProductsModel = new List(); - foreach (var x in relatedProducts) - relatedProductsModel.Add(new ProductModel.RelatedProductModel { - Id = x.Id, - ProductId1 = productId, - ProductId2 = x.ProductId2, - Product2Name = (await productService.GetProductById(x.ProductId2))?.Name, - DisplayOrder = x.DisplayOrder - }); - - var gridModel = new DataSourceResult { - Data = relatedProductsModel, - Total = relatedProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RelatedProductUpdate(ProductModel.RelatedProductModel model) - { - var product = await productService.GetProductById(model.ProductId1); - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await productViewModelService.UpdateRelatedProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RelatedProductDelete(ProductModel.RelatedProductModel model) - { - var product = await productService.GetProductById(model.ProductId1); - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await productViewModelService.DeleteRelatedProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task RelatedProductAddPopup(string productId) - { - // No access check here in any of the three original hosts (Admin/Store/Vendor all open this - // popup unconditionally once the Edit permission is satisfied) - only the mutating POST below - // ties access to a specific product. PrepareRelatedProductModel() now reads scope - // internally (Task 9). - var model = await productViewModelService.PrepareRelatedProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RelatedProductAddPopupList(DataSourceRequest command, - ProductModel.AddRelatedProductModel model) - { - if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; - - var (products, totalCount) = - await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RelatedProductAddPopup(ProductModel.AddRelatedProductModel model) - { - var product = await productService.GetProductById(model.ProductId); - // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original - // RelatedProductAddPopup(POST) had no check at all, letting any vendor add related-product - // mappings onto another vendor's product by posting its id - closed here the same way as the - // List/Update/Delete gap above. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await productViewModelService.InsertRelatedProductModel(model); - return Content(""); - } - - return await InvalidRelatedProductAddPopupResult(model); - } - - /// Hook for the host-specific invalid-model-state response of the AddPopup(POST) action - /// above. Admin and Store both re-prepare the popup model and return the View; Vendor instead - /// returns Content(ModelState.GetErrors()) - a Vendor-only extension method that AdminShared cannot - /// reference. Default here matches Admin/Store; a future Vendor subclass overrides it once hosts are - /// subclassed onto BaseProductController (Task 11). - protected virtual async Task InvalidRelatedProductAddPopupResult(ProductModel.AddRelatedProductModel model) - { - Error(ModelState); - model = await productViewModelService.PrepareRelatedProductModel(); - return View(model); - } - - #endregion - - #region Similar products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task SimilarProductList(DataSourceRequest command, string productId) - { - var product = await productService.GetProductById(productId); - - // CanView on this read: a product shared with other stores opens read-only on Store, so its - // tabs must load; the mutating actions below keep the strict HasAccess. History: - // HasAccess (strict) has the same shape as "Related products" above - mirrors Store's - // CanAccessProduct check on this action. Applying it uniformly also closes a real gap: Vendor's - // original SimilarProductUpdate/Delete/AddPopup(GET/POST) (below) had no ownership check at all - - // only List checked (via CheckAccessToProduct) - letting any vendor mutate another vendor's - // similar-product mappings by id. - if (!await scope.CanView(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var similarProducts = product.SimilarProducts.OrderBy(x => x.DisplayOrder); - var similarProductsModel = new List(); - foreach (var x in similarProducts) - similarProductsModel.Add(new ProductModel.SimilarProductModel { - Id = x.Id, - ProductId1 = productId, - ProductId2 = x.ProductId2, - Product2Name = (await productService.GetProductById(x.ProductId2))?.Name, - DisplayOrder = x.DisplayOrder - }); - - var gridModel = new DataSourceResult { - Data = similarProductsModel, - Total = similarProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SimilarProductUpdate(ProductModel.SimilarProductModel model) - { - var product = await productService.GetProductById(model.ProductId1); - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await productViewModelService.UpdateSimilarProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SimilarProductDelete(ProductModel.SimilarProductModel model) - { - var product = await productService.GetProductById(model.ProductId1); - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await productViewModelService.DeleteSimilarProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task SimilarProductAddPopup(string productId) - { - // No access check here in any of the three original hosts (Admin/Store/Vendor all open this - // popup unconditionally once the Edit permission is satisfied) - only the mutating POST below - // ties access to a specific product. PrepareSimilarProductModel() now reads scope - // internally (Task 9). - var model = await productViewModelService.PrepareSimilarProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SimilarProductAddPopupList(DataSourceRequest command, - ProductModel.AddSimilarProductModel model) - { - if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; - - var (products, totalCount) = - await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SimilarProductAddPopup(ProductModel.AddSimilarProductModel model) - { - var product = await productService.GetProductById(model.ProductId); - // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original - // SimilarProductAddPopup(POST) had no check at all, letting any vendor add similar-product - // mappings onto another vendor's product by posting its id - closed here the same way as the - // List/Update/Delete gap above. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await productViewModelService.InsertSimilarProductModel(model); - return Content(""); - } - - return await InvalidSimilarProductAddPopupResult(model); - } - - /// Hook for the host-specific invalid-model-state response of the AddPopup(POST) action - /// above. Admin and Store both re-prepare the popup model and return the View; Vendor instead - /// returns Content(ModelState.GetErrors()) - a Vendor-only extension method that AdminShared cannot - /// reference. Default here matches Admin/Store; a future Vendor subclass overrides it once hosts are - /// subclassed onto BaseProductController (Task 11). - protected virtual async Task InvalidSimilarProductAddPopupResult(ProductModel.AddSimilarProductModel model) - { - Error(ModelState); - model = await productViewModelService.PrepareSimilarProductModel(); - return View(model); - } - - #endregion - #region Bundle products [PermissionAuthorizeAction(PermissionActionName.Preview)] @@ -768,254 +497,6 @@ protected virtual async Task InvalidBundleProductAddPopupResult(P #endregion - #region Cross-sell products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task CrossSellProductList(DataSourceRequest command, string productId) - { - var product = await productService.GetProductById(productId); - - // CanView on this read: a product shared with other stores opens read-only on Store, so its - // tabs must load; the mutating actions below keep the strict HasAccess. History: - // HasAccess (strict) has the same shape as "Related products"/"Bundle products" above - - // mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct gating this action on both - // hosts. Admin's original CrossSellProductList had no check at all - applying HasAccess uniformly - // also closes that gap without changing Admin's superuser behaviour (HasAccess is a no-op for - // Admin's scope). - if (!await scope.CanView(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var crossSellProducts = product.CrossSellProduct; - var crossSellProductsModel = new List(); - foreach (var x in crossSellProducts) - crossSellProductsModel.Add(new ProductModel.CrossSellProductModel { - Id = x, - ProductId = product.Id, - Product2Name = (await productService.GetProductById(x))?.Name - }); - var gridModel = new DataSourceResult { - Data = crossSellProductsModel, - Total = crossSellProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task CrossSellProductDelete(ProductModel.CrossSellProductModel model) - { - var product = await productService.GetProductById(model.ProductId); - if (product == null) throw new ArgumentException("Product not exists"); - - // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original - // CrossSellProductDelete had no check at all, letting any vendor delete another vendor's - // cross-sell-product mappings by id - closed here the same way as the List gap above. - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var crossSellProduct = product.CrossSellProduct.FirstOrDefault(x => x == model.Id); - if (string.IsNullOrEmpty(crossSellProduct)) - throw new ArgumentException("No cross-sell product found with the specified id"); - - if (ModelState.IsValid) - { - await productViewModelService.DeleteCrossSellProduct(product.Id, crossSellProduct); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task CrossSellProductAddPopup(string productId) - { - // No access check here in any of the three original hosts (Admin/Store/Vendor all open this - // popup unconditionally once the Edit permission is satisfied) - only the mutating POST below - // ties access to a specific product. PrepareCrossSellProductModel() now reads scope - // internally (Task 9). - var model = await productViewModelService.PrepareCrossSellProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task CrossSellProductAddPopupList(DataSourceRequest command, - ProductModel.AddCrossSellProductModel model) - { - if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; - - var (products, totalCount) = - await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task CrossSellProductAddPopup(ProductModel.AddCrossSellProductModel model) - { - var product = await productService.GetProductById(model.ProductId); - // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original - // CrossSellProductAddPopup(POST) had no check at all, letting any vendor add cross-sell-product - // mappings onto another vendor's product by posting its id - closed here the same way as the - // List/Delete gap above. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await productViewModelService.InsertCrossSellProductModel(model); - return Content(""); - } - - return await InvalidCrossSellProductAddPopupResult(model); - } - - /// Hook for the host-specific invalid-model-state response of the AddPopup(POST) action - /// above. Admin and Store both re-prepare the popup model and return the View; Vendor instead - /// returns Content(ModelState.GetErrors()) - a Vendor-only extension method that AdminShared cannot - /// reference. Default here matches Admin/Store; a future Vendor subclass overrides it once hosts are - /// subclassed onto BaseProductController (Task 11). - protected virtual async Task InvalidCrossSellProductAddPopupResult(ProductModel.AddCrossSellProductModel model) - { - Error(ModelState); - model = await productViewModelService.PrepareCrossSellProductModel(); - return View(model); - } - - #endregion - - #region Recommended products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task RecommendedProductList(DataSourceRequest command, string productId) - { - var product = await productService.GetProductById(productId); - - // CanView on this read: a product shared with other stores opens read-only on Store, so its - // tabs must load; the mutating actions below keep the strict HasAccess. History: - // HasAccess (strict) has the same shape as "Cross-sell products" above - mirrors Store's - // CanAccessProduct and Vendor's CheckAccessToProduct gating this action on both hosts. Admin's - // original RecommendedProductList had no check at all - applying HasAccess uniformly also closes - // that gap without changing Admin's superuser behaviour (HasAccess is a no-op for Admin's scope). - // Vendor's original signature also dropped the DataSourceRequest command parameter entirely - // (unused by the body on any host either way) - kept here for parity with Admin/Store. - if (!await scope.CanView(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var recommendedProductsModel = new List(); - foreach (var x in product.RecommendedProduct) - recommendedProductsModel.Add(new ProductModel.RecommendedProductModel { - Id = x, - ProductId = product.Id, - Product2Name = (await productService.GetProductById(x))?.Name - }); - var gridModel = new DataSourceResult { - Data = recommendedProductsModel, - Total = recommendedProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RecommendedProductDelete(ProductModel.RecommendedProductModel model) - { - var product = await productService.GetProductById(model.ProductId); - if (product == null) throw new ArgumentException("Product not exists"); - - // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original - // RecommendedProductDelete had no check at all, letting any vendor delete another vendor's - // recommended-product mappings by id - closed here the same way as the List gap above. - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var recommendedProduct = product.RecommendedProduct.FirstOrDefault(x => x == model.Id); - if (string.IsNullOrEmpty(recommendedProduct)) - throw new ArgumentException("No recommended product found with the specified id"); - - if (ModelState.IsValid) - { - await productViewModelService.DeleteRecommendedProduct(product.Id, recommendedProduct); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task RecommendedProductAddPopup(string productId) - { - // No access check here in any of the three original hosts (Admin/Store/Vendor all open this - // popup unconditionally once the Edit permission is satisfied) - only the mutating POST below - // ties access to a specific product. PrepareRecommendedProductModel() now reads scope - // internally (Task 9). - var model = await productViewModelService.PrepareRecommendedProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RecommendedProductAddPopupList(DataSourceRequest command, - ProductModel.AddRecommendedProductModel model) - { - if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; - - var (products, totalCount) = - await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RecommendedProductAddPopup(ProductModel.AddRecommendedProductModel model) - { - var product = await productService.GetProductById(model.ProductId); - // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original - // RecommendedProductAddPopup(POST) had no check at all, letting any vendor add recommended-product - // mappings onto another vendor's product by posting its id - closed here the same way as the - // List/Delete gap above. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await productViewModelService.InsertRecommendedProductModel(model); - return Content(""); - } - - return await InvalidRecommendedProductAddPopupResult(model); - } - - /// Hook for the host-specific invalid-model-state response of the AddPopup(POST) action - /// above. Admin and Store both re-prepare the popup model and return the View; Vendor instead - /// returns Content(ModelState.GetErrors()) - a Vendor-only extension method that AdminShared cannot - /// reference. Default here matches Admin/Store; a future Vendor subclass overrides it once hosts are - /// subclassed onto BaseProductController (Task 11). - protected virtual async Task InvalidRecommendedProductAddPopupResult(ProductModel.AddRecommendedProductModel model) - { - Error(ModelState); - model = await productViewModelService.PrepareRecommendedProductModel(); - return View(model); - } - - #endregion - #region Associated products [PermissionAuthorizeAction(PermissionActionName.Preview)] diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductRelatedController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductRelatedController.cs new file mode 100644 index 000000000..f50e7024b --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductRelatedController.cs @@ -0,0 +1,543 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Domain.Permissions; +using Grand.SharedKernel.Extensions; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.AdminShared.Controllers; + +[PermissionAuthorize(PermissionSystemName.Products)] +[AutoValidateAntiforgeryToken] +public abstract class BaseProductRelatedController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseController +{ + #region Related products + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task RelatedProductList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // CanView on this read: a product shared with other stores opens read-only on Store, so its + // tabs must load; the mutating actions below keep the strict HasAccess. History: + // HasAccess (strict) has the same shape as "Product categories"/"Product collections" - + // mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct gating this action on both + // hosts. Applying it uniformly also closes the same kind of gap found in those two regions: + // Vendor's original RelatedProductUpdate/Delete/AddPopup(POST) (below) had no ownership check at + // all - only List checked - letting any vendor mutate another vendor's related-product mappings + // by id. + if (!await scope.CanView(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var relatedProducts = product.RelatedProducts.OrderBy(x => x.DisplayOrder); + var relatedProductsModel = new List(); + foreach (var x in relatedProducts) + relatedProductsModel.Add(new ProductModel.RelatedProductModel { + Id = x.Id, + ProductId1 = productId, + ProductId2 = x.ProductId2, + Product2Name = (await productService.GetProductById(x.ProductId2))?.Name, + DisplayOrder = x.DisplayOrder + }); + + var gridModel = new DataSourceResult { + Data = relatedProductsModel, + Total = relatedProductsModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task RelatedProductUpdate(ProductModel.RelatedProductModel model) + { + var product = await productService.GetProductById(model.ProductId1); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.UpdateRelatedProductModel(model); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task RelatedProductDelete(ProductModel.RelatedProductModel model) + { + var product = await productService.GetProductById(model.ProductId1); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.DeleteRelatedProductModel(model); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task RelatedProductAddPopup(string productId) + { + // No access check here in any of the three original hosts (Admin/Store/Vendor all open this + // popup unconditionally once the Edit permission is satisfied) - only the mutating POST below + // ties access to a specific product. PrepareRelatedProductModel() now reads scope + // internally (Task 9). + var model = await productViewModelService.PrepareRelatedProductModel(); + model.ProductId = productId; + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task RelatedProductAddPopupList(DataSourceRequest command, + ProductModel.AddRelatedProductModel model) + { + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + + var (products, totalCount) = + await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = products.ToList(), + Total = totalCount + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task RelatedProductAddPopup(ProductModel.AddRelatedProductModel model) + { + var product = await productService.GetProductById(model.ProductId); + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // RelatedProductAddPopup(POST) had no check at all, letting any vendor add related-product + // mappings onto another vendor's product by posting its id - closed here the same way as the + // List/Update/Delete gap above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + if (model.SelectedProductIds != null) await productViewModelService.InsertRelatedProductModel(model); + return Content(""); + } + + return await InvalidRelatedProductAddPopupResult(model); + } + + /// Hook for the host-specific invalid-model-state response of the AddPopup(POST) action + /// above. Admin and Store both re-prepare the popup model and return the View; Vendor instead + /// returns Content(ModelState.GetErrors()) - a Vendor-only extension method that AdminShared cannot + /// reference. Default here matches Admin/Store; a future Vendor subclass overrides it once hosts are + /// subclassed onto BaseProductController (Task 11). + protected virtual async Task InvalidRelatedProductAddPopupResult(ProductModel.AddRelatedProductModel model) + { + Error(ModelState); + model = await productViewModelService.PrepareRelatedProductModel(); + return View(model); + } + + #endregion + + #region Similar products + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task SimilarProductList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // CanView on this read: a product shared with other stores opens read-only on Store, so its + // tabs must load; the mutating actions below keep the strict HasAccess. History: + // HasAccess (strict) has the same shape as "Related products" above - mirrors Store's + // CanAccessProduct check on this action. Applying it uniformly also closes a real gap: Vendor's + // original SimilarProductUpdate/Delete/AddPopup(GET/POST) (below) had no ownership check at all - + // only List checked (via CheckAccessToProduct) - letting any vendor mutate another vendor's + // similar-product mappings by id. + if (!await scope.CanView(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var similarProducts = product.SimilarProducts.OrderBy(x => x.DisplayOrder); + var similarProductsModel = new List(); + foreach (var x in similarProducts) + similarProductsModel.Add(new ProductModel.SimilarProductModel { + Id = x.Id, + ProductId1 = productId, + ProductId2 = x.ProductId2, + Product2Name = (await productService.GetProductById(x.ProductId2))?.Name, + DisplayOrder = x.DisplayOrder + }); + + var gridModel = new DataSourceResult { + Data = similarProductsModel, + Total = similarProductsModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task SimilarProductUpdate(ProductModel.SimilarProductModel model) + { + var product = await productService.GetProductById(model.ProductId1); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.UpdateSimilarProductModel(model); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task SimilarProductDelete(ProductModel.SimilarProductModel model) + { + var product = await productService.GetProductById(model.ProductId1); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.DeleteSimilarProductModel(model); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task SimilarProductAddPopup(string productId) + { + // No access check here in any of the three original hosts (Admin/Store/Vendor all open this + // popup unconditionally once the Edit permission is satisfied) - only the mutating POST below + // ties access to a specific product. PrepareSimilarProductModel() now reads scope + // internally (Task 9). + var model = await productViewModelService.PrepareSimilarProductModel(); + model.ProductId = productId; + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task SimilarProductAddPopupList(DataSourceRequest command, + ProductModel.AddSimilarProductModel model) + { + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + + var (products, totalCount) = + await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = products.ToList(), + Total = totalCount + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task SimilarProductAddPopup(ProductModel.AddSimilarProductModel model) + { + var product = await productService.GetProductById(model.ProductId); + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // SimilarProductAddPopup(POST) had no check at all, letting any vendor add similar-product + // mappings onto another vendor's product by posting its id - closed here the same way as the + // List/Update/Delete gap above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + if (model.SelectedProductIds != null) await productViewModelService.InsertSimilarProductModel(model); + return Content(""); + } + + return await InvalidSimilarProductAddPopupResult(model); + } + + /// Hook for the host-specific invalid-model-state response of the AddPopup(POST) action + /// above. Admin and Store both re-prepare the popup model and return the View; Vendor instead + /// returns Content(ModelState.GetErrors()) - a Vendor-only extension method that AdminShared cannot + /// reference. Default here matches Admin/Store; a future Vendor subclass overrides it once hosts are + /// subclassed onto BaseProductController (Task 11). + protected virtual async Task InvalidSimilarProductAddPopupResult(ProductModel.AddSimilarProductModel model) + { + Error(ModelState); + model = await productViewModelService.PrepareSimilarProductModel(); + return View(model); + } + + #endregion + + #region Cross-sell products + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task CrossSellProductList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // CanView on this read: a product shared with other stores opens read-only on Store, so its + // tabs must load; the mutating actions below keep the strict HasAccess. History: + // HasAccess (strict) has the same shape as "Related products"/"Bundle products" above - + // mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct gating this action on both + // hosts. Admin's original CrossSellProductList had no check at all - applying HasAccess uniformly + // also closes that gap without changing Admin's superuser behaviour (HasAccess is a no-op for + // Admin's scope). + if (!await scope.CanView(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var crossSellProducts = product.CrossSellProduct; + var crossSellProductsModel = new List(); + foreach (var x in crossSellProducts) + crossSellProductsModel.Add(new ProductModel.CrossSellProductModel { + Id = x, + ProductId = product.Id, + Product2Name = (await productService.GetProductById(x))?.Name + }); + var gridModel = new DataSourceResult { + Data = crossSellProductsModel, + Total = crossSellProductsModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task CrossSellProductDelete(ProductModel.CrossSellProductModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) throw new ArgumentException("Product not exists"); + + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // CrossSellProductDelete had no check at all, letting any vendor delete another vendor's + // cross-sell-product mappings by id - closed here the same way as the List gap above. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var crossSellProduct = product.CrossSellProduct.FirstOrDefault(x => x == model.Id); + if (string.IsNullOrEmpty(crossSellProduct)) + throw new ArgumentException("No cross-sell product found with the specified id"); + + if (ModelState.IsValid) + { + await productViewModelService.DeleteCrossSellProduct(product.Id, crossSellProduct); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task CrossSellProductAddPopup(string productId) + { + // No access check here in any of the three original hosts (Admin/Store/Vendor all open this + // popup unconditionally once the Edit permission is satisfied) - only the mutating POST below + // ties access to a specific product. PrepareCrossSellProductModel() now reads scope + // internally (Task 9). + var model = await productViewModelService.PrepareCrossSellProductModel(); + model.ProductId = productId; + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task CrossSellProductAddPopupList(DataSourceRequest command, + ProductModel.AddCrossSellProductModel model) + { + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + + var (products, totalCount) = + await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = products.ToList(), + Total = totalCount + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task CrossSellProductAddPopup(ProductModel.AddCrossSellProductModel model) + { + var product = await productService.GetProductById(model.ProductId); + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // CrossSellProductAddPopup(POST) had no check at all, letting any vendor add cross-sell-product + // mappings onto another vendor's product by posting its id - closed here the same way as the + // List/Delete gap above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + if (model.SelectedProductIds != null) await productViewModelService.InsertCrossSellProductModel(model); + return Content(""); + } + + return await InvalidCrossSellProductAddPopupResult(model); + } + + /// Hook for the host-specific invalid-model-state response of the AddPopup(POST) action + /// above. Admin and Store both re-prepare the popup model and return the View; Vendor instead + /// returns Content(ModelState.GetErrors()) - a Vendor-only extension method that AdminShared cannot + /// reference. Default here matches Admin/Store; a future Vendor subclass overrides it once hosts are + /// subclassed onto BaseProductController (Task 11). + protected virtual async Task InvalidCrossSellProductAddPopupResult(ProductModel.AddCrossSellProductModel model) + { + Error(ModelState); + model = await productViewModelService.PrepareCrossSellProductModel(); + return View(model); + } + + #endregion + + #region Recommended products + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task RecommendedProductList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // CanView on this read: a product shared with other stores opens read-only on Store, so its + // tabs must load; the mutating actions below keep the strict HasAccess. History: + // HasAccess (strict) has the same shape as "Cross-sell products" above - mirrors Store's + // CanAccessProduct and Vendor's CheckAccessToProduct gating this action on both hosts. Admin's + // original RecommendedProductList had no check at all - applying HasAccess uniformly also closes + // that gap without changing Admin's superuser behaviour (HasAccess is a no-op for Admin's scope). + // Vendor's original signature also dropped the DataSourceRequest command parameter entirely + // (unused by the body on any host either way) - kept here for parity with Admin/Store. + if (!await scope.CanView(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var recommendedProductsModel = new List(); + foreach (var x in product.RecommendedProduct) + recommendedProductsModel.Add(new ProductModel.RecommendedProductModel { + Id = x, + ProductId = product.Id, + Product2Name = (await productService.GetProductById(x))?.Name + }); + var gridModel = new DataSourceResult { + Data = recommendedProductsModel, + Total = recommendedProductsModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task RecommendedProductDelete(ProductModel.RecommendedProductModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) throw new ArgumentException("Product not exists"); + + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // RecommendedProductDelete had no check at all, letting any vendor delete another vendor's + // recommended-product mappings by id - closed here the same way as the List gap above. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var recommendedProduct = product.RecommendedProduct.FirstOrDefault(x => x == model.Id); + if (string.IsNullOrEmpty(recommendedProduct)) + throw new ArgumentException("No recommended product found with the specified id"); + + if (ModelState.IsValid) + { + await productViewModelService.DeleteRecommendedProduct(product.Id, recommendedProduct); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task RecommendedProductAddPopup(string productId) + { + // No access check here in any of the three original hosts (Admin/Store/Vendor all open this + // popup unconditionally once the Edit permission is satisfied) - only the mutating POST below + // ties access to a specific product. PrepareRecommendedProductModel() now reads scope + // internally (Task 9). + var model = await productViewModelService.PrepareRecommendedProductModel(); + model.ProductId = productId; + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task RecommendedProductAddPopupList(DataSourceRequest command, + ProductModel.AddRecommendedProductModel model) + { + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + + var (products, totalCount) = + await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = products.ToList(), + Total = totalCount + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task RecommendedProductAddPopup(ProductModel.AddRecommendedProductModel model) + { + var product = await productService.GetProductById(model.ProductId); + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // RecommendedProductAddPopup(POST) had no check at all, letting any vendor add recommended-product + // mappings onto another vendor's product by posting its id - closed here the same way as the + // List/Delete gap above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + if (model.SelectedProductIds != null) await productViewModelService.InsertRecommendedProductModel(model); + return Content(""); + } + + return await InvalidRecommendedProductAddPopupResult(model); + } + + /// Hook for the host-specific invalid-model-state response of the AddPopup(POST) action + /// above. Admin and Store both re-prepare the popup model and return the View; Vendor instead + /// returns Content(ModelState.GetErrors()) - a Vendor-only extension method that AdminShared cannot + /// reference. Default here matches Admin/Store; a future Vendor subclass overrides it once hosts are + /// subclassed onto BaseProductController (Task 11). + protected virtual async Task InvalidRecommendedProductAddPopupResult(ProductModel.AddRecommendedProductModel model) + { + Error(ModelState); + model = await productViewModelService.PrepareRecommendedProductModel(); + return View(model); + } + + #endregion +} diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.CrossSells.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.CrossSells.cshtml index 4bb2c4de4..195021ed7 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.CrossSells.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.CrossSells.cshtml @@ -12,15 +12,15 @@

@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.CrossSells"]

@if (!readOnly) { - @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.CrossSells.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.CrossSells.AddNew"] }
diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Recommended.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Recommended.cshtml index e60a4db73..b1483ae2d 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Recommended.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Recommended.cshtml @@ -12,15 +12,15 @@

@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Recommended"]

@if (!readOnly) { - @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Recommended.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Recommended.AddNew"] }
diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.RelatedProducts.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.RelatedProducts.cshtml index c8c1cea30..a5bdfc9d3 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.RelatedProducts.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.RelatedProducts.cshtml @@ -12,16 +12,16 @@

@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.RelatedProducts"]

@if (!readOnly) { - @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.RelatedProducts.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.RelatedProducts.AddNew"] }
diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.SimilarProducts.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.SimilarProducts.cshtml index 815c6081f..1dc3be56b 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.SimilarProducts.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.SimilarProducts.cshtml @@ -13,16 +13,16 @@

@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.SimilarProducts"]

@if (!readOnly) { - @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.SimilarProducts.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.SimilarProducts.AddNew"] }
diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/CrossSellProductAddPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductRelated/CrossSellProductAddPopup.cshtml similarity index 97% rename from src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/CrossSellProductAddPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductRelated/CrossSellProductAddPopup.cshtml index b4aa4c275..ae1b8096d 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/CrossSellProductAddPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductRelated/CrossSellProductAddPopup.cshtml @@ -5,7 +5,7 @@ //page title ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.CrossSells.AddNew"]; } - @@ -28,7 +28,7 @@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/RecommendedProductAddPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductRelated/RecommendedProductAddPopup.cshtml similarity index 97% rename from src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/RecommendedProductAddPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductRelated/RecommendedProductAddPopup.cshtml index 42e13b008..018b9b8e9 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/RecommendedProductAddPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductRelated/RecommendedProductAddPopup.cshtml @@ -5,7 +5,7 @@ //page title ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Recommended.AddNew"]; } - @@ -26,7 +26,7 @@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/RelatedProductAddPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductRelated/RelatedProductAddPopup.cshtml similarity index 97% rename from src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/RelatedProductAddPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductRelated/RelatedProductAddPopup.cshtml index ad032dbce..a02ddb70b 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/RelatedProductAddPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductRelated/RelatedProductAddPopup.cshtml @@ -5,7 +5,7 @@ //page title ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.RelatedProducts.AddNew"]; } - @@ -28,7 +28,7 @@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/SimilarProductAddPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductRelated/SimilarProductAddPopup.cshtml similarity index 97% rename from src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/SimilarProductAddPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductRelated/SimilarProductAddPopup.cshtml index cf64762e3..e31f0d029 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/SimilarProductAddPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductRelated/SimilarProductAddPopup.cshtml @@ -5,7 +5,7 @@ //page title ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.SimilarProducts.AddNew"]; } - @@ -26,7 +26,7 @@ diff --git a/src/Web/Grand.Web.Store/Controllers/ProductRelatedController.cs b/src/Web/Grand.Web.Store/Controllers/ProductRelatedController.cs new file mode 100644 index 000000000..08f465227 --- /dev/null +++ b/src/Web/Grand.Web.Store/Controllers/ProductRelatedController.cs @@ -0,0 +1,21 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Store.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Store.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaStore)] +[AuthorizeStore] +[AuthorizeMenu] +public class ProductRelatedController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductRelatedController(productViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductController.cs index 62dc8ae26..154ea356b 100644 --- a/src/Web/Grand.Web.Vendor/Controllers/ProductController.cs +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductController.cs @@ -43,18 +43,6 @@ public class ProductController( // Vendor's original AddPopup(POST) actions returned Content(ModelState.GetErrors()) on an invalid // model, instead of Admin/Store's re-prepare-and-View. Overriding the base's Admin/Store default, // per each hook's own doc comment. - protected override Task InvalidRelatedProductAddPopupResult(ProductModel.AddRelatedProductModel model) - => Task.FromResult(Content(ModelState.GetErrors())); - - protected override Task InvalidSimilarProductAddPopupResult(ProductModel.AddSimilarProductModel model) - => Task.FromResult(Content(ModelState.GetErrors())); - protected override Task InvalidBundleProductAddPopupResult(ProductModel.AddBundleProductModel model) => Task.FromResult(Content(ModelState.GetErrors())); - - protected override Task InvalidCrossSellProductAddPopupResult(ProductModel.AddCrossSellProductModel model) - => Task.FromResult(Content(ModelState.GetErrors())); - - protected override Task InvalidRecommendedProductAddPopupResult(ProductModel.AddRecommendedProductModel model) - => Task.FromResult(Content(ModelState.GetErrors())); } diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductRelatedController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductRelatedController.cs new file mode 100644 index 000000000..a5e8cd422 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductRelatedController.cs @@ -0,0 +1,38 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Vendor.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Vendor.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaVendor)] +[AuthorizeVendor] +[AuthorizeMenu] +public class ProductRelatedController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductRelatedController(productViewModelService, productService, translationService, scope) +{ + // Vendor's original AddPopup(POST) actions returned Content(ModelState.GetErrors()) on an invalid + // model, instead of Admin/Store's re-prepare-and-View. Overriding the base's Admin/Store default, + // per each hook's own doc comment. + protected override Task InvalidRelatedProductAddPopupResult(ProductModel.AddRelatedProductModel model) + => Task.FromResult(Content(ModelState.GetErrors())); + + protected override Task InvalidSimilarProductAddPopupResult(ProductModel.AddSimilarProductModel model) + => Task.FromResult(Content(ModelState.GetErrors())); + + protected override Task InvalidCrossSellProductAddPopupResult(ProductModel.AddCrossSellProductModel model) + => Task.FromResult(Content(ModelState.GetErrors())); + + protected override Task InvalidRecommendedProductAddPopupResult(ProductModel.AddRecommendedProductModel model) + => Task.FromResult(Content(ModelState.GetErrors())); +} From 6a3801f4ebd02642ccd1400f44faa8e493bff543 Mon Sep 17 00:00:00 2001 From: support Date: Thu, 22 Oct 2026 06:34:47 +0200 Subject: [PATCH 08/27] Move the bundle and associated products tabs into their own controllers --- .../BaseProductAssociatedControllerTests.cs | 309 ++++++++++++ .../BaseProductBundleControllerTests.cs | 239 +++++++++ .../Controllers/BaseProductControllerTests.cs | 475 ------------------ .../ProductAssociatedController.cs | 21 + .../Controllers/ProductBundleController.cs | 21 + .../BaseProductAssociatedController.cs | 205 ++++++++ .../BaseProductBundleController.cs | 159 ++++++ .../Controllers/BaseProductController.cs | 314 ------------ .../CreateOrUpdate.AssociatedProducts.cshtml | 8 +- .../CreateOrUpdate.BundleProducts.cshtml | 8 +- .../AssociatedProductAddPopup.cshtml | 4 +- .../BundleProductAddPopup.cshtml | 4 +- .../ProductAssociatedController.cs | 21 + .../Controllers/ProductBundleController.cs | 21 + .../ProductAssociatedController.cs | 29 ++ .../Controllers/ProductBundleController.cs | 29 ++ .../Controllers/ProductController.cs | 19 +- 17 files changed, 1068 insertions(+), 818 deletions(-) create mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAssociatedControllerTests.cs create mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductBundleControllerTests.cs create mode 100644 src/Web/Grand.Web.Admin/Controllers/ProductAssociatedController.cs create mode 100644 src/Web/Grand.Web.Admin/Controllers/ProductBundleController.cs create mode 100644 src/Web/Grand.Web.AdminShared/Controllers/BaseProductAssociatedController.cs create mode 100644 src/Web/Grand.Web.AdminShared/Controllers/BaseProductBundleController.cs rename src/Web/Grand.Web.AdminShared/Views/AdminShared/{Product => ProductAssociated}/AssociatedProductAddPopup.cshtml (97%) rename src/Web/Grand.Web.AdminShared/Views/AdminShared/{Product => ProductBundle}/BundleProductAddPopup.cshtml (98%) create mode 100644 src/Web/Grand.Web.Store/Controllers/ProductAssociatedController.cs create mode 100644 src/Web/Grand.Web.Store/Controllers/ProductBundleController.cs create mode 100644 src/Web/Grand.Web.Vendor/Controllers/ProductAssociatedController.cs create mode 100644 src/Web/Grand.Web.Vendor/Controllers/ProductBundleController.cs diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAssociatedControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAssociatedControllerTests.cs new file mode 100644 index 000000000..911787717 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAssociatedControllerTests.cs @@ -0,0 +1,309 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Utilities.Catalog; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Linq.Expressions; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class BaseProductAssociatedControllerTests : ProductControllerTestsBase +{ + // BaseProductAssociatedController is abstract. This minimal subclass exists only so the actions under test + // can be invoked directly. + public class TestProductAssociatedController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductAssociatedController(productViewModelService, productService, translationService, scope); + + protected override TestProductAssociatedController CreateController() => new( + _productViewModelServiceMock.Object, + _productServiceMock.Object, + _translationServiceMock.Object, + _scopeMock.Object); + + // --- AssociatedProductList ----------------------------------------------------------------------- + // HasAccess (strict), not CanView: same shape as RelatedProductList above. Admin's original had no + // check at all. + + [TestMethod] + public async Task AssociatedProductList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + + var result = await _controller.AssociatedProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task AssociatedProductList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + _productServiceMock.Setup(p => p.GetAssociatedProducts("p1", CatalogAccess.IncludingHidden(), "")) + .ReturnsAsync(new List { new() { Id = "a1", Name = "Assoc", DisplayOrder = 1 } }); + + var result = await _controller.AssociatedProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- AssociatedProductUpdate --------------------------------------------------------------------- + + [TestMethod] + public async Task AssociatedProductUpdate_NotFound_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync((Product)null); + var model = new ProductModel.AssociatedProductModel { Id = "a1" }; + + await Assert.ThrowsExactlyAsync(() => _controller.AssociatedProductUpdate(model)); + } + + [TestMethod] + public async Task AssociatedProductUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() + { + var associatedProduct = new Product { Id = "a1" }; + _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(associatedProduct); + _scopeMock.Setup(s => s.HasAccess(associatedProduct)).ReturnsAsync(false); + var model = new ProductModel.AssociatedProductModel { Id = "a1" }; + + var result = await _controller.AssociatedProductUpdate(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productServiceMock.Verify(p => p.UpdateAssociatedProduct(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task AssociatedProductUpdate_ScopeGrantsAccess_ValidModel_Updates() + { + var associatedProduct = new Product { Id = "a1" }; + _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(associatedProduct); + _scopeMock.Setup(s => s.HasAccess(associatedProduct)).ReturnsAsync(true); + var model = new ProductModel.AssociatedProductModel { Id = "a1", DisplayOrder = 5 }; + + var result = await _controller.AssociatedProductUpdate(model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual(5, associatedProduct.DisplayOrder); + _productServiceMock.Verify(p => p.UpdateAssociatedProduct(associatedProduct), Times.Once); + } + + // --- AssociatedProductDelete --------------------------------------------------------------------- + + [TestMethod] + public async Task AssociatedProductDelete_NotFound_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync((Product)null); + var model = new ProductModel.AssociatedProductModel { Id = "a1" }; + + await Assert.ThrowsExactlyAsync(() => _controller.AssociatedProductDelete(model)); + } + + [TestMethod] + public async Task AssociatedProductDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "a1" }; + _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.AssociatedProductModel { Id = "a1" }; + + var result = await _controller.AssociatedProductDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.DeleteAssociatedProduct(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task AssociatedProductDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "a1" }; + _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.AssociatedProductModel { Id = "a1" }; + + var result = await _controller.AssociatedProductDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteAssociatedProduct(product), Times.Once); + } + + // --- AssociatedProductAddPopup (GET) ------------------------------------------------------------- + + [TestMethod] + public async Task AssociatedProductAddPopupGet_CallsPrepareAssociatedProductModel() + { + // PrepareAssociatedProductModel() now reads scope internally (Task 9). + _productViewModelServiceMock.Setup(s => s.PrepareAssociatedProductModel()) + .ReturnsAsync(new ProductModel.AddAssociatedProductModel()); + + var result = await _controller.AssociatedProductAddPopup("p1") as ViewResult; + + Assert.IsNotNull(result); + var model = result.Model as ProductModel.AddAssociatedProductModel; + Assert.IsNotNull(model); + Assert.AreEqual("p1", model.ProductId); + _productViewModelServiceMock.Verify(s => s.PrepareAssociatedProductModel(), Times.Once); + } + + // --- AssociatedProductAddPopupList --------------------------------------------------------------- + + [TestMethod] + public async Task AssociatedProductAddPopupList_UsesScopeDefaultStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddAssociatedProductModel(); + var result = await _controller.AssociatedProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("store-1", model.SearchStoreId); + } + + [TestMethod] + public async Task AssociatedProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddAssociatedProductModel { SearchStoreId = "explicit" }; + var result = await _controller.AssociatedProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("explicit", model.SearchStoreId); + } + + // --- AssociatedProductAddPopup (POST) ------------------------------------------------------------ + // HasAccess (strict) on the parent product: closes a real gap - Vendor's controller had no ownership + // check on the parent product (model.ProductId) at all, in either layer. Vendor's own (host-specific) + // service already filtered each selected product via HasAccessToProduct before reparenting it + // (Grand.Web.Vendor/Services/ProductViewModelService.cs InsertAssociatedProductModel), but the parent + // was never checked anywhere, letting a vendor attach their own products under another vendor's + // grouped product. This controller-level fix (parent HasAccess + per-selected-id HasAccess, matching + // Store's original controller-level filtering) is necessary regardless of that pre-existing severity, + // since BaseProductController uses AdminShared's unfiltered IProductViewModelService - once Vendor is + // subclassed onto this base (Task 11), it loses its own service's per-id filter entirely, so the + // controller must enforce both checks itself. + + [TestMethod] + public async Task AssociatedProductAddPopupPost_ScopeDeniesAccessToParent_ReturnsContentMessage_DoesNotInsert() + { + var parent = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(parent); + _scopeMock.Setup(s => s.HasAccess(parent)).ReturnsAsync(false); + var model = new ProductModel.AddAssociatedProductModel { ProductId = "p1", SelectedProductIds = ["a1"] }; + + var result = await _controller.AssociatedProductAddPopup(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.InsertAssociatedProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task AssociatedProductAddPopupPost_ScopeGrantsAccess_AllSelectedIdsAllowed_InsertsAll() + { + var parent = new Product { Id = "p1" }; + var selected = new Product { Id = "a1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(parent); + _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(selected); + _scopeMock.Setup(s => s.HasAccess(parent)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(selected)).ReturnsAsync(true); + var model = new ProductModel.AddAssociatedProductModel { ProductId = "p1", SelectedProductIds = ["a1"] }; + + var result = await _controller.AssociatedProductAddPopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("", content.Content); + CollectionAssert.AreEqual(new[] { "a1" }, model.SelectedProductIds); + _productViewModelServiceMock.Verify(s => s.InsertAssociatedProductModel(model), Times.Once); + } + + [TestMethod] + public async Task AssociatedProductAddPopupPost_ScopeGrantsAccessToParent_FiltersOutDeniedSelectedIds() + { + var parent = new Product { Id = "p1" }; + var allowed = new Product { Id = "a1" }; + var denied = new Product { Id = "a2" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(parent); + _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(allowed); + _productServiceMock.Setup(p => p.GetProductById("a2", false)).ReturnsAsync(denied); + _scopeMock.Setup(s => s.HasAccess(parent)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(allowed)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(denied)).ReturnsAsync(false); + var model = new ProductModel.AddAssociatedProductModel { ProductId = "p1", SelectedProductIds = ["a1", "a2"] }; + + var result = await _controller.AssociatedProductAddPopup(model); + + Assert.IsInstanceOfType(result); + CollectionAssert.AreEqual(new[] { "a1" }, model.SelectedProductIds); + _productViewModelServiceMock.Verify(s => s.InsertAssociatedProductModel(model), Times.Once); + } + + [TestMethod] + public async Task AssociatedProductAddPopupPost_AllSelectedIdsDenied_DoesNotInsert() + { + var parent = new Product { Id = "p1" }; + var denied = new Product { Id = "a1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(parent); + _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(denied); + _scopeMock.Setup(s => s.HasAccess(parent)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(denied)).ReturnsAsync(false); + var model = new ProductModel.AddAssociatedProductModel { ProductId = "p1", SelectedProductIds = ["a1"] }; + + var result = await _controller.AssociatedProductAddPopup(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.InsertAssociatedProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task AssociatedProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ReturnsViewWithRepreparedModel() + { + var parent = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(parent); + _scopeMock.Setup(s => s.HasAccess(parent)).ReturnsAsync(true); + var reprepared = new ProductModel.AddAssociatedProductModel(); + _productViewModelServiceMock.Setup(s => s.PrepareAssociatedProductModel()).ReturnsAsync(reprepared); + var model = new ProductModel.AddAssociatedProductModel { ProductId = "p1" }; + _controller.ModelState.AddModelError("x", "error"); + + var result = await _controller.AssociatedProductAddPopup(model); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreSame(reprepared, view.Model); + _productViewModelServiceMock.Verify(s => s.InsertAssociatedProductModel(It.IsAny()), Times.Never); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductBundleControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductBundleControllerTests.cs new file mode 100644 index 000000000..e9fd3fa5d --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductBundleControllerTests.cs @@ -0,0 +1,239 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Linq.Expressions; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class BaseProductBundleControllerTests : ProductControllerTestsBase +{ + // BaseProductBundleController is abstract. This minimal subclass exists only so the actions under test + // can be invoked directly. + public class TestProductBundleController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductBundleController(productViewModelService, productService, translationService, scope); + + protected override TestProductBundleController CreateController() => new( + _productViewModelServiceMock.Object, + _productServiceMock.Object, + _translationServiceMock.Object, + _scopeMock.Object); + + // --- BundleProductList ------------------------------------------------------------------------ + // HasAccess (strict), not CanView: same shape as RelatedProductList/SimilarProductList above. + + [TestMethod] + public async Task BundleProductList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + + var result = await _controller.BundleProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task BundleProductList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + product.BundleProducts.Add(new BundleProduct { Id = "r1", ProductId = "p2", DisplayOrder = 0, Quantity = 3 }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _productServiceMock.Setup(p => p.GetProductById("p2", false)).ReturnsAsync(new Product { Id = "p2", Name = "Second" }); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + + var result = await _controller.BundleProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- BundleProductUpdate ---------------------------------------------------------------------- + + [TestMethod] + public async Task BundleProductUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.BundleProductModel { ProductBundleId = "p1" }; + + var result = await _controller.BundleProductUpdate(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.UpdateBundleProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task BundleProductUpdate_ScopeGrantsAccess_ValidModel_Updates() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.BundleProductModel { ProductBundleId = "p1" }; + + var result = await _controller.BundleProductUpdate(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.UpdateBundleProductModel(model), Times.Once); + } + + // --- BundleProductDelete ---------------------------------------------------------------------- + + [TestMethod] + public async Task BundleProductDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.BundleProductModel { ProductBundleId = "p1" }; + + var result = await _controller.BundleProductDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.DeleteBundleProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task BundleProductDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.BundleProductModel { ProductBundleId = "p1" }; + + var result = await _controller.BundleProductDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteBundleProductModel(model), Times.Once); + } + + // --- BundleProductAddPopup (GET) -------------------------------------------------------------- + + [TestMethod] + public async Task BundleProductAddPopupGet_CallsPrepareBundleProductModel() + { + // PrepareBundleProductModel() now reads scope internally (Task 9). + _productViewModelServiceMock.Setup(s => s.PrepareBundleProductModel()) + .ReturnsAsync(new ProductModel.AddBundleProductModel()); + + var result = await _controller.BundleProductAddPopup("p1") as ViewResult; + + Assert.IsNotNull(result); + var model = result.Model as ProductModel.AddBundleProductModel; + Assert.IsNotNull(model); + Assert.AreEqual("p1", model.ProductId); + _productViewModelServiceMock.Verify(s => s.PrepareBundleProductModel(), Times.Once); + } + + // --- BundleProductAddPopupList ----------------------------------------------------------------- + + [TestMethod] + public async Task BundleProductAddPopupList_UsesScopeDefaultStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddBundleProductModel(); + var result = await _controller.BundleProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("store-1", model.SearchStoreId); + } + + [TestMethod] + public async Task BundleProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddBundleProductModel { SearchStoreId = "explicit" }; + var result = await _controller.BundleProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("explicit", model.SearchStoreId); + } + + // --- BundleProductAddPopup (POST) -------------------------------------------------------------- + // HasAccess (strict): closes a real gap - Vendor's original BundleProductAddPopup(POST) had no + // ownership check at all, letting any vendor attach bundle-product mappings onto another vendor's + // product by posting its id. + + [TestMethod] + public async Task BundleProductAddPopupPost_ScopeDeniesAccess_ReturnsContentMessage_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.AddBundleProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + + var result = await _controller.BundleProductAddPopup(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.InsertBundleProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task BundleProductAddPopupPost_ScopeGrantsAccess_ValidModel_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.AddBundleProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + + var result = await _controller.BundleProductAddPopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("", content.Content); + _productViewModelServiceMock.Verify(s => s.InsertBundleProductModel(model), Times.Once); + } + + [TestMethod] + public async Task BundleProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ReturnsViewWithRepreparedModel() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var reprepared = new ProductModel.AddBundleProductModel(); + _productViewModelServiceMock.Setup(s => s.PrepareBundleProductModel()).ReturnsAsync(reprepared); + var model = new ProductModel.AddBundleProductModel { ProductId = "p1" }; + _controller.ModelState.AddModelError("x", "error"); + + var result = await _controller.BundleProductAddPopup(model); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreSame(reprepared, view.Model); + _productViewModelServiceMock.Verify(s => s.InsertBundleProductModel(It.IsAny()), Times.Never); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index d2fdd84fe..670480ed7 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -560,481 +560,6 @@ public async Task RequiredProductAddPopupList_NoDefaultStoreId_DoesNotOverrideMo Assert.AreEqual("explicit", model.SearchStoreId); } - // --- BundleProductList ------------------------------------------------------------------------ - // HasAccess (strict), not CanView: same shape as RelatedProductList/SimilarProductList above. - - [TestMethod] - public async Task BundleProductList_ScopeDeniesAccess_ReturnsErrorJson() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - - var result = await _controller.BundleProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - } - - [TestMethod] - public async Task BundleProductList_ScopeGrantsAccess_ReturnsGrid() - { - var product = new Product { Id = "p1" }; - product.BundleProducts.Add(new BundleProduct { Id = "r1", ProductId = "p2", DisplayOrder = 0, Quantity = 3 }); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _productServiceMock.Setup(p => p.GetProductById("p2", false)).ReturnsAsync(new Product { Id = "p2", Name = "Second" }); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - - var result = await _controller.BundleProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - var json = result as JsonResult; - Assert.IsNotNull(json); - var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.AreEqual(1, gridModel.Total); - } - - // --- BundleProductUpdate ---------------------------------------------------------------------- - - [TestMethod] - public async Task BundleProductUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.BundleProductModel { ProductBundleId = "p1" }; - - var result = await _controller.BundleProductUpdate(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.UpdateBundleProductModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task BundleProductUpdate_ScopeGrantsAccess_ValidModel_Updates() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.BundleProductModel { ProductBundleId = "p1" }; - - var result = await _controller.BundleProductUpdate(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.UpdateBundleProductModel(model), Times.Once); - } - - // --- BundleProductDelete ---------------------------------------------------------------------- - - [TestMethod] - public async Task BundleProductDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.BundleProductModel { ProductBundleId = "p1" }; - - var result = await _controller.BundleProductDelete(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.DeleteBundleProductModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task BundleProductDelete_ScopeGrantsAccess_ValidModel_Deletes() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.BundleProductModel { ProductBundleId = "p1" }; - - var result = await _controller.BundleProductDelete(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.DeleteBundleProductModel(model), Times.Once); - } - - // --- BundleProductAddPopup (GET) -------------------------------------------------------------- - - [TestMethod] - public async Task BundleProductAddPopupGet_CallsPrepareBundleProductModel() - { - // PrepareBundleProductModel() now reads scope internally (Task 9). - _productViewModelServiceMock.Setup(s => s.PrepareBundleProductModel()) - .ReturnsAsync(new ProductModel.AddBundleProductModel()); - - var result = await _controller.BundleProductAddPopup("p1") as ViewResult; - - Assert.IsNotNull(result); - var model = result.Model as ProductModel.AddBundleProductModel; - Assert.IsNotNull(model); - Assert.AreEqual("p1", model.ProductId); - _productViewModelServiceMock.Verify(s => s.PrepareBundleProductModel(), Times.Once); - } - - // --- BundleProductAddPopupList ----------------------------------------------------------------- - - [TestMethod] - public async Task BundleProductAddPopupList_UsesScopeDefaultStoreId() - { - _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); - _productViewModelServiceMock - .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) - .ReturnsAsync((new List(), 0)); - - var model = new ProductModel.AddBundleProductModel(); - var result = await _controller.BundleProductAddPopupList( - new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); - - Assert.IsInstanceOfType(result); - Assert.AreEqual("store-1", model.SearchStoreId); - } - - [TestMethod] - public async Task BundleProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() - { - _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); - _productViewModelServiceMock - .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) - .ReturnsAsync((new List(), 0)); - - var model = new ProductModel.AddBundleProductModel { SearchStoreId = "explicit" }; - var result = await _controller.BundleProductAddPopupList( - new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); - - Assert.IsInstanceOfType(result); - Assert.AreEqual("explicit", model.SearchStoreId); - } - - // --- BundleProductAddPopup (POST) -------------------------------------------------------------- - // HasAccess (strict): closes a real gap - Vendor's original BundleProductAddPopup(POST) had no - // ownership check at all, letting any vendor attach bundle-product mappings onto another vendor's - // product by posting its id. - - [TestMethod] - public async Task BundleProductAddPopupPost_ScopeDeniesAccess_ReturnsContentMessage_DoesNotInsert() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.AddBundleProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; - - var result = await _controller.BundleProductAddPopup(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.InsertBundleProductModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task BundleProductAddPopupPost_ScopeGrantsAccess_ValidModel_Inserts() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.AddBundleProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; - - var result = await _controller.BundleProductAddPopup(model); - - var content = result as ContentResult; - Assert.IsNotNull(content); - Assert.AreEqual("", content.Content); - _productViewModelServiceMock.Verify(s => s.InsertBundleProductModel(model), Times.Once); - } - - [TestMethod] - public async Task BundleProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ReturnsViewWithRepreparedModel() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var reprepared = new ProductModel.AddBundleProductModel(); - _productViewModelServiceMock.Setup(s => s.PrepareBundleProductModel()).ReturnsAsync(reprepared); - var model = new ProductModel.AddBundleProductModel { ProductId = "p1" }; - _controller.ModelState.AddModelError("x", "error"); - - var result = await _controller.BundleProductAddPopup(model); - - var view = result as ViewResult; - Assert.IsNotNull(view); - Assert.AreSame(reprepared, view.Model); - _productViewModelServiceMock.Verify(s => s.InsertBundleProductModel(It.IsAny()), Times.Never); - } - - // --- AssociatedProductList ----------------------------------------------------------------------- - // HasAccess (strict), not CanView: same shape as RelatedProductList above. Admin's original had no - // check at all. - - [TestMethod] - public async Task AssociatedProductList_ScopeDeniesAccess_ReturnsErrorJson() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - - var result = await _controller.AssociatedProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - } - - [TestMethod] - public async Task AssociatedProductList_ScopeGrantsAccess_ReturnsGrid() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - _productServiceMock.Setup(p => p.GetAssociatedProducts("p1", CatalogAccess.IncludingHidden(), "")) - .ReturnsAsync(new List { new() { Id = "a1", Name = "Assoc", DisplayOrder = 1 } }); - - var result = await _controller.AssociatedProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - var json = result as JsonResult; - Assert.IsNotNull(json); - var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.AreEqual(1, gridModel.Total); - } - - // --- AssociatedProductUpdate --------------------------------------------------------------------- - - [TestMethod] - public async Task AssociatedProductUpdate_NotFound_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync((Product)null); - var model = new ProductModel.AssociatedProductModel { Id = "a1" }; - - await Assert.ThrowsExactlyAsync(() => _controller.AssociatedProductUpdate(model)); - } - - [TestMethod] - public async Task AssociatedProductUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() - { - var associatedProduct = new Product { Id = "a1" }; - _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(associatedProduct); - _scopeMock.Setup(s => s.HasAccess(associatedProduct)).ReturnsAsync(false); - var model = new ProductModel.AssociatedProductModel { Id = "a1" }; - - var result = await _controller.AssociatedProductUpdate(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productServiceMock.Verify(p => p.UpdateAssociatedProduct(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task AssociatedProductUpdate_ScopeGrantsAccess_ValidModel_Updates() - { - var associatedProduct = new Product { Id = "a1" }; - _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(associatedProduct); - _scopeMock.Setup(s => s.HasAccess(associatedProduct)).ReturnsAsync(true); - var model = new ProductModel.AssociatedProductModel { Id = "a1", DisplayOrder = 5 }; - - var result = await _controller.AssociatedProductUpdate(model); - - Assert.IsInstanceOfType(result); - Assert.AreEqual(5, associatedProduct.DisplayOrder); - _productServiceMock.Verify(p => p.UpdateAssociatedProduct(associatedProduct), Times.Once); - } - - // --- AssociatedProductDelete --------------------------------------------------------------------- - - [TestMethod] - public async Task AssociatedProductDelete_NotFound_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync((Product)null); - var model = new ProductModel.AssociatedProductModel { Id = "a1" }; - - await Assert.ThrowsExactlyAsync(() => _controller.AssociatedProductDelete(model)); - } - - [TestMethod] - public async Task AssociatedProductDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() - { - var product = new Product { Id = "a1" }; - _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.AssociatedProductModel { Id = "a1" }; - - var result = await _controller.AssociatedProductDelete(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.DeleteAssociatedProduct(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task AssociatedProductDelete_ScopeGrantsAccess_ValidModel_Deletes() - { - var product = new Product { Id = "a1" }; - _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.AssociatedProductModel { Id = "a1" }; - - var result = await _controller.AssociatedProductDelete(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.DeleteAssociatedProduct(product), Times.Once); - } - - // --- AssociatedProductAddPopup (GET) ------------------------------------------------------------- - - [TestMethod] - public async Task AssociatedProductAddPopupGet_CallsPrepareAssociatedProductModel() - { - // PrepareAssociatedProductModel() now reads scope internally (Task 9). - _productViewModelServiceMock.Setup(s => s.PrepareAssociatedProductModel()) - .ReturnsAsync(new ProductModel.AddAssociatedProductModel()); - - var result = await _controller.AssociatedProductAddPopup("p1") as ViewResult; - - Assert.IsNotNull(result); - var model = result.Model as ProductModel.AddAssociatedProductModel; - Assert.IsNotNull(model); - Assert.AreEqual("p1", model.ProductId); - _productViewModelServiceMock.Verify(s => s.PrepareAssociatedProductModel(), Times.Once); - } - - // --- AssociatedProductAddPopupList --------------------------------------------------------------- - - [TestMethod] - public async Task AssociatedProductAddPopupList_UsesScopeDefaultStoreId() - { - _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); - _productViewModelServiceMock - .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) - .ReturnsAsync((new List(), 0)); - - var model = new ProductModel.AddAssociatedProductModel(); - var result = await _controller.AssociatedProductAddPopupList( - new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); - - Assert.IsInstanceOfType(result); - Assert.AreEqual("store-1", model.SearchStoreId); - } - - [TestMethod] - public async Task AssociatedProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() - { - _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); - _productViewModelServiceMock - .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) - .ReturnsAsync((new List(), 0)); - - var model = new ProductModel.AddAssociatedProductModel { SearchStoreId = "explicit" }; - var result = await _controller.AssociatedProductAddPopupList( - new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); - - Assert.IsInstanceOfType(result); - Assert.AreEqual("explicit", model.SearchStoreId); - } - - // --- AssociatedProductAddPopup (POST) ------------------------------------------------------------ - // HasAccess (strict) on the parent product: closes a real gap - Vendor's controller had no ownership - // check on the parent product (model.ProductId) at all, in either layer. Vendor's own (host-specific) - // service already filtered each selected product via HasAccessToProduct before reparenting it - // (Grand.Web.Vendor/Services/ProductViewModelService.cs InsertAssociatedProductModel), but the parent - // was never checked anywhere, letting a vendor attach their own products under another vendor's - // grouped product. This controller-level fix (parent HasAccess + per-selected-id HasAccess, matching - // Store's original controller-level filtering) is necessary regardless of that pre-existing severity, - // since BaseProductController uses AdminShared's unfiltered IProductViewModelService - once Vendor is - // subclassed onto this base (Task 11), it loses its own service's per-id filter entirely, so the - // controller must enforce both checks itself. - - [TestMethod] - public async Task AssociatedProductAddPopupPost_ScopeDeniesAccessToParent_ReturnsContentMessage_DoesNotInsert() - { - var parent = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(parent); - _scopeMock.Setup(s => s.HasAccess(parent)).ReturnsAsync(false); - var model = new ProductModel.AddAssociatedProductModel { ProductId = "p1", SelectedProductIds = ["a1"] }; - - var result = await _controller.AssociatedProductAddPopup(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.InsertAssociatedProductModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task AssociatedProductAddPopupPost_ScopeGrantsAccess_AllSelectedIdsAllowed_InsertsAll() - { - var parent = new Product { Id = "p1" }; - var selected = new Product { Id = "a1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(parent); - _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(selected); - _scopeMock.Setup(s => s.HasAccess(parent)).ReturnsAsync(true); - _scopeMock.Setup(s => s.HasAccess(selected)).ReturnsAsync(true); - var model = new ProductModel.AddAssociatedProductModel { ProductId = "p1", SelectedProductIds = ["a1"] }; - - var result = await _controller.AssociatedProductAddPopup(model); - - var content = result as ContentResult; - Assert.IsNotNull(content); - Assert.AreEqual("", content.Content); - CollectionAssert.AreEqual(new[] { "a1" }, model.SelectedProductIds); - _productViewModelServiceMock.Verify(s => s.InsertAssociatedProductModel(model), Times.Once); - } - - [TestMethod] - public async Task AssociatedProductAddPopupPost_ScopeGrantsAccessToParent_FiltersOutDeniedSelectedIds() - { - var parent = new Product { Id = "p1" }; - var allowed = new Product { Id = "a1" }; - var denied = new Product { Id = "a2" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(parent); - _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(allowed); - _productServiceMock.Setup(p => p.GetProductById("a2", false)).ReturnsAsync(denied); - _scopeMock.Setup(s => s.HasAccess(parent)).ReturnsAsync(true); - _scopeMock.Setup(s => s.HasAccess(allowed)).ReturnsAsync(true); - _scopeMock.Setup(s => s.HasAccess(denied)).ReturnsAsync(false); - var model = new ProductModel.AddAssociatedProductModel { ProductId = "p1", SelectedProductIds = ["a1", "a2"] }; - - var result = await _controller.AssociatedProductAddPopup(model); - - Assert.IsInstanceOfType(result); - CollectionAssert.AreEqual(new[] { "a1" }, model.SelectedProductIds); - _productViewModelServiceMock.Verify(s => s.InsertAssociatedProductModel(model), Times.Once); - } - - [TestMethod] - public async Task AssociatedProductAddPopupPost_AllSelectedIdsDenied_DoesNotInsert() - { - var parent = new Product { Id = "p1" }; - var denied = new Product { Id = "a1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(parent); - _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(denied); - _scopeMock.Setup(s => s.HasAccess(parent)).ReturnsAsync(true); - _scopeMock.Setup(s => s.HasAccess(denied)).ReturnsAsync(false); - var model = new ProductModel.AddAssociatedProductModel { ProductId = "p1", SelectedProductIds = ["a1"] }; - - var result = await _controller.AssociatedProductAddPopup(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.InsertAssociatedProductModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task AssociatedProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ReturnsViewWithRepreparedModel() - { - var parent = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(parent); - _scopeMock.Setup(s => s.HasAccess(parent)).ReturnsAsync(true); - var reprepared = new ProductModel.AddAssociatedProductModel(); - _productViewModelServiceMock.Setup(s => s.PrepareAssociatedProductModel()).ReturnsAsync(reprepared); - var model = new ProductModel.AddAssociatedProductModel { ProductId = "p1" }; - _controller.ModelState.AddModelError("x", "error"); - - var result = await _controller.AssociatedProductAddPopup(model); - - var view = result as ViewResult; - Assert.IsNotNull(view); - Assert.AreSame(reprepared, view.Model); - _productViewModelServiceMock.Verify(s => s.InsertAssociatedProductModel(It.IsAny()), Times.Never); - } - // --- Product pictures --------------------------------------------------------------------------- // ProductPictureAdd is deliberately not covered here (same rationale as Store's original // ProductControllerTests): reaching its HasAccess check requires a non-empty IFormFileCollection and diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductAssociatedController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductAssociatedController.cs new file mode 100644 index 000000000..74b0de7f3 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Controllers/ProductAssociatedController.cs @@ -0,0 +1,21 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.Admin.Extensions; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Admin.Controllers; + +[AuthorizeAdmin] +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaAdmin)] +[AuthorizeMenu] +public class ProductAssociatedController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductAssociatedController(productViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductBundleController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductBundleController.cs new file mode 100644 index 000000000..147e0fbac --- /dev/null +++ b/src/Web/Grand.Web.Admin/Controllers/ProductBundleController.cs @@ -0,0 +1,21 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.Admin.Extensions; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Admin.Controllers; + +[AuthorizeAdmin] +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaAdmin)] +[AuthorizeMenu] +public class ProductBundleController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductBundleController(productViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAssociatedController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAssociatedController.cs new file mode 100644 index 000000000..086d95892 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAssociatedController.cs @@ -0,0 +1,205 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Utilities.Catalog; +using Grand.Domain.Catalog; +using Grand.Domain.Permissions; +using Grand.SharedKernel.Extensions; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.AdminShared.Controllers; + +[PermissionAuthorize(PermissionSystemName.Products)] +[AutoValidateAntiforgeryToken] +public abstract class BaseProductAssociatedController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseController +{ + #region Associated products + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task AssociatedProductList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // CanView on this read: a product shared with other stores opens read-only on Store, so its + // tabs must load; the mutating actions below keep the strict HasAccess. History: + // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct gating + // this action on both hosts. Admin's original had no check at all - GlobalAdminDataScope.HasAccess + // is a no-op there, so this closes that gap the same way as every other row in this task. + if (!await scope.CanView(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + // AssociatedProductVendorId (hook below): Vendor's original also passed CurrentVendor.Id into + // GetAssociatedProducts(vendorId:) so a vendor only sees, among a grouped product's full + // associated-product set, the ones they themselves own. That vendor filter is unconditional in + // GetAssociatedProducts regardless of showHidden, unlike its storeId parameter (which only applies + // when showHidden is false - moot here since all three hosts pass showHidden: true). + var associatedProducts = await productService.GetAssociatedProducts(productId, + access: CatalogAccess.IncludingHidden(), + vendorId: AssociatedProductVendorId); + var associatedProductsModel = associatedProducts + .Select(x => new ProductModel.AssociatedProductModel { + Id = x.Id, + ProductId = productId, + ProductName = x.Name, + DisplayOrder = x.DisplayOrder + }) + .ToList(); + + var gridModel = new DataSourceResult { + Data = associatedProductsModel, + Total = associatedProductsModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task AssociatedProductUpdate(ProductModel.AssociatedProductModel model) + { + var associatedProduct = await productService.GetProductById(model.Id); + if (associatedProduct == null) + throw new ArgumentException("No associated product found with the specified id"); + + // HasAccess (strict): mirrors Vendor's inline HasAccessToProduct(associatedProduct) check and + // Store's CanAccessProduct. Admin's original had no check at all. + if (!await scope.HasAccess(associatedProduct)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + associatedProduct.DisplayOrder = model.DisplayOrder; + await productService.UpdateAssociatedProduct(associatedProduct); + + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task AssociatedProductDelete(ProductModel.AssociatedProductModel model) + { + var product = await productService.GetProductById(model.Id); + if (product == null) + throw new ArgumentException("No associated product found with the specified id"); + + // HasAccess (strict): mirrors Vendor's inline HasAccessToProduct(product) check and Store's + // CanAccessProduct. Admin's original had no check at all. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.DeleteAssociatedProduct(product); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task AssociatedProductAddPopup(string productId) + { + // No access check here in any of the three original hosts (all open this popup unconditionally + // once the Edit permission is satisfied) - only the mutating actions below tie access to a + // specific product. PrepareAssociatedProductModel() now reads scope internally (Task 9). + var model = await productViewModelService.PrepareAssociatedProductModel(); + model.ProductId = productId; + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task AssociatedProductAddPopupList(DataSourceRequest command, + ProductModel.AddAssociatedProductModel model) + { + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + + var (products, totalCount) = + await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = products.ToList(), + Total = totalCount + }; + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task AssociatedProductAddPopup(ProductModel.AddAssociatedProductModel model) + { + var parentProduct = await productService.GetProductById(model.ProductId); + // HasAccess (strict): mirrors Store's CanAccessProduct(parentProduct) check on this action. + // Vendor's controller had NO check on the parent product at all (see below for the selected-ids + // side). Vendor's own service-layer InsertAssociatedProductModel already filtered each selected + // product via HasAccessToProduct before reparenting it, but never checked the parent - so a vendor + // could attach their own products under another vendor's grouped product (cross-vendor storefront + // pollution / data-integrity issue, not a write to someone else's product record). Closed here the + // same way as every other AddPopup(POST) in this task. + if (!await scope.HasAccess(parentProduct)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + // InsertAssociatedProductModel reparents each selected product - it writes + // ParentGroupedProductId directly onto that product's own record - unlike + // Related/Similar/Bundle/Cross-sell/Recommended, whose Insert*ProductModel only adds a mapping + // entry that references the selected product's id from the parent's own list; the selected + // product's record is never itself modified there. So here, unlike those simpler regions, + // every selected id must independently pass HasAccess too - the same per-id filtering Store's + // controller-level original already did, and which Vendor's original also had, but only at the + // service layer (Grand.Web.Vendor/Services/ProductViewModelService.cs InsertAssociatedProductModel: + // `if (product == null || !HasAccessToProduct(product)) continue;`). BaseProductController + // injects AdminShared's IProductViewModelService - the unfiltered variant - not Vendor's own, so + // once Vendor is subclassed onto this base (Task 11) it loses that service-layer filter + // entirely. Enforcing the per-id check here at the controller level is what preserves the + // invariant going forward. + if (model.SelectedProductIds != null) + { + var validIds = new List(); + foreach (var id in model.SelectedProductIds) + { + var selected = await productService.GetProductById(id); + if (await scope.HasAccess(selected)) validIds.Add(id); + } + + model.SelectedProductIds = validIds.ToArray(); + if (validIds.Count > 0) await productViewModelService.InsertAssociatedProductModel(model); + } + + return Content(""); + } + + // Unlike Related/Similar/Bundle/Cross-sell/Recommended, all three original hosts share the exact + // same invalid-model-state handling here (Error(ModelState) + re-prepare + View) - Vendor's + // AssociatedProductAddPopup(POST) does not use the Content(ModelState.GetErrors()) shortcut it + // uses in those other regions, so no host-specific hook is needed for this action. + Error(ModelState); + model = await productViewModelService.PrepareAssociatedProductModel(); + return View(model); + } + + /// Vendor id to filter the associated-products grid by, in addition to the HasAccess gate in + /// AssociatedProductList above. Vendor's original passed CurrentVendor.Id into + /// GetAssociatedProducts(vendorId:) so a vendor only sees the subset of a grouped product's associated + /// products that they themselves own. Admin/Store passed no vendorId (both show every associated + /// product on the parent). Empty here, matching Admin/Store; a future Vendor subclass overrides it + /// once hosts are subclassed onto BaseProductController (Task 11). + protected virtual string AssociatedProductVendorId => ""; + + #endregion +} diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductBundleController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductBundleController.cs new file mode 100644 index 000000000..858df800c --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductBundleController.cs @@ -0,0 +1,159 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Domain.Permissions; +using Grand.SharedKernel.Extensions; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.AdminShared.Controllers; + +[PermissionAuthorize(PermissionSystemName.Products)] +[AutoValidateAntiforgeryToken] +public abstract class BaseProductBundleController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseController +{ + #region Bundle products + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task BundleProductList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // CanView on this read: a product shared with other stores opens read-only on Store, so its + // tabs must load; the mutating actions below keep the strict HasAccess. History: + // HasAccess (strict) has the same shape as "Related products"/"Similar products" above - + // mirrors Store's CanAccessProduct check on this action. Applying it uniformly also closes the + // same kind of gap found in those two regions: Vendor's original BundleProductUpdate/Delete/ + // AddPopup(GET/POST) (below) had no ownership check at all - only List checked (via + // CheckAccessToProduct) - letting any vendor mutate another vendor's bundle-product mappings by id. + if (!await scope.CanView(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var bundleProducts = product.BundleProducts.OrderBy(x => x.DisplayOrder); + var bundleProductsModel = new List(); + foreach (var x in bundleProducts) + bundleProductsModel.Add(new ProductModel.BundleProductModel { + Id = x.Id, + ProductBundleId = productId, + ProductId = x.ProductId, + ProductName = (await productService.GetProductById(x.ProductId))?.Name, + DisplayOrder = x.DisplayOrder, + Quantity = x.Quantity + }); + var gridModel = new DataSourceResult { + Data = bundleProductsModel, + Total = bundleProductsModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task BundleProductUpdate(ProductModel.BundleProductModel model) + { + var product = await productService.GetProductById(model.ProductBundleId); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.UpdateBundleProductModel(model); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task BundleProductDelete(ProductModel.BundleProductModel model) + { + var product = await productService.GetProductById(model.ProductBundleId); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.DeleteBundleProductModel(model); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task BundleProductAddPopup(string productId) + { + // No access check here in any of the three original hosts (Admin/Store/Vendor all open this + // popup unconditionally once the Edit permission is satisfied) - only the mutating POST below + // ties access to a specific product. PrepareBundleProductModel() now reads scope + // internally (Task 9). + var model = await productViewModelService.PrepareBundleProductModel(); + model.ProductId = productId; + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task BundleProductAddPopupList(DataSourceRequest command, + ProductModel.AddBundleProductModel model) + { + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + + var (products, totalCount) = + await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = products.ToList(), + Total = totalCount + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task BundleProductAddPopup(ProductModel.AddBundleProductModel model) + { + var product = await productService.GetProductById(model.ProductId); + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // BundleProductAddPopup(POST) had no check at all, letting any vendor add bundle-product mappings + // onto another vendor's product by posting its id - closed here the same way as the + // List/Update/Delete gap above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + if (model.SelectedProductIds != null) await productViewModelService.InsertBundleProductModel(model); + return Content(""); + } + + return await InvalidBundleProductAddPopupResult(model); + } + + /// Hook for the host-specific invalid-model-state response of the AddPopup(POST) action + /// above. Admin and Store both re-prepare the popup model and return the View; Vendor instead + /// returns Content(ModelState.GetErrors()) - a Vendor-only extension method that AdminShared cannot + /// reference. Default here matches Admin/Store; a future Vendor subclass overrides it once hosts are + /// subclassed onto BaseProductController (Task 11). + protected virtual async Task InvalidBundleProductAddPopupResult(ProductModel.AddBundleProductModel model) + { + Error(ModelState); + model = await productViewModelService.PrepareBundleProductModel(); + return View(model); + } + + #endregion +} diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index c819fd440..e493675f1 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -362,320 +362,6 @@ public async Task RequiredProductAddPopupList(DataSourceRequest c #endregion - #region Bundle products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task BundleProductList(DataSourceRequest command, string productId) - { - var product = await productService.GetProductById(productId); - - // CanView on this read: a product shared with other stores opens read-only on Store, so its - // tabs must load; the mutating actions below keep the strict HasAccess. History: - // HasAccess (strict) has the same shape as "Related products"/"Similar products" above - - // mirrors Store's CanAccessProduct check on this action. Applying it uniformly also closes the - // same kind of gap found in those two regions: Vendor's original BundleProductUpdate/Delete/ - // AddPopup(GET/POST) (below) had no ownership check at all - only List checked (via - // CheckAccessToProduct) - letting any vendor mutate another vendor's bundle-product mappings by id. - if (!await scope.CanView(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var bundleProducts = product.BundleProducts.OrderBy(x => x.DisplayOrder); - var bundleProductsModel = new List(); - foreach (var x in bundleProducts) - bundleProductsModel.Add(new ProductModel.BundleProductModel { - Id = x.Id, - ProductBundleId = productId, - ProductId = x.ProductId, - ProductName = (await productService.GetProductById(x.ProductId))?.Name, - DisplayOrder = x.DisplayOrder, - Quantity = x.Quantity - }); - var gridModel = new DataSourceResult { - Data = bundleProductsModel, - Total = bundleProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BundleProductUpdate(ProductModel.BundleProductModel model) - { - var product = await productService.GetProductById(model.ProductBundleId); - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await productViewModelService.UpdateBundleProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BundleProductDelete(ProductModel.BundleProductModel model) - { - var product = await productService.GetProductById(model.ProductBundleId); - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await productViewModelService.DeleteBundleProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task BundleProductAddPopup(string productId) - { - // No access check here in any of the three original hosts (Admin/Store/Vendor all open this - // popup unconditionally once the Edit permission is satisfied) - only the mutating POST below - // ties access to a specific product. PrepareBundleProductModel() now reads scope - // internally (Task 9). - var model = await productViewModelService.PrepareBundleProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BundleProductAddPopupList(DataSourceRequest command, - ProductModel.AddBundleProductModel model) - { - if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; - - var (products, totalCount) = - await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BundleProductAddPopup(ProductModel.AddBundleProductModel model) - { - var product = await productService.GetProductById(model.ProductId); - // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original - // BundleProductAddPopup(POST) had no check at all, letting any vendor add bundle-product mappings - // onto another vendor's product by posting its id - closed here the same way as the - // List/Update/Delete gap above. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await productViewModelService.InsertBundleProductModel(model); - return Content(""); - } - - return await InvalidBundleProductAddPopupResult(model); - } - - /// Hook for the host-specific invalid-model-state response of the AddPopup(POST) action - /// above. Admin and Store both re-prepare the popup model and return the View; Vendor instead - /// returns Content(ModelState.GetErrors()) - a Vendor-only extension method that AdminShared cannot - /// reference. Default here matches Admin/Store; a future Vendor subclass overrides it once hosts are - /// subclassed onto BaseProductController (Task 11). - protected virtual async Task InvalidBundleProductAddPopupResult(ProductModel.AddBundleProductModel model) - { - Error(ModelState); - model = await productViewModelService.PrepareBundleProductModel(); - return View(model); - } - - #endregion - - #region Associated products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task AssociatedProductList(DataSourceRequest command, string productId) - { - var product = await productService.GetProductById(productId); - - // CanView on this read: a product shared with other stores opens read-only on Store, so its - // tabs must load; the mutating actions below keep the strict HasAccess. History: - // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct gating - // this action on both hosts. Admin's original had no check at all - GlobalAdminDataScope.HasAccess - // is a no-op there, so this closes that gap the same way as every other row in this task. - if (!await scope.CanView(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - // AssociatedProductVendorId (hook below): Vendor's original also passed CurrentVendor.Id into - // GetAssociatedProducts(vendorId:) so a vendor only sees, among a grouped product's full - // associated-product set, the ones they themselves own. That vendor filter is unconditional in - // GetAssociatedProducts regardless of showHidden, unlike its storeId parameter (which only applies - // when showHidden is false - moot here since all three hosts pass showHidden: true). - var associatedProducts = await productService.GetAssociatedProducts(productId, - access: CatalogAccess.IncludingHidden(), - vendorId: AssociatedProductVendorId); - var associatedProductsModel = associatedProducts - .Select(x => new ProductModel.AssociatedProductModel { - Id = x.Id, - ProductId = productId, - ProductName = x.Name, - DisplayOrder = x.DisplayOrder - }) - .ToList(); - - var gridModel = new DataSourceResult { - Data = associatedProductsModel, - Total = associatedProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociatedProductUpdate(ProductModel.AssociatedProductModel model) - { - var associatedProduct = await productService.GetProductById(model.Id); - if (associatedProduct == null) - throw new ArgumentException("No associated product found with the specified id"); - - // HasAccess (strict): mirrors Vendor's inline HasAccessToProduct(associatedProduct) check and - // Store's CanAccessProduct. Admin's original had no check at all. - if (!await scope.HasAccess(associatedProduct)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - associatedProduct.DisplayOrder = model.DisplayOrder; - await productService.UpdateAssociatedProduct(associatedProduct); - - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociatedProductDelete(ProductModel.AssociatedProductModel model) - { - var product = await productService.GetProductById(model.Id); - if (product == null) - throw new ArgumentException("No associated product found with the specified id"); - - // HasAccess (strict): mirrors Vendor's inline HasAccessToProduct(product) check and Store's - // CanAccessProduct. Admin's original had no check at all. - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await productViewModelService.DeleteAssociatedProduct(product); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task AssociatedProductAddPopup(string productId) - { - // No access check here in any of the three original hosts (all open this popup unconditionally - // once the Edit permission is satisfied) - only the mutating actions below tie access to a - // specific product. PrepareAssociatedProductModel() now reads scope internally (Task 9). - var model = await productViewModelService.PrepareAssociatedProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociatedProductAddPopupList(DataSourceRequest command, - ProductModel.AddAssociatedProductModel model) - { - if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; - - var (products, totalCount) = - await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociatedProductAddPopup(ProductModel.AddAssociatedProductModel model) - { - var parentProduct = await productService.GetProductById(model.ProductId); - // HasAccess (strict): mirrors Store's CanAccessProduct(parentProduct) check on this action. - // Vendor's controller had NO check on the parent product at all (see below for the selected-ids - // side). Vendor's own service-layer InsertAssociatedProductModel already filtered each selected - // product via HasAccessToProduct before reparenting it, but never checked the parent - so a vendor - // could attach their own products under another vendor's grouped product (cross-vendor storefront - // pollution / data-integrity issue, not a write to someone else's product record). Closed here the - // same way as every other AddPopup(POST) in this task. - if (!await scope.HasAccess(parentProduct)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - // InsertAssociatedProductModel reparents each selected product - it writes - // ParentGroupedProductId directly onto that product's own record - unlike - // Related/Similar/Bundle/Cross-sell/Recommended, whose Insert*ProductModel only adds a mapping - // entry that references the selected product's id from the parent's own list; the selected - // product's record is never itself modified there. So here, unlike those simpler regions, - // every selected id must independently pass HasAccess too - the same per-id filtering Store's - // controller-level original already did, and which Vendor's original also had, but only at the - // service layer (Grand.Web.Vendor/Services/ProductViewModelService.cs InsertAssociatedProductModel: - // `if (product == null || !HasAccessToProduct(product)) continue;`). BaseProductController - // injects AdminShared's IProductViewModelService - the unfiltered variant - not Vendor's own, so - // once Vendor is subclassed onto this base (Task 11) it loses that service-layer filter - // entirely. Enforcing the per-id check here at the controller level is what preserves the - // invariant going forward. - if (model.SelectedProductIds != null) - { - var validIds = new List(); - foreach (var id in model.SelectedProductIds) - { - var selected = await productService.GetProductById(id); - if (await scope.HasAccess(selected)) validIds.Add(id); - } - - model.SelectedProductIds = validIds.ToArray(); - if (validIds.Count > 0) await productViewModelService.InsertAssociatedProductModel(model); - } - - return Content(""); - } - - // Unlike Related/Similar/Bundle/Cross-sell/Recommended, all three original hosts share the exact - // same invalid-model-state handling here (Error(ModelState) + re-prepare + View) - Vendor's - // AssociatedProductAddPopup(POST) does not use the Content(ModelState.GetErrors()) shortcut it - // uses in those other regions, so no host-specific hook is needed for this action. - Error(ModelState); - model = await productViewModelService.PrepareAssociatedProductModel(); - return View(model); - } - - /// Vendor id to filter the associated-products grid by, in addition to the HasAccess gate in - /// AssociatedProductList above. Vendor's original passed CurrentVendor.Id into - /// GetAssociatedProducts(vendorId:) so a vendor only sees the subset of a grouped product's associated - /// products that they themselves own. Admin/Store passed no vendorId (both show every associated - /// product on the parent). Empty here, matching Admin/Store; a future Vendor subclass overrides it - /// once hosts are subclassed onto BaseProductController (Task 11). - protected virtual string AssociatedProductVendorId => ""; - - #endregion - #region Purchased with order // Type note: this method covers Admin and Store only. Both already share diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.AssociatedProducts.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.AssociatedProducts.cshtml index f4a4c8341..ba711d92d 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.AssociatedProducts.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.AssociatedProducts.cshtml @@ -14,7 +14,7 @@

@if (!readOnly) { - @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.AssociatedProducts.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.AssociatedProducts.AddNew"] }
@@ -22,9 +22,9 @@
diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.BundleProducts.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.BundleProducts.cshtml index d05155747..79b12fa5e 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.BundleProducts.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.BundleProducts.cshtml @@ -12,7 +12,7 @@

@if (!readOnly) { - @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.BundleProducts.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.BundleProducts.AddNew"] }
@@ -20,9 +20,9 @@
diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/AssociatedProductAddPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductAssociated/AssociatedProductAddPopup.cshtml similarity index 97% rename from src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/AssociatedProductAddPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductAssociated/AssociatedProductAddPopup.cshtml index 7bcf81ee8..936331b65 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/AssociatedProductAddPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductAssociated/AssociatedProductAddPopup.cshtml @@ -6,7 +6,7 @@ ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.AssociatedProducts.AddNew"]; } - @@ -29,7 +29,7 @@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/BundleProductAddPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductBundle/BundleProductAddPopup.cshtml similarity index 98% rename from src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/BundleProductAddPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductBundle/BundleProductAddPopup.cshtml index 9d089ae16..02c86ce4f 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/BundleProductAddPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductBundle/BundleProductAddPopup.cshtml @@ -6,7 +6,7 @@ //page title ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.BundleProducts.AddNew"]; } - @@ -27,7 +27,7 @@ diff --git a/src/Web/Grand.Web.Store/Controllers/ProductAssociatedController.cs b/src/Web/Grand.Web.Store/Controllers/ProductAssociatedController.cs new file mode 100644 index 000000000..94d04e926 --- /dev/null +++ b/src/Web/Grand.Web.Store/Controllers/ProductAssociatedController.cs @@ -0,0 +1,21 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Store.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Store.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaStore)] +[AuthorizeStore] +[AuthorizeMenu] +public class ProductAssociatedController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductAssociatedController(productViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.Store/Controllers/ProductBundleController.cs b/src/Web/Grand.Web.Store/Controllers/ProductBundleController.cs new file mode 100644 index 000000000..a2b37a6cb --- /dev/null +++ b/src/Web/Grand.Web.Store/Controllers/ProductBundleController.cs @@ -0,0 +1,21 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Store.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Store.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaStore)] +[AuthorizeStore] +[AuthorizeMenu] +public class ProductBundleController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductBundleController(productViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductAssociatedController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductAssociatedController.cs new file mode 100644 index 000000000..fae06caa5 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductAssociatedController.cs @@ -0,0 +1,29 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Vendor.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Vendor.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaVendor)] +[AuthorizeVendor] +[AuthorizeMenu] +public class ProductAssociatedController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope, + IContextAccessor contextAccessor) + : BaseProductAssociatedController(productViewModelService, productService, translationService, scope) +{ + // Vendor's original passed CurrentVendor.Id into GetAssociatedProducts(vendorId:) so a vendor only + // sees the subset of a grouped product's associated products that they themselves own. Overriding + // the base's empty default, per BaseProductAssociatedController.AssociatedProductVendorId's own doc comment. + protected override string AssociatedProductVendorId => contextAccessor.WorkContext.CurrentVendor.Id; +} diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductBundleController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductBundleController.cs new file mode 100644 index 000000000..7930fa945 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductBundleController.cs @@ -0,0 +1,29 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Vendor.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Vendor.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaVendor)] +[AuthorizeVendor] +[AuthorizeMenu] +public class ProductBundleController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductBundleController(productViewModelService, productService, translationService, scope) +{ + // Vendor's original AddPopup(POST) actions returned Content(ModelState.GetErrors()) on an invalid + // model, instead of Admin/Store's re-prepare-and-View. Overriding the base's Admin/Store default, + // per each hook's own doc comment. + protected override Task InvalidBundleProductAddPopupResult(ProductModel.AddBundleProductModel model) + => Task.FromResult(Content(ModelState.GetErrors())); +} diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductController.cs index 154ea356b..9cfb555dc 100644 --- a/src/Web/Grand.Web.Vendor/Controllers/ProductController.cs +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductController.cs @@ -2,10 +2,8 @@ using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Interfaces.Common.Localization; using Grand.Domain.Catalog; -using Grand.Infrastructure; using Grand.Web.AdminShared.Controllers; using Grand.Web.AdminShared.Interfaces; -using Grand.Web.AdminShared.Models.Catalog; using Grand.Web.Common.Filters; using Grand.Web.Common.Localization; using Grand.Web.Common.Security.Authorization; @@ -29,20 +27,7 @@ public class ProductController( IDateTimeService dateTimeService, IRequestPermissionService permissionService, IEnumTranslationService enumTranslationService, - IAdminDataScope scope, - IContextAccessor contextAccessor) + IAdminDataScope scope) : BaseProductController(productViewModelService, productService, inventoryManageService, languageService, translationService, productReservationService, auctionService, dateTimeService, permissionService, - enumTranslationService, scope) -{ - // Vendor's original passed CurrentVendor.Id into GetAssociatedProducts(vendorId:) so a vendor only - // sees the subset of a grouped product's associated products that they themselves own. Overriding - // the base's empty default, per BaseProductController.AssociatedProductVendorId's own doc comment. - protected override string AssociatedProductVendorId => contextAccessor.WorkContext.CurrentVendor.Id; - - // Vendor's original AddPopup(POST) actions returned Content(ModelState.GetErrors()) on an invalid - // model, instead of Admin/Store's re-prepare-and-View. Overriding the base's Admin/Store default, - // per each hook's own doc comment. - protected override Task InvalidBundleProductAddPopupResult(ProductModel.AddBundleProductModel model) - => Task.FromResult(Content(ModelState.GetErrors())); -} + enumTranslationService, scope); From 338012d71b8e97902605d017cbef05109d0edb69 Mon Sep 17 00:00:00 2001 From: support Date: Thu, 22 Oct 2026 06:37:02 +0200 Subject: [PATCH 09/27] Move the product prices tab into its own controller --- .../Controllers/BaseProductControllerTests.cs | 397 ---------------- .../BaseProductPriceControllerTests.cs | 436 ++++++++++++++++++ .../Partials/CreateOrUpdate.Prices.cshtml | 8 +- .../WidgetZone.TierPrice.Bottom.cshtml | 0 .../WidgetZone.TierPrice.Buttons.cshtml | 0 .../Partials/WidgetZone.TierPrice.Top.cshtml | 0 .../Controllers/ProductPriceController.cs | 23 + .../Controllers/BaseProductController.cs | 284 ------------ .../Controllers/BaseProductPriceController.cs | 312 +++++++++++++ .../CreateOrUpdate.ProductPrices.cshtml | 8 +- .../Partials/CreateOrUpdateTierPrice.cshtml | 0 .../Partials/TierPrice.StoreScope.cshtml | 0 .../WidgetZone.TierPrice.Bottom.cshtml | 0 .../WidgetZone.TierPrice.Buttons.cshtml | 0 .../Partials/WidgetZone.TierPrice.Top.cshtml | 0 .../TierPriceCreatePopup.cshtml | 2 +- .../TierPriceEditPopup.cshtml | 2 +- .../Partials/CreateOrUpdate.Prices.cshtml | 8 +- .../WidgetZone.TierPrice.Bottom.cshtml | 0 .../WidgetZone.TierPrice.Buttons.cshtml | 0 .../Partials/WidgetZone.TierPrice.Top.cshtml | 0 .../Controllers/ProductPriceController.cs | 23 + .../Partials/CreateOrUpdate.Prices.cshtml | 8 +- .../Partials/TierPrice.StoreScope.cshtml | 0 .../WidgetZone.TierPrice.Bottom.cshtml | 0 .../WidgetZone.TierPrice.Buttons.cshtml | 0 .../Partials/WidgetZone.TierPrice.Top.cshtml | 0 .../Controllers/ProductPriceController.cs | 23 + 28 files changed, 835 insertions(+), 699 deletions(-) create mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductPriceControllerTests.cs rename src/Web/Grand.Web.Admin/Areas/Admin/Views/{Product => ProductPrice}/Partials/WidgetZone.TierPrice.Bottom.cshtml (100%) rename src/Web/Grand.Web.Admin/Areas/Admin/Views/{Product => ProductPrice}/Partials/WidgetZone.TierPrice.Buttons.cshtml (100%) rename src/Web/Grand.Web.Admin/Areas/Admin/Views/{Product => ProductPrice}/Partials/WidgetZone.TierPrice.Top.cshtml (100%) create mode 100644 src/Web/Grand.Web.Admin/Controllers/ProductPriceController.cs create mode 100644 src/Web/Grand.Web.AdminShared/Controllers/BaseProductPriceController.cs rename src/Web/Grand.Web.AdminShared/Views/AdminShared/{Product => ProductPrice}/Partials/CreateOrUpdateTierPrice.cshtml (100%) rename src/Web/Grand.Web.AdminShared/Views/AdminShared/{Product => ProductPrice}/Partials/TierPrice.StoreScope.cshtml (100%) rename src/Web/Grand.Web.AdminShared/Views/AdminShared/{Product => ProductPrice}/Partials/WidgetZone.TierPrice.Bottom.cshtml (100%) rename src/Web/Grand.Web.AdminShared/Views/AdminShared/{Product => ProductPrice}/Partials/WidgetZone.TierPrice.Buttons.cshtml (100%) rename src/Web/Grand.Web.AdminShared/Views/AdminShared/{Product => ProductPrice}/Partials/WidgetZone.TierPrice.Top.cshtml (100%) rename src/Web/Grand.Web.AdminShared/Views/AdminShared/{Product => ProductPrice}/TierPriceCreatePopup.cshtml (97%) rename src/Web/Grand.Web.AdminShared/Views/AdminShared/{Product => ProductPrice}/TierPriceEditPopup.cshtml (97%) rename src/Web/Grand.Web.Store/Areas/Store/Views/{Product => ProductPrice}/Partials/WidgetZone.TierPrice.Bottom.cshtml (100%) rename src/Web/Grand.Web.Store/Areas/Store/Views/{Product => ProductPrice}/Partials/WidgetZone.TierPrice.Buttons.cshtml (100%) rename src/Web/Grand.Web.Store/Areas/Store/Views/{Product => ProductPrice}/Partials/WidgetZone.TierPrice.Top.cshtml (100%) create mode 100644 src/Web/Grand.Web.Store/Controllers/ProductPriceController.cs rename src/Web/Grand.Web.Vendor/Areas/Vendor/Views/{Product => ProductPrice}/Partials/TierPrice.StoreScope.cshtml (100%) rename src/Web/Grand.Web.Vendor/Areas/Vendor/Views/{Product => ProductPrice}/Partials/WidgetZone.TierPrice.Bottom.cshtml (100%) rename src/Web/Grand.Web.Vendor/Areas/Vendor/Views/{Product => ProductPrice}/Partials/WidgetZone.TierPrice.Buttons.cshtml (100%) rename src/Web/Grand.Web.Vendor/Areas/Vendor/Views/{Product => ProductPrice}/Partials/WidgetZone.TierPrice.Top.cshtml (100%) create mode 100644 src/Web/Grand.Web.Vendor/Controllers/ProductPriceController.cs diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index 670480ed7..1d7405934 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -1096,403 +1096,6 @@ public async Task BulkEditUpdate_ProductsWithEmptyIds_AreIgnoredWithoutServiceCa s => s.UpdateBulkEdit(It.IsAny>()), Times.Never); } - // --- ProductPriceList ------------------------------------------------------------------------- - // HasAccess (strict), not CanView: mirrors Store's CanAccessProduct check on this action. Applying it - // uniformly also closes real gaps on the mutate actions below: Store's original checked access only on - // List/Insert (Update/Delete had no check at all), and Vendor's original checked access only on List - // (Insert/Update/Delete had no check at all). - - [TestMethod] - public async Task ProductPriceList_ScopeDeniesAccess_ReturnsErrorJson() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - - var result = await _controller.ProductPriceList( - new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - } - - [TestMethod] - public async Task ProductPriceList_ScopeDeniesAccess_UsesScopeResourceKeyPrefix() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); - - var result = await _controller.ProductPriceList( - new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); - } - - [TestMethod] - public async Task ProductPriceList_ScopeGrantsAccess_ReturnsGrid() - { - var product = new Product { Id = "p1" }; - product.ProductPrices.Add(new ProductPrice { Id = "pp1", CurrencyCode = "EUR", Price = 9.99 }); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - - var result = await _controller.ProductPriceList( - new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - var json = result as JsonResult; - Assert.IsNotNull(json); - var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.AreEqual(1, gridModel.Total); - } - - // --- ProductPriceInsert ----------------------------------------------------------------------- - - [TestMethod] - public async Task ProductPriceInsert_ScopeDeniesAccess_ReturnsErrorJson_DoesNotInsert() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.ProductPriceModel { ProductId = "p1", CurrencyCode = "EUR", Price = 9.99 }; - - var result = await _controller.ProductPriceInsert(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productServiceMock.Verify(s => s.InsertProductPrice(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductPriceInsert_ScopeGrantsAccess_ValidModel_Inserts() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductPriceModel { ProductId = "p1", CurrencyCode = "EUR", Price = 9.99 }; - - var result = await _controller.ProductPriceInsert(model); - - Assert.IsInstanceOfType(result); - _productServiceMock.Verify(s => s.InsertProductPrice(It.Is( - pp => pp.ProductId == "p1" && pp.CurrencyCode == "EUR" && pp.Price == 9.99)), Times.Once); - } - - // --- ProductPriceUpdate ----------------------------------------------------------------------- - - [TestMethod] - public async Task ProductPriceUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() - { - var product = new Product { Id = "p1" }; - product.ProductPrices.Add(new ProductPrice { Id = "pp1", CurrencyCode = "EUR", Price = 9.99 }); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.ProductPriceModel { Id = "pp1", ProductId = "p1", CurrencyCode = "USD", Price = 19.99 }; - - var result = await _controller.ProductPriceUpdate(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productServiceMock.Verify(s => s.UpdateProductPrice(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductPriceUpdate_ScopeGrantsAccess_ValidModel_Updates() - { - var product = new Product { Id = "p1" }; - product.ProductPrices.Add(new ProductPrice { Id = "pp1", CurrencyCode = "EUR", Price = 9.99 }); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductPriceModel { Id = "pp1", ProductId = "p1", CurrencyCode = "USD", Price = 19.99 }; - - var result = await _controller.ProductPriceUpdate(model); - - Assert.IsInstanceOfType(result); - _productServiceMock.Verify(s => s.UpdateProductPrice(It.Is( - pp => pp.Id == "pp1" && pp.CurrencyCode == "USD" && pp.Price == 19.99 && pp.ProductId == "p1")), Times.Once); - } - - // --- ProductPriceDelete ----------------------------------------------------------------------- - - [TestMethod] - public async Task ProductPriceDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() - { - var product = new Product { Id = "p1" }; - product.ProductPrices.Add(new ProductPrice { Id = "pp1", CurrencyCode = "EUR", Price = 9.99 }); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.ProductPriceModel { Id = "pp1", ProductId = "p1" }; - - var result = await _controller.ProductPriceDelete(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productServiceMock.Verify(s => s.DeleteProductPrice(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductPriceDelete_ScopeGrantsAccess_ValidModel_Deletes() - { - var product = new Product { Id = "p1" }; - product.ProductPrices.Add(new ProductPrice { Id = "pp1", CurrencyCode = "EUR", Price = 9.99 }); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductPriceModel { Id = "pp1", ProductId = "p1" }; - - var result = await _controller.ProductPriceDelete(model); - - Assert.IsInstanceOfType(result); - _productServiceMock.Verify(s => s.DeleteProductPrice(It.Is( - pp => pp.Id == "pp1" && pp.ProductId == "p1")), Times.Once); - } - - // --- TierPriceList ---------------------------------------------------------------------------- - // HasAccess applied uniformly on every action in this region. Vendor's original checked ownership - // only on List and TierPriceEditPopup(GET); TierPriceCreatePopup(POST), TierPriceEditPopup(POST) and - // TierPriceDelete had NO ownership check at all, letting a vendor create/update/delete a tier price on - // any product by id. Store's original never checked TierPriceEditPopup(GET) either. - - [TestMethod] - public async Task TierPriceList_ScopeDeniesAccess_ReturnsErrorJson() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - - var result = await _controller.TierPriceList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( - s => s.PrepareTierPriceModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task TierPriceList_ScopeDeniesAccess_UsesScopeResourceKeyPrefix() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); - - var result = await _controller.TierPriceList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); - } - - [TestMethod] - public async Task TierPriceList_ScopeGrantsAccess_ReturnsGrid() - { - // PrepareTierPriceModel(product) now reads scope internally (Task 9). - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - var tierPrices = new List { new() { Id = "tp1" } }; - _productViewModelServiceMock.Setup(s => s.PrepareTierPriceModel(product)).ReturnsAsync(tierPrices); - - var result = await _controller.TierPriceList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - var json = result as JsonResult; - Assert.IsNotNull(json); - var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.AreEqual(1, gridModel.Total); - } - - // --- TierPriceCreatePopup (GET) ---------------------------------------------------------------- - - [TestMethod] - public async Task TierPriceCreatePopup_Get_PreparesModel() - { - // PrepareTierPriceModel(model) now reads scope internally (Task 9). - var result = await _controller.TierPriceCreatePopup("p1"); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( - s => s.PrepareTierPriceModel(It.Is(m => m.ProductId == "p1")), - Times.Once); - } - - // --- TierPriceCreatePopup (POST) --------------------------------------------------------------- - - [TestMethod] - public async Task TierPriceCreatePopup_Post_ScopeDeniesAccess_DoesNotInsert() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.TierPriceModel { ProductId = "p1" }; - - var result = await _controller.TierPriceCreatePopup(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productServiceMock.Verify(s => s.InsertTierPrice(It.IsAny(), It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task TierPriceCreatePopup_Post_ScopeGrantsAccess_ValidModel_Inserts() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.TierPriceModel { ProductId = "p1" }; - - var result = await _controller.TierPriceCreatePopup(model); - - Assert.IsInstanceOfType(result); - _productServiceMock.Verify( - s => s.InsertTierPrice(It.IsAny(), "p1"), Times.Once); - } - - [TestMethod] - public async Task TierPriceCreatePopup_Post_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); - var model = new ProductModel.TierPriceModel { ProductId = "missing" }; - - await Assert.ThrowsExactlyAsync(() => _controller.TierPriceCreatePopup(model)); - } - - // --- TierPriceEditPopup (GET) ------------------------------------------------------------------ - // HasAccess added here: Store's original had no ownership check on this GET action at all. - - [TestMethod] - public async Task TierPriceEditPopup_Get_ScopeDeniesAccess_ReturnsNotYourProductContent() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - var result = await _controller.TierPriceEditPopup("tp1", "p1"); - - var content = result as ContentResult; - Assert.IsNotNull(content); - Assert.AreEqual("This is not your product", content.Content); - _productViewModelServiceMock.Verify( - s => s.PrepareTierPriceModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task TierPriceEditPopup_Get_ScopeGrantsAccess_TierPriceMissing_ReturnsEmptyContent() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - - var result = await _controller.TierPriceEditPopup("missing-tp", "p1"); - - var content = result as ContentResult; - Assert.IsNotNull(content); - Assert.AreEqual("Empty tier price", content.Content); - } - - [TestMethod] - public async Task TierPriceEditPopup_Get_ScopeGrantsAccess_PreparesModel() - { - // PrepareTierPriceModel(model) now reads scope internally (Task 9). - var product = new Product { Id = "p1" }; - product.TierPrices.Add(new TierPrice { Id = "tp1" }); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - - var result = await _controller.TierPriceEditPopup("tp1", "p1"); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( - s => s.PrepareTierPriceModel(It.Is(m => m.ProductId == "p1")), - Times.Once); - } - - [TestMethod] - public async Task TierPriceEditPopup_Get_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); - - await Assert.ThrowsExactlyAsync(() => _controller.TierPriceEditPopup("tp1", "missing")); - } - - // --- TierPriceEditPopup (POST) ----------------------------------------------------------------- - - [TestMethod] - public async Task TierPriceEditPopup_Post_ScopeDeniesAccess_DoesNotUpdate() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.TierPriceModel { Id = "tp1", ProductId = "p1" }; - - var result = await _controller.TierPriceEditPopup("p1", model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productServiceMock.Verify(s => s.UpdateTierPrice(It.IsAny(), It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task TierPriceEditPopup_Post_ScopeGrantsAccess_ValidModel_Updates() - { - var product = new Product { Id = "p1" }; - product.TierPrices.Add(new TierPrice { Id = "tp1" }); - _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.TierPriceModel { Id = "tp1", ProductId = "p1" }; - - var result = await _controller.TierPriceEditPopup("p1", model); - - Assert.IsInstanceOfType(result); - _productServiceMock.Verify(s => s.UpdateTierPrice(It.IsAny(), "p1"), Times.Once); - } - - // --- TierPriceDelete ---------------------------------------------------------------------------- - - [TestMethod] - public async Task TierPriceDelete_ScopeDeniesAccess_DoesNotDelete() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.TierPriceDeleteModel("tp1", "p1"); - - var result = await _controller.TierPriceDelete(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productServiceMock.Verify(s => s.DeleteTierPrice(It.IsAny(), It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task TierPriceDelete_ScopeGrantsAccess_ValidModel_Deletes() - { - var product = new Product { Id = "p1" }; - product.TierPrices.Add(new TierPrice { Id = "tp1" }); - _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.TierPriceDeleteModel("tp1", "p1"); - - var result = await _controller.TierPriceDelete(model); - - Assert.IsInstanceOfType(result); - _productServiceMock.Verify(s => s.DeleteTierPrice(It.IsAny(), "p1"), Times.Once); - } - - [TestMethod] - public async Task TierPriceDelete_ScopeGrantsAccess_TierPriceMissing_Throws() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.TierPriceDeleteModel("missing-tp", "p1"); - - await Assert.ThrowsExactlyAsync(() => _controller.TierPriceDelete(model)); - } - // --- ProductAttributeMappingList ------------------------------------------------------------- // HasAccess applied uniformly on every action in this region. Store's original checked ownership // (CanAccessProduct) on List/PopupGET/PopupPOST/Delete/ValidationRulesPopupGET but not on diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductPriceControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductPriceControllerTests.cs new file mode 100644 index 000000000..92808e9f4 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductPriceControllerTests.cs @@ -0,0 +1,436 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Linq.Expressions; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class BaseProductPriceControllerTests : ProductControllerTestsBase +{ + // BaseProductPriceController is abstract. This minimal subclass exists only so the actions under test + // can be invoked directly. + public class TestProductPriceController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IDateTimeService dateTimeService, + IAdminDataScope scope) + : BaseProductPriceController(productViewModelService, productService, translationService, dateTimeService, scope); + + protected override TestProductPriceController CreateController() => new( + _productViewModelServiceMock.Object, + _productServiceMock.Object, + _translationServiceMock.Object, + _dateTimeServiceMock.Object, + _scopeMock.Object); + + // --- ProductPriceList ------------------------------------------------------------------------- + // HasAccess (strict), not CanView: mirrors Store's CanAccessProduct check on this action. Applying it + // uniformly also closes real gaps on the mutate actions below: Store's original checked access only on + // List/Insert (Update/Delete had no check at all), and Vendor's original checked access only on List + // (Insert/Update/Delete had no check at all). + + [TestMethod] + public async Task ProductPriceList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + + var result = await _controller.ProductPriceList( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task ProductPriceList_ScopeDeniesAccess_UsesScopeResourceKeyPrefix() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); + + var result = await _controller.ProductPriceList( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); + } + + [TestMethod] + public async Task ProductPriceList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + product.ProductPrices.Add(new ProductPrice { Id = "pp1", CurrencyCode = "EUR", Price = 9.99 }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + + var result = await _controller.ProductPriceList( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- ProductPriceInsert ----------------------------------------------------------------------- + + [TestMethod] + public async Task ProductPriceInsert_ScopeDeniesAccess_ReturnsErrorJson_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductPriceModel { ProductId = "p1", CurrencyCode = "EUR", Price = 9.99 }; + + var result = await _controller.ProductPriceInsert(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productServiceMock.Verify(s => s.InsertProductPrice(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductPriceInsert_ScopeGrantsAccess_ValidModel_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductPriceModel { ProductId = "p1", CurrencyCode = "EUR", Price = 9.99 }; + + var result = await _controller.ProductPriceInsert(model); + + Assert.IsInstanceOfType(result); + _productServiceMock.Verify(s => s.InsertProductPrice(It.Is( + pp => pp.ProductId == "p1" && pp.CurrencyCode == "EUR" && pp.Price == 9.99)), Times.Once); + } + + // --- ProductPriceUpdate ----------------------------------------------------------------------- + + [TestMethod] + public async Task ProductPriceUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + product.ProductPrices.Add(new ProductPrice { Id = "pp1", CurrencyCode = "EUR", Price = 9.99 }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductPriceModel { Id = "pp1", ProductId = "p1", CurrencyCode = "USD", Price = 19.99 }; + + var result = await _controller.ProductPriceUpdate(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productServiceMock.Verify(s => s.UpdateProductPrice(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductPriceUpdate_ScopeGrantsAccess_ValidModel_Updates() + { + var product = new Product { Id = "p1" }; + product.ProductPrices.Add(new ProductPrice { Id = "pp1", CurrencyCode = "EUR", Price = 9.99 }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductPriceModel { Id = "pp1", ProductId = "p1", CurrencyCode = "USD", Price = 19.99 }; + + var result = await _controller.ProductPriceUpdate(model); + + Assert.IsInstanceOfType(result); + _productServiceMock.Verify(s => s.UpdateProductPrice(It.Is( + pp => pp.Id == "pp1" && pp.CurrencyCode == "USD" && pp.Price == 19.99 && pp.ProductId == "p1")), Times.Once); + } + + // --- ProductPriceDelete ----------------------------------------------------------------------- + + [TestMethod] + public async Task ProductPriceDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + product.ProductPrices.Add(new ProductPrice { Id = "pp1", CurrencyCode = "EUR", Price = 9.99 }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductPriceModel { Id = "pp1", ProductId = "p1" }; + + var result = await _controller.ProductPriceDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productServiceMock.Verify(s => s.DeleteProductPrice(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductPriceDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "p1" }; + product.ProductPrices.Add(new ProductPrice { Id = "pp1", CurrencyCode = "EUR", Price = 9.99 }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductPriceModel { Id = "pp1", ProductId = "p1" }; + + var result = await _controller.ProductPriceDelete(model); + + Assert.IsInstanceOfType(result); + _productServiceMock.Verify(s => s.DeleteProductPrice(It.Is( + pp => pp.Id == "pp1" && pp.ProductId == "p1")), Times.Once); + } + + // --- TierPriceList ---------------------------------------------------------------------------- + // HasAccess applied uniformly on every action in this region. Vendor's original checked ownership + // only on List and TierPriceEditPopup(GET); TierPriceCreatePopup(POST), TierPriceEditPopup(POST) and + // TierPriceDelete had NO ownership check at all, letting a vendor create/update/delete a tier price on + // any product by id. Store's original never checked TierPriceEditPopup(GET) either. + + [TestMethod] + public async Task TierPriceList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + + var result = await _controller.TierPriceList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.PrepareTierPriceModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task TierPriceList_ScopeDeniesAccess_UsesScopeResourceKeyPrefix() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); + + var result = await _controller.TierPriceList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); + } + + [TestMethod] + public async Task TierPriceList_ScopeGrantsAccess_ReturnsGrid() + { + // PrepareTierPriceModel(product) now reads scope internally (Task 9). + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + var tierPrices = new List { new() { Id = "tp1" } }; + _productViewModelServiceMock.Setup(s => s.PrepareTierPriceModel(product)).ReturnsAsync(tierPrices); + + var result = await _controller.TierPriceList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- TierPriceCreatePopup (GET) ---------------------------------------------------------------- + + [TestMethod] + public async Task TierPriceCreatePopup_Get_PreparesModel() + { + // PrepareTierPriceModel(model) now reads scope internally (Task 9). + var result = await _controller.TierPriceCreatePopup("p1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.PrepareTierPriceModel(It.Is(m => m.ProductId == "p1")), + Times.Once); + } + + // --- TierPriceCreatePopup (POST) --------------------------------------------------------------- + + [TestMethod] + public async Task TierPriceCreatePopup_Post_ScopeDeniesAccess_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.TierPriceModel { ProductId = "p1" }; + + var result = await _controller.TierPriceCreatePopup(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productServiceMock.Verify(s => s.InsertTierPrice(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task TierPriceCreatePopup_Post_ScopeGrantsAccess_ValidModel_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.TierPriceModel { ProductId = "p1" }; + + var result = await _controller.TierPriceCreatePopup(model); + + Assert.IsInstanceOfType(result); + _productServiceMock.Verify( + s => s.InsertTierPrice(It.IsAny(), "p1"), Times.Once); + } + + [TestMethod] + public async Task TierPriceCreatePopup_Post_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var model = new ProductModel.TierPriceModel { ProductId = "missing" }; + + await Assert.ThrowsExactlyAsync(() => _controller.TierPriceCreatePopup(model)); + } + + // --- TierPriceEditPopup (GET) ------------------------------------------------------------------ + // HasAccess added here: Store's original had no ownership check on this GET action at all. + + [TestMethod] + public async Task TierPriceEditPopup_Get_ScopeDeniesAccess_ReturnsNotYourProductContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.TierPriceEditPopup("tp1", "p1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("This is not your product", content.Content); + _productViewModelServiceMock.Verify( + s => s.PrepareTierPriceModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task TierPriceEditPopup_Get_ScopeGrantsAccess_TierPriceMissing_ReturnsEmptyContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.TierPriceEditPopup("missing-tp", "p1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("Empty tier price", content.Content); + } + + [TestMethod] + public async Task TierPriceEditPopup_Get_ScopeGrantsAccess_PreparesModel() + { + // PrepareTierPriceModel(model) now reads scope internally (Task 9). + var product = new Product { Id = "p1" }; + product.TierPrices.Add(new TierPrice { Id = "tp1" }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.TierPriceEditPopup("tp1", "p1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.PrepareTierPriceModel(It.Is(m => m.ProductId == "p1")), + Times.Once); + } + + [TestMethod] + public async Task TierPriceEditPopup_Get_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + + await Assert.ThrowsExactlyAsync(() => _controller.TierPriceEditPopup("tp1", "missing")); + } + + // --- TierPriceEditPopup (POST) ----------------------------------------------------------------- + + [TestMethod] + public async Task TierPriceEditPopup_Post_ScopeDeniesAccess_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.TierPriceModel { Id = "tp1", ProductId = "p1" }; + + var result = await _controller.TierPriceEditPopup("p1", model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productServiceMock.Verify(s => s.UpdateTierPrice(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task TierPriceEditPopup_Post_ScopeGrantsAccess_ValidModel_Updates() + { + var product = new Product { Id = "p1" }; + product.TierPrices.Add(new TierPrice { Id = "tp1" }); + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.TierPriceModel { Id = "tp1", ProductId = "p1" }; + + var result = await _controller.TierPriceEditPopup("p1", model); + + Assert.IsInstanceOfType(result); + _productServiceMock.Verify(s => s.UpdateTierPrice(It.IsAny(), "p1"), Times.Once); + } + + // --- TierPriceDelete ---------------------------------------------------------------------------- + + [TestMethod] + public async Task TierPriceDelete_ScopeDeniesAccess_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.TierPriceDeleteModel("tp1", "p1"); + + var result = await _controller.TierPriceDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productServiceMock.Verify(s => s.DeleteTierPrice(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task TierPriceDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "p1" }; + product.TierPrices.Add(new TierPrice { Id = "tp1" }); + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.TierPriceDeleteModel("tp1", "p1"); + + var result = await _controller.TierPriceDelete(model); + + Assert.IsInstanceOfType(result); + _productServiceMock.Verify(s => s.DeleteTierPrice(It.IsAny(), "p1"), Times.Once); + } + + [TestMethod] + public async Task TierPriceDelete_ScopeGrantsAccess_TierPriceMissing_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.TierPriceDeleteModel("missing-tp", "p1"); + + await Assert.ThrowsExactlyAsync(() => _controller.TierPriceDelete(model)); + } +} diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Prices.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Prices.cshtml index 75053fe5e..ad9d3fe84 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Prices.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Prices.cshtml @@ -63,14 +63,14 @@ { - @Loc["Admin.Catalog.Products.TierPrices.AddNew"] + @Loc["Admin.Catalog.Products.TierPrices.AddNew"] @* Delete posts the whole row; the Kendo parameterMap posted only Id and ProductId, which are the fields TierPriceDeleteModel binds *@ @@ -92,7 +92,7 @@ -  {{ $texts.edit }} +  {{ $texts.edit }} diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.TierPrice.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/ProductPrice/Partials/WidgetZone.TierPrice.Bottom.cshtml similarity index 100% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.TierPrice.Bottom.cshtml rename to src/Web/Grand.Web.Admin/Areas/Admin/Views/ProductPrice/Partials/WidgetZone.TierPrice.Bottom.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.TierPrice.Buttons.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/ProductPrice/Partials/WidgetZone.TierPrice.Buttons.cshtml similarity index 100% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.TierPrice.Buttons.cshtml rename to src/Web/Grand.Web.Admin/Areas/Admin/Views/ProductPrice/Partials/WidgetZone.TierPrice.Buttons.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.TierPrice.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/ProductPrice/Partials/WidgetZone.TierPrice.Top.cshtml similarity index 100% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.TierPrice.Top.cshtml rename to src/Web/Grand.Web.Admin/Areas/Admin/Views/ProductPrice/Partials/WidgetZone.TierPrice.Top.cshtml diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductPriceController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductPriceController.cs new file mode 100644 index 000000000..558d3949e --- /dev/null +++ b/src/Web/Grand.Web.Admin/Controllers/ProductPriceController.cs @@ -0,0 +1,23 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Domain.Catalog; +using Grand.Web.Admin.Extensions; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Admin.Controllers; + +[AuthorizeAdmin] +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaAdmin)] +[AuthorizeMenu] +public class ProductPriceController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IDateTimeService dateTimeService, + IAdminDataScope scope) + : BaseProductPriceController(productViewModelService, productService, translationService, dateTimeService, scope); diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index e493675f1..72aac0992 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -691,290 +691,6 @@ private async Task> FilterBulkEditProductsByAccess( #endregion - #region Product currency price - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductPriceList(DataSourceRequest command, string productId) - { - var product = await productService.GetProductById(productId); - - // CanView on this read: a product shared with other stores opens read-only on Store, so its - // tabs must load; the mutating actions below keep the strict HasAccess. History: - // HasAccess (strict) mirrors Store's CanAccessProduct (AccessToEntityByStore) check - // on this action. Applying it uniformly also closes real gaps on the mutate actions below: Store's - // original checked access only on List/Insert (ProductPriceUpdate/ProductPriceDelete had no check - // at all), and Vendor's original checked access only on List (ProductPriceInsert/Update/Delete had - // no check at all) - both let another party's product prices be updated/deleted by id. - if (!await scope.CanView(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var items = new List(); - foreach (var item in product.ProductPrices) - items.Add(new ProductModel.ProductPriceModel { - Id = item.Id, - CurrencyCode = item.CurrencyCode, - Price = item.Price, - ProductId = product.Id - }); - - var gridModel = new DataSourceResult { - Data = items, - Total = items.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductPriceInsert(ProductModel.ProductPriceModel model) - { - var product = await productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (product.ProductPrices.Any(x => x.CurrencyCode == model.CurrencyCode)) - throw new ArgumentException("Currency code exists"); - - if (ModelState.IsValid) - try - { - await productService.InsertProductPrice(new ProductPrice { - ProductId = product.Id, - CurrencyCode = model.CurrencyCode, - Price = model.Price - }); - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductPriceUpdate(ProductModel.ProductPriceModel model) - { - var product = await productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var productPrice = product.ProductPrices.FirstOrDefault(x => x.Id == model.Id); - if (productPrice == null) - throw new ArgumentException("Product price model not exists"); - - if (product.ProductPrices.Any(x => x.Id != model.Id && x.CurrencyCode == model.CurrencyCode)) - throw new ArgumentException("You can't use this currency code"); - - if (ModelState.IsValid) - try - { - productPrice!.CurrencyCode = model.CurrencyCode; - productPrice.Price = model.Price; - productPrice.ProductId = model.ProductId; - - await productService.UpdateProductPrice(productPrice); - - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductPriceDelete(ProductModel.ProductPriceModel model) - { - var product = await productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var productPrice = product.ProductPrices.FirstOrDefault(x => x.Id == model.Id); - if (productPrice == null) - throw new ArgumentException("Product price model not exists"); - - if (ModelState.IsValid) - { - productPrice!.ProductId = model.ProductId; - await productService.DeleteProductPrice(productPrice); - - return new JsonResult(""); - } - - // ErrorForKendoGridJson(ModelState), not Content(ModelState.GetErrors()): matches Admin/Store. - // Vendor's original used the Vendor-only GetErrors() extension here (and inconsistently, since - // several of Vendor's *other* grid actions in this same file already use ErrorForKendoGridJson) - - // not a deliberate host-specific contract, just Vendor's own inconsistency. AdminShared cannot - // reference Grand.Web.Vendor.Extensions.ModelStateExtensions.GetErrors() from this project anyway. - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Tier prices - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task TierPriceList(DataSourceRequest command, string productId) - { - var product = await productService.GetProductById(productId); - - // CanView on this read: a product shared with other stores opens read-only on Store, so its - // tabs must load; the mutating actions below keep the strict HasAccess. History: - // HasAccess on List (Store/Vendor both checked here; Admin's Global scope is a no-op). Closes the - // same class of gap as "Product currency price": Vendor's original checked ownership only on List - // and TierPriceEditPopup(GET) - TierPriceCreatePopup(POST), TierPriceEditPopup(POST) and - // TierPriceDelete had NO ownership check at all, so a vendor could create/update/delete a tier - // price on any product (not just their own) by posting a known productId. Applying scope.HasAccess - // uniformly on every mutating action below closes that. - if (!await scope.CanView(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - // PrepareTierPriceModel(product) now reads scope internally (Task 9); scope.DefaultStoreId - // is null for Admin/Vendor (Global/VendorProduct scopes) and the staff store for Store. - var tierPricesModel = await productViewModelService.PrepareTierPriceModel(product); - var gridModel = new DataSourceResult { - Data = tierPricesModel, - Total = tierPricesModel.Count - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task TierPriceCreatePopup(string productId) - { - var model = new ProductModel.TierPriceModel { - ProductId = productId - }; - await productViewModelService.PrepareTierPriceModel(model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task TierPriceCreatePopup(ProductModel.TierPriceModel model) - { - if (ModelState.IsValid) - { - var product = await productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - // Vendor's original never even loaded the product here - it inserted straight off - // model.ProductId with no ownership check at all. See the List-action comment above. - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var tierPrice = model.ToEntity(dateTimeService); - await productService.InsertTierPrice(tierPrice, product.Id); - - return Content(""); - } - - Error(ModelState); - //If we got this far, something failed, redisplay form - await productViewModelService.PrepareTierPriceModel(model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task TierPriceEditPopup(string id, string productId) - { - var product = await productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - // HasAccess here too: Store's original never checked ownership on this GET action (only on the - // List/Create-POST/Edit-POST/Delete siblings), which would let store staff open (read-only, - // via this popup) the tier-price edit view for a product outside their store. Vendor's original did - // check (HasAccessToProduct), so this closes Store's gap while keeping Vendor's existing behavior. - if (!await scope.HasAccess(product)) - return Content("This is not your product"); - - var tierPrice = product.TierPrices.FirstOrDefault(x => x.Id == id); - if (tierPrice == null) - return Content("Empty tier price"); - - var model = tierPrice.ToModel(dateTimeService); - model.ProductId = productId; - await productViewModelService.PrepareTierPriceModel(model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task TierPriceEditPopup(string productId, ProductModel.TierPriceModel model) - { - if (ModelState.IsValid) - { - var product = await productService.GetProductById(productId, true); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - // See the List-action comment: Vendor's original had no ownership check on this POST at all. - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var tierPrice = product.TierPrices.FirstOrDefault(x => x.Id == model.Id); - if (tierPrice == null) - return Content("Empty tier price"); - - tierPrice = model.ToEntity(tierPrice, dateTimeService); - await productService.UpdateTierPrice(tierPrice, product.Id); - - return Content(""); - } - - Error(ModelState); - //stores - await productViewModelService.PrepareTierPriceModel(model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task TierPriceDelete(ProductModel.TierPriceDeleteModel model) - { - if (ModelState.IsValid) - { - var product = await productService.GetProductById(model.ProductId, true); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - // See the List-action comment: Vendor's original had no ownership check on Delete at all. - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var tierPrice = product.TierPrices.FirstOrDefault(x => x.Id == model.Id); - if (tierPrice == null) - throw new ArgumentException("No tier price found with the specified id"); - - await productService.DeleteTierPrice(tierPrice, product.Id); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - #region Product attributes [PermissionAuthorizeAction(PermissionActionName.Preview)] diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductPriceController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductPriceController.cs new file mode 100644 index 000000000..a0801ea91 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductPriceController.cs @@ -0,0 +1,312 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Domain.Catalog; +using Grand.Domain.Permissions; +using Grand.SharedKernel.Extensions; +using Grand.Web.AdminShared.Extensions.Mapping; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Extensions; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.AdminShared.Controllers; + +[PermissionAuthorize(PermissionSystemName.Products)] +[AutoValidateAntiforgeryToken] +public abstract class BaseProductPriceController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IDateTimeService dateTimeService, + IAdminDataScope scope) + : BaseController +{ + #region Product currency price + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ProductPriceList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // CanView on this read: a product shared with other stores opens read-only on Store, so its + // tabs must load; the mutating actions below keep the strict HasAccess. History: + // HasAccess (strict) mirrors Store's CanAccessProduct (AccessToEntityByStore) check + // on this action. Applying it uniformly also closes real gaps on the mutate actions below: Store's + // original checked access only on List/Insert (ProductPriceUpdate/ProductPriceDelete had no check + // at all), and Vendor's original checked access only on List (ProductPriceInsert/Update/Delete had + // no check at all) - both let another party's product prices be updated/deleted by id. + if (!await scope.CanView(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var items = new List(); + foreach (var item in product.ProductPrices) + items.Add(new ProductModel.ProductPriceModel { + Id = item.Id, + CurrencyCode = item.CurrencyCode, + Price = item.Price, + ProductId = product.Id + }); + + var gridModel = new DataSourceResult { + Data = items, + Total = items.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductPriceInsert(ProductModel.ProductPriceModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (product.ProductPrices.Any(x => x.CurrencyCode == model.CurrencyCode)) + throw new ArgumentException("Currency code exists"); + + if (ModelState.IsValid) + try + { + await productService.InsertProductPrice(new ProductPrice { + ProductId = product.Id, + CurrencyCode = model.CurrencyCode, + Price = model.Price + }); + return new JsonResult(""); + } + catch (Exception ex) + { + return ErrorForKendoGridJson(ex.Message); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductPriceUpdate(ProductModel.ProductPriceModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productPrice = product.ProductPrices.FirstOrDefault(x => x.Id == model.Id); + if (productPrice == null) + throw new ArgumentException("Product price model not exists"); + + if (product.ProductPrices.Any(x => x.Id != model.Id && x.CurrencyCode == model.CurrencyCode)) + throw new ArgumentException("You can't use this currency code"); + + if (ModelState.IsValid) + try + { + productPrice!.CurrencyCode = model.CurrencyCode; + productPrice.Price = model.Price; + productPrice.ProductId = model.ProductId; + + await productService.UpdateProductPrice(productPrice); + + return new JsonResult(""); + } + catch (Exception ex) + { + return ErrorForKendoGridJson(ex.Message); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductPriceDelete(ProductModel.ProductPriceModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productPrice = product.ProductPrices.FirstOrDefault(x => x.Id == model.Id); + if (productPrice == null) + throw new ArgumentException("Product price model not exists"); + + if (ModelState.IsValid) + { + productPrice!.ProductId = model.ProductId; + await productService.DeleteProductPrice(productPrice); + + return new JsonResult(""); + } + + // ErrorForKendoGridJson(ModelState), not Content(ModelState.GetErrors()): matches Admin/Store. + // Vendor's original used the Vendor-only GetErrors() extension here (and inconsistently, since + // several of Vendor's *other* grid actions in this same file already use ErrorForKendoGridJson) - + // not a deliberate host-specific contract, just Vendor's own inconsistency. AdminShared cannot + // reference Grand.Web.Vendor.Extensions.ModelStateExtensions.GetErrors() from this project anyway. + return ErrorForKendoGridJson(ModelState); + } + + #endregion + + #region Tier prices + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task TierPriceList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // CanView on this read: a product shared with other stores opens read-only on Store, so its + // tabs must load; the mutating actions below keep the strict HasAccess. History: + // HasAccess on List (Store/Vendor both checked here; Admin's Global scope is a no-op). Closes the + // same class of gap as "Product currency price": Vendor's original checked ownership only on List + // and TierPriceEditPopup(GET) - TierPriceCreatePopup(POST), TierPriceEditPopup(POST) and + // TierPriceDelete had NO ownership check at all, so a vendor could create/update/delete a tier + // price on any product (not just their own) by posting a known productId. Applying scope.HasAccess + // uniformly on every mutating action below closes that. + if (!await scope.CanView(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + // PrepareTierPriceModel(product) now reads scope internally (Task 9); scope.DefaultStoreId + // is null for Admin/Vendor (Global/VendorProduct scopes) and the staff store for Store. + var tierPricesModel = await productViewModelService.PrepareTierPriceModel(product); + var gridModel = new DataSourceResult { + Data = tierPricesModel, + Total = tierPricesModel.Count + }; + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task TierPriceCreatePopup(string productId) + { + var model = new ProductModel.TierPriceModel { + ProductId = productId + }; + await productViewModelService.PrepareTierPriceModel(model); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task TierPriceCreatePopup(ProductModel.TierPriceModel model) + { + if (ModelState.IsValid) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // Vendor's original never even loaded the product here - it inserted straight off + // model.ProductId with no ownership check at all. See the List-action comment above. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var tierPrice = model.ToEntity(dateTimeService); + await productService.InsertTierPrice(tierPrice, product.Id); + + return Content(""); + } + + Error(ModelState); + //If we got this far, something failed, redisplay form + await productViewModelService.PrepareTierPriceModel(model); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task TierPriceEditPopup(string id, string productId) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // HasAccess here too: Store's original never checked ownership on this GET action (only on the + // List/Create-POST/Edit-POST/Delete siblings), which would let store staff open (read-only, + // via this popup) the tier-price edit view for a product outside their store. Vendor's original did + // check (HasAccessToProduct), so this closes Store's gap while keeping Vendor's existing behavior. + if (!await scope.HasAccess(product)) + return Content("This is not your product"); + + var tierPrice = product.TierPrices.FirstOrDefault(x => x.Id == id); + if (tierPrice == null) + return Content("Empty tier price"); + + var model = tierPrice.ToModel(dateTimeService); + model.ProductId = productId; + await productViewModelService.PrepareTierPriceModel(model); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task TierPriceEditPopup(string productId, ProductModel.TierPriceModel model) + { + if (ModelState.IsValid) + { + var product = await productService.GetProductById(productId, true); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // See the List-action comment: Vendor's original had no ownership check on this POST at all. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var tierPrice = product.TierPrices.FirstOrDefault(x => x.Id == model.Id); + if (tierPrice == null) + return Content("Empty tier price"); + + tierPrice = model.ToEntity(tierPrice, dateTimeService); + await productService.UpdateTierPrice(tierPrice, product.Id); + + return Content(""); + } + + Error(ModelState); + //stores + await productViewModelService.PrepareTierPriceModel(model); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task TierPriceDelete(ProductModel.TierPriceDeleteModel model) + { + if (ModelState.IsValid) + { + var product = await productService.GetProductById(model.ProductId, true); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // See the List-action comment: Vendor's original had no ownership check on Delete at all. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var tierPrice = product.TierPrices.FirstOrDefault(x => x.Id == model.Id); + if (tierPrice == null) + throw new ArgumentException("No tier price found with the specified id"); + + await productService.DeleteTierPrice(tierPrice, product.Id); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + #endregion +} diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.ProductPrices.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.ProductPrices.cshtml index e1aea93c8..331ec9a4f 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.ProductPrices.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.ProductPrices.cshtml @@ -17,10 +17,10 @@ @*loaded by tabstrip_on_tab_show when the Prices tab opens*@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/TierPriceEditPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductPrice/TierPriceEditPopup.cshtml similarity index 97% rename from src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/TierPriceEditPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductPrice/TierPriceEditPopup.cshtml index 1384b09f3..10c113839 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/TierPriceEditPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductPrice/TierPriceEditPopup.cshtml @@ -8,7 +8,7 @@ ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.TierPrices.Edit"]; } - diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Prices.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Prices.cshtml index 8695488a4..3f0f532a8 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Prices.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Prices.cshtml @@ -71,7 +71,7 @@ @if (!readOnly) { - @Loc["Admin.Catalog.Products.TierPrices.AddNew"] + @Loc["Admin.Catalog.Products.TierPrices.AddNew"] } @@ -79,8 +79,8 @@ fields TierPriceDeleteModel binds *@ @@ -104,7 +104,7 @@ { -  {{ $texts.edit }} +  {{ $texts.edit }} } diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.TierPrice.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/ProductPrice/Partials/WidgetZone.TierPrice.Bottom.cshtml similarity index 100% rename from src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.TierPrice.Bottom.cshtml rename to src/Web/Grand.Web.Store/Areas/Store/Views/ProductPrice/Partials/WidgetZone.TierPrice.Bottom.cshtml diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.TierPrice.Buttons.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/ProductPrice/Partials/WidgetZone.TierPrice.Buttons.cshtml similarity index 100% rename from src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.TierPrice.Buttons.cshtml rename to src/Web/Grand.Web.Store/Areas/Store/Views/ProductPrice/Partials/WidgetZone.TierPrice.Buttons.cshtml diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.TierPrice.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/ProductPrice/Partials/WidgetZone.TierPrice.Top.cshtml similarity index 100% rename from src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.TierPrice.Top.cshtml rename to src/Web/Grand.Web.Store/Areas/Store/Views/ProductPrice/Partials/WidgetZone.TierPrice.Top.cshtml diff --git a/src/Web/Grand.Web.Store/Controllers/ProductPriceController.cs b/src/Web/Grand.Web.Store/Controllers/ProductPriceController.cs new file mode 100644 index 000000000..175241a06 --- /dev/null +++ b/src/Web/Grand.Web.Store/Controllers/ProductPriceController.cs @@ -0,0 +1,23 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Store.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Store.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaStore)] +[AuthorizeStore] +[AuthorizeMenu] +public class ProductPriceController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IDateTimeService dateTimeService, + IAdminDataScope scope) + : BaseProductPriceController(productViewModelService, productService, translationService, dateTimeService, scope); diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Prices.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Prices.cshtml index 08d34d19b..4f8ef07d9 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Prices.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Prices.cshtml @@ -64,12 +64,12 @@ { - @Loc["Vendor.Catalog.Products.TierPrices.AddNew"] + @Loc["Vendor.Catalog.Products.TierPrices.AddNew"] @@ -82,7 +82,7 @@ -  {{ $texts.edit }} +  {{ $texts.edit }} diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/TierPrice.StoreScope.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/ProductPrice/Partials/TierPrice.StoreScope.cshtml similarity index 100% rename from src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/TierPrice.StoreScope.cshtml rename to src/Web/Grand.Web.Vendor/Areas/Vendor/Views/ProductPrice/Partials/TierPrice.StoreScope.cshtml diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.TierPrice.Bottom.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/ProductPrice/Partials/WidgetZone.TierPrice.Bottom.cshtml similarity index 100% rename from src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.TierPrice.Bottom.cshtml rename to src/Web/Grand.Web.Vendor/Areas/Vendor/Views/ProductPrice/Partials/WidgetZone.TierPrice.Bottom.cshtml diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.TierPrice.Buttons.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/ProductPrice/Partials/WidgetZone.TierPrice.Buttons.cshtml similarity index 100% rename from src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.TierPrice.Buttons.cshtml rename to src/Web/Grand.Web.Vendor/Areas/Vendor/Views/ProductPrice/Partials/WidgetZone.TierPrice.Buttons.cshtml diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.TierPrice.Top.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/ProductPrice/Partials/WidgetZone.TierPrice.Top.cshtml similarity index 100% rename from src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.TierPrice.Top.cshtml rename to src/Web/Grand.Web.Vendor/Areas/Vendor/Views/ProductPrice/Partials/WidgetZone.TierPrice.Top.cshtml diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductPriceController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductPriceController.cs new file mode 100644 index 000000000..226c25ba1 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductPriceController.cs @@ -0,0 +1,23 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Vendor.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Vendor.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaVendor)] +[AuthorizeVendor] +[AuthorizeMenu] +public class ProductPriceController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IDateTimeService dateTimeService, + IAdminDataScope scope) + : BaseProductPriceController(productViewModelService, productService, translationService, dateTimeService, scope); From 0523856a0857bb2b939a6ddf3df4519fcf524915 Mon Sep 17 00:00:00 2001 From: support Date: Thu, 22 Oct 2026 06:39:40 +0200 Subject: [PATCH 10/27] Move the product attributes tab into its own controller --- ...eProductAttributeMappingControllerTests.cs | 1157 +++++++++++++++++ .../Controllers/BaseProductControllerTests.cs | 1117 ---------------- .../WidgetZone.AttributeValueButtons.cshtml | 0 .../ProductAttributeMappingController.cs | 23 + .../BaseProductAttributeMappingController.cs | 513 ++++++++ .../Controllers/BaseProductController.cs | 485 ------- ...ate.ProductAttributes.TabAttributes.cshtml | 20 +- ...sociateProductToAttributeValuePopup.cshtml | 4 +- ...CreateOrUpdateProductAttributeValue.cshtml | 2 +- .../WidgetZone.AttributeValueButtons.cshtml | 0 .../ProductAttributeConditionPopup.cshtml | 2 +- .../ProductAttributeMappingPopup.cshtml | 2 +- ...roductAttributeValidationRulesPopup.cshtml | 2 +- .../ProductAttributeValueCreatePopup.cshtml | 2 +- .../ProductAttributeValueEditPopup.cshtml | 2 +- .../WidgetZone.AttributeValueButtons.cshtml | 0 .../ProductAttributeMappingController.cs | 23 + .../WidgetZone.AttributeValueButtons.cshtml | 0 .../ProductAttributeMappingController.cs | 23 + 19 files changed, 1757 insertions(+), 1620 deletions(-) create mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAttributeMappingControllerTests.cs rename src/Web/Grand.Web.Admin/Areas/Admin/Views/{Product => ProductAttributeMapping}/Partials/WidgetZone.AttributeValueButtons.cshtml (100%) create mode 100644 src/Web/Grand.Web.Admin/Controllers/ProductAttributeMappingController.cs create mode 100644 src/Web/Grand.Web.AdminShared/Controllers/BaseProductAttributeMappingController.cs rename src/Web/Grand.Web.AdminShared/Views/AdminShared/{Product => ProductAttributeMapping}/AssociateProductToAttributeValuePopup.cshtml (96%) rename src/Web/Grand.Web.AdminShared/Views/AdminShared/{Product => ProductAttributeMapping}/Partials/CreateOrUpdateProductAttributeValue.cshtml (96%) rename src/Web/Grand.Web.AdminShared/Views/AdminShared/{Product => ProductAttributeMapping}/Partials/WidgetZone.AttributeValueButtons.cshtml (100%) rename src/Web/Grand.Web.AdminShared/Views/AdminShared/{Product => ProductAttributeMapping}/ProductAttributeConditionPopup.cshtml (98%) rename src/Web/Grand.Web.AdminShared/Views/AdminShared/{Product => ProductAttributeMapping}/ProductAttributeMappingPopup.cshtml (96%) rename src/Web/Grand.Web.AdminShared/Views/AdminShared/{Product => ProductAttributeMapping}/ProductAttributeValidationRulesPopup.cshtml (95%) rename src/Web/Grand.Web.AdminShared/Views/AdminShared/{Product => ProductAttributeMapping}/ProductAttributeValueCreatePopup.cshtml (95%) rename src/Web/Grand.Web.AdminShared/Views/AdminShared/{Product => ProductAttributeMapping}/ProductAttributeValueEditPopup.cshtml (95%) rename src/Web/Grand.Web.Store/Areas/Store/Views/{Product => ProductAttributeMapping}/Partials/WidgetZone.AttributeValueButtons.cshtml (100%) create mode 100644 src/Web/Grand.Web.Store/Controllers/ProductAttributeMappingController.cs rename src/Web/Grand.Web.Vendor/Areas/Vendor/Views/{Product => ProductAttributeMapping}/Partials/WidgetZone.AttributeValueButtons.cshtml (100%) create mode 100644 src/Web/Grand.Web.Vendor/Controllers/ProductAttributeMappingController.cs diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAttributeMappingControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAttributeMappingControllerTests.cs new file mode 100644 index 000000000..5f44fcffa --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAttributeMappingControllerTests.cs @@ -0,0 +1,1157 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Domain; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Linq.Expressions; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class BaseProductAttributeMappingControllerTests : ProductControllerTestsBase +{ + // BaseProductAttributeMappingController is abstract. This minimal subclass exists only so the actions under test + // can be invoked directly. + public class TestProductAttributeMappingController( + IProductViewModelService productViewModelService, + IProductService productService, + ILanguageService languageService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductAttributeMappingController(productViewModelService, productService, languageService, translationService, scope); + + protected override TestProductAttributeMappingController CreateController() => new( + _productViewModelServiceMock.Object, + _productServiceMock.Object, + _languageServiceMock.Object, + _translationServiceMock.Object, + _scopeMock.Object); + + // --- ProductAttributeMappingList ------------------------------------------------------------- + // HasAccess applied uniformly on every action in this region. Store's original checked ownership + // (CanAccessProduct) on List/PopupGET/PopupPOST/Delete/ValidationRulesPopupGET but not on + // ValidationRulesPopup(POST). Vendor's original checked (CheckAccessToProduct/HasAccessToProduct) on + // List/PopupGET/Delete/ValidationRulesPopupGET, but NOT on ProductAttributeMappingPopup(POST) or + // ValidationRulesPopup(POST) - letting a vendor edit an attribute mapping (or its validation rules) on + // any product, not just their own, by posting a known productId. + + [TestMethod] + public async Task ProductAttributeMappingList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeMappingList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.PrepareProductAttributeMappingModels(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeMappingList_ScopeDeniesAccess_UsesScopeResourceKeyPrefix() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); + + var result = await _controller.ProductAttributeMappingList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeMappingList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + var attributes = new List { new() { Id = "pam1" } }; + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModels(product)) + .ReturnsAsync(attributes); + + var result = await _controller.ProductAttributeMappingList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- ProductAttributeMappingPopup (GET) ------------------------------------------------------ + + [TestMethod] + public async Task ProductAttributeMappingPopup_Get_ScopeDeniesAccess_ReturnsPermissionsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeMappingPopup("p1", null); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.PrepareProductAttributeMappingModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeMappingPopup_Get_ScopeGrantsAccess_NoMappingId_PreparesNewModel() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModel(product)) + .ReturnsAsync(new ProductModel.ProductAttributeMappingModel()); + + var result = await _controller.ProductAttributeMappingPopup("p1", null); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeMappingModel(product), Times.Once); + } + + [TestMethod] + public async Task ProductAttributeMappingPopup_Get_ScopeGrantsAccess_MappingId_PreparesEditModel() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModel(product, mapping)) + .ReturnsAsync(new ProductModel.ProductAttributeMappingModel()); + + var result = await _controller.ProductAttributeMappingPopup("p1", "pam1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeMappingModel(product, mapping), Times.Once); + } + + // --- ProductAttributeMappingPopup (POST) ----------------------------------------------------- + // HasAccess added here: Vendor's original had no ownership check on this POST at all. + + [TestMethod] + public async Task ProductAttributeMappingPopup_Post_ScopeDeniesAccess_DoesNotInsertOrUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductAttributeMappingModel { ProductId = "p1" }; + + var result = await _controller.ProductAttributeMappingPopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.InsertProductAttributeMappingModel(It.IsAny()), + Times.Never); + _productViewModelServiceMock.Verify( + s => s.UpdateProductAttributeMappingModel(It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeMappingPopup_Post_ScopeGrantsAccess_NoId_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeMappingModel { ProductId = "p1" }; + + var result = await _controller.ProductAttributeMappingPopup(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.InsertProductAttributeMappingModel(model), Times.Once); + _productViewModelServiceMock.Verify( + s => s.UpdateProductAttributeMappingModel(It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeMappingPopup_Post_ScopeGrantsAccess_WithId_Updates() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeMappingModel { Id = "pam1", ProductId = "p1" }; + + var result = await _controller.ProductAttributeMappingPopup(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.UpdateProductAttributeMappingModel(model), Times.Once); + } + + [TestMethod] + public async Task ProductAttributeMappingPopup_Post_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var model = new ProductModel.ProductAttributeMappingModel { ProductId = "missing" }; + + await Assert.ThrowsExactlyAsync(() => _controller.ProductAttributeMappingPopup(model)); + } + + [TestMethod] + public async Task ProductAttributeMappingPopup_Post_InvalidModelState_ReturnsView_DoesNotCheckAccess() + { + _controller.ModelState.AddModelError("x", "err"); + var model = new ProductModel.ProductAttributeMappingModel { ProductId = "p1" }; + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModel(model)).ReturnsAsync(model); + + var result = await _controller.ProductAttributeMappingPopup(model); + + Assert.IsInstanceOfType(result); + _productServiceMock.Verify(p => p.GetProductById(It.IsAny(), It.IsAny()), Times.Never); + _scopeMock.Verify(s => s.HasAccess(It.IsAny()), Times.Never); + } + + // --- ProductAttributeMappingDelete ----------------------------------------------------------- + + [TestMethod] + public async Task ProductAttributeMappingDelete_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var attrServiceMock = new Mock(); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeMappingDelete("pam1", "missing", attrServiceMock.Object)); + } + + [TestMethod] + public async Task ProductAttributeMappingDelete_MappingNotFound_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + var attrServiceMock = new Mock(); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeMappingDelete("missing-pam", "p1", attrServiceMock.Object)); + } + + [TestMethod] + public async Task ProductAttributeMappingDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var attrServiceMock = new Mock(); + + var result = await _controller.ProductAttributeMappingDelete("pam1", "p1", attrServiceMock.Object); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + attrServiceMock.Verify( + s => s.DeleteProductAttributeMapping(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeMappingDelete_ScopeGrantsAccess_Deletes() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var attrServiceMock = new Mock(); + + var result = await _controller.ProductAttributeMappingDelete("pam1", "p1", attrServiceMock.Object); + + Assert.IsInstanceOfType(result); + attrServiceMock.Verify(s => s.DeleteProductAttributeMapping(mapping, "p1"), Times.Once); + } + + // --- ProductAttributeValidationRulesPopup (GET) ----------------------------------------------- + + [TestMethod] + public async Task ProductAttributeValidationRulesPopup_Get_ScopeDeniesAccess_ReturnsPermissionsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeValidationRulesPopup("pam1", "p1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task ProductAttributeValidationRulesPopup_Get_ScopeGrantsAccess_MappingMissing_ReturnsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.ProductAttributeValidationRulesPopup("missing-pam", "p1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("No attribute value found with the specified id", content.Content); + } + + [TestMethod] + public async Task ProductAttributeValidationRulesPopup_Get_ScopeGrantsAccess_PreparesModel() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModel(mapping)) + .ReturnsAsync(new ProductModel.ProductAttributeMappingModel()); + + var result = await _controller.ProductAttributeValidationRulesPopup("pam1", "p1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeMappingModel(mapping), Times.Once); + } + + // --- ProductAttributeValidationRulesPopup (POST) ---------------------------------------------- + // HasAccess added here: none of the three original hosts checked ownership on this POST at all - a + // store/vendor user could update an attribute mapping's validation rules on any product by posting a + // known productId/model.Id. + + [TestMethod] + public async Task ProductAttributeValidationRulesPopup_Post_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var model = new ProductModel.ProductAttributeMappingModel { ProductId = "missing" }; + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeValidationRulesPopup(model)); + } + + [TestMethod] + public async Task ProductAttributeValidationRulesPopup_Post_MappingNotFound_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + var model = new ProductModel.ProductAttributeMappingModel { Id = "missing-pam", ProductId = "p1" }; + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeValidationRulesPopup(model)); + } + + [TestMethod] + public async Task ProductAttributeValidationRulesPopup_Post_ScopeDeniesAccess_ReturnsPermissionsContent_DoesNotUpdate() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductAttributeMappingModel { Id = "pam1", ProductId = "p1" }; + + var result = await _controller.ProductAttributeValidationRulesPopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.UpdateProductAttributeValidationRulesModel(It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeValidationRulesPopup_Post_ScopeGrantsAccess_ValidModel_Updates() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeMappingModel { Id = "pam1", ProductId = "p1" }; + + var result = await _controller.ProductAttributeValidationRulesPopup(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.UpdateProductAttributeValidationRulesModel(mapping, model), Times.Once); + } + + [TestMethod] + public async Task ProductAttributeValidationRulesPopup_Post_ScopeGrantsAccess_InvalidModelState_ReturnsView() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _controller.ModelState.AddModelError("x", "err"); + var model = new ProductModel.ProductAttributeMappingModel { Id = "pam1", ProductId = "p1" }; + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModel(mapping)).ReturnsAsync(model); + + var result = await _controller.ProductAttributeValidationRulesPopup(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.UpdateProductAttributeValidationRulesModel(It.IsAny(), + It.IsAny()), Times.Never); + } + + // --- ProductAttributeConditionPopup (GET) ---------------------------------------------------- + // HasAccess added uniformly. Admin's original had no ownership check on either action of this + // region at all. + + [TestMethod] + public async Task ProductAttributeConditionPopup_Get_ScopeDeniesAccess_ReturnsPermissionsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeConditionPopup("p1", "pam1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.PrepareProductAttributeConditionModel(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeConditionPopup_Get_ScopeDeniesAccess_UsesScopeResourceKeyPrefix() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); + + var result = await _controller.ProductAttributeConditionPopup("p1", "pam1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeConditionPopup_Get_ScopeGrantsAccess_MappingMissing_ReturnsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.ProductAttributeConditionPopup("p1", "missing-pam"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("No attribute value found with the specified id", content.Content); + _productViewModelServiceMock.Verify( + s => s.PrepareProductAttributeConditionModel(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeConditionPopup_Get_ScopeGrantsAccess_PreparesModel() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeConditionModel(product, mapping)) + .ReturnsAsync(new ProductAttributeConditionModel()); + + var result = await _controller.ProductAttributeConditionPopup("p1", "pam1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeConditionModel(product, mapping), + Times.Once); + } + + // --- ProductAttributeConditionPopup (POST) --------------------------------------------------- + // HasAccess added here: Vendor's original never checked ownership on this POST at all (only its + // GET sibling did, via CheckAccessToProduct), letting a vendor update an attribute condition on + // any product by posting a known productId/productAttributeMappingId. Admin's original had no + // check on either action. + + [TestMethod] + public async Task ProductAttributeConditionPopup_Post_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var model = new ProductAttributeConditionModel { ProductId = "missing" }; + + await Assert.ThrowsExactlyAsync(() => _controller.ProductAttributeConditionPopup(model)); + } + + [TestMethod] + public async Task ProductAttributeConditionPopup_Post_MappingNotFound_ReturnsContent_DoesNotCheckAccess() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + var model = new ProductAttributeConditionModel { ProductId = "p1", ProductAttributeMappingId = "missing-pam" }; + + var result = await _controller.ProductAttributeConditionPopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("No attribute value found with the specified id", content.Content); + _scopeMock.Verify(s => s.HasAccess(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeConditionPopup_Post_ScopeDeniesAccess_ReturnsPermissionsContent_DoesNotUpdate() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductAttributeConditionModel { ProductId = "p1", ProductAttributeMappingId = "pam1" }; + + var result = await _controller.ProductAttributeConditionPopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.UpdateProductAttributeConditionModel(It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeConditionPopup_Post_ScopeGrantsAccess_Updates() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductAttributeConditionModel { ProductId = "p1", ProductAttributeMappingId = "pam1" }; + + var result = await _controller.ProductAttributeConditionPopup(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.UpdateProductAttributeConditionModel(product, mapping, model), + Times.Once); + } + + // --- EditAttributeValues (GET) ------------------------------------------------------------------ + // HasAccess applied uniformly. Admin's original had no ownership check on this action at all. + + [TestMethod] + public async Task EditAttributeValues_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var attrServiceMock = new Mock(); + + await Assert.ThrowsExactlyAsync( + () => _controller.EditAttributeValues("pam1", "missing", attrServiceMock.Object)); + } + + [TestMethod] + public async Task EditAttributeValues_ScopeDeniesAccess_ReturnsPermissionsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var attrServiceMock = new Mock(); + + var result = await _controller.EditAttributeValues("pam1", "p1", attrServiceMock.Object); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task EditAttributeValues_ScopeGrantsAccess_MappingMissing_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var attrServiceMock = new Mock(); + + await Assert.ThrowsExactlyAsync( + () => _controller.EditAttributeValues("missing-pam", "p1", attrServiceMock.Object)); + } + + [TestMethod] + public async Task EditAttributeValues_ScopeGrantsAccess_PreparesListModel() + { + var mapping = new ProductAttributeMapping { Id = "pam1", ProductAttributeId = "pa1" }; + var product = new Product { Id = "p1", Name = "Product 1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var attrServiceMock = new Mock(); + attrServiceMock.Setup(s => s.GetProductAttributeById("pa1")) + .ReturnsAsync(new ProductAttribute { Id = "pa1", Name = "Color" }); + + var result = await _controller.EditAttributeValues("pam1", "p1", attrServiceMock.Object); + + var view = result as ViewResult; + Assert.IsNotNull(view); + var model = view.Model as ProductModel.ProductAttributeValueListModel; + Assert.IsNotNull(model); + Assert.AreEqual("p1", model.ProductId); + Assert.AreEqual("Product 1", model.ProductName); + Assert.AreEqual("Color", model.ProductAttributeName); + Assert.AreEqual("pam1", model.ProductAttributeMappingId); + } + + // --- ProductAttributeValueList (POST) ----------------------------------------------------------- + + [TestMethod] + public async Task ProductAttributeValueList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeValueList("pam1", "p1", new Grand.Web.Common.DataSource.DataSourceRequest()); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.PrepareProductAttributeValueModels(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeValueList_ScopeGrantsAccess_MappingMissing_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeValueList("missing-pam", "p1", new Grand.Web.Common.DataSource.DataSourceRequest())); + } + + [TestMethod] + public async Task ProductAttributeValueList_ScopeGrantsAccess_ReturnsGrid() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var values = new List { new() { Id = "pav1" } }; + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeValueModels(product, mapping)) + .ReturnsAsync(values); + + var result = await _controller.ProductAttributeValueList("pam1", "p1", new Grand.Web.Common.DataSource.DataSourceRequest()); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- ProductAttributeValueCreatePopup (GET) ----------------------------------------------------- + + [TestMethod] + public async Task ProductAttributeValueCreatePopup_Get_ScopeDeniesAccess_ReturnsPermissionsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeValueCreatePopup("pam1", "p1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.PrepareProductAttributeValueModel(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeValueCreatePopup_Get_ScopeGrantsAccess_MappingMissing_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeValueCreatePopup("missing-pam", "p1")); + } + + [TestMethod] + public async Task ProductAttributeValueCreatePopup_Get_ScopeGrantsAccess_PreparesModel() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeValueModel(product, mapping)) + .ReturnsAsync(new ProductModel.ProductAttributeValueModel()); + + var result = await _controller.ProductAttributeValueCreatePopup("pam1", "p1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeValueModel(product, mapping), Times.Once); + } + + // --- ProductAttributeValueCreatePopup (POST) ---------------------------------------------------- + // HasAccess added explicitly. Admin has no ownership concept at all (GlobalAdminDataScope.HasAccess is + // always true), so Admin was never at risk here. The real risk is to VENDOR: Vendor's original relied + // solely on ProductAttributeValueModel : IProductValidVendor triggering ValidationFilter's + // ProductValidVendor check + Vendor's own `if (ModelState.IsValid)` gate - no explicit action-level + // check. Moving to the shared AdminShared model (no IProductValidVendor) would have silently dropped + // that guard for Vendor; scope.HasAccess replaces it explicitly and uniformly. + + [TestMethod] + public async Task ProductAttributeValueCreatePopup_Post_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var model = new ProductModel.ProductAttributeValueModel { ProductId = "missing" }; + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeValueCreatePopup(model)); + } + + [TestMethod] + public async Task ProductAttributeValueCreatePopup_Post_ScopeDeniesAccess_ReturnsPermissionsContent_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductAttributeValueModel { ProductId = "p1" }; + + var result = await _controller.ProductAttributeValueCreatePopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.InsertProductAttributeValueModel(It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeValueCreatePopup_Post_ScopeDeniesAccess_UsesVendorResourceKeyPrefix() + { + // The host/action combination this row's fix actually protects: Vendor losing its + // IProductValidVendor-driven guard in the merge. See the comment above the HasAccess check in + // BaseProductController.ProductAttributeValueCreatePopup(POST). + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); + var model = new ProductModel.ProductAttributeValueModel { ProductId = "p1" }; + + var result = await _controller.ProductAttributeValueCreatePopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); + _productViewModelServiceMock.Verify( + s => s.InsertProductAttributeValueModel(It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeValueCreatePopup_Post_ScopeGrantsAccess_MappingMissing_RedirectsToList() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeValueModel { ProductId = "p1", ProductAttributeMappingId = "missing-pam" }; + + var result = await _controller.ProductAttributeValueCreatePopup(model); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + Assert.AreEqual("Product", redirect.ControllerName); + } + + [TestMethod] + public async Task ProductAttributeValueCreatePopup_Post_ScopeGrantsAccess_ValidModel_Inserts() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeValueModel { ProductId = "p1", ProductAttributeMappingId = "pam1" }; + + var result = await _controller.ProductAttributeValueCreatePopup(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.InsertProductAttributeValueModel(model), Times.Once); + } + + [TestMethod] + public async Task ProductAttributeValueCreatePopup_Post_ScopeGrantsAccess_InvalidModelState_ReturnsView() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _controller.ModelState.AddModelError("x", "err"); + var model = new ProductModel.ProductAttributeValueModel { ProductId = "p1", ProductAttributeMappingId = "pam1" }; + + var result = await _controller.ProductAttributeValueCreatePopup(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.InsertProductAttributeValueModel(It.IsAny()), + Times.Never); + _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeValueModel(product, model), Times.Once); + } + + // --- ProductAttributeValueEditPopup (GET) ------------------------------------------------------- + + [TestMethod] + public async Task ProductAttributeValueEditPopup_Get_ScopeDeniesAccess_ReturnsPermissionsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeValueEditPopup("pav1", "p1", "pam1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task ProductAttributeValueEditPopup_Get_ScopeGrantsAccess_MappingMissing_RedirectsToList() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.ProductAttributeValueEditPopup("pav1", "p1", "missing-pam"); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + } + + [TestMethod] + public async Task ProductAttributeValueEditPopup_Get_ScopeGrantsAccess_ValueMissing_RedirectsToList() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.ProductAttributeValueEditPopup("missing-pav", "p1", "pam1"); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + } + + [TestMethod] + public async Task ProductAttributeValueEditPopup_Get_ScopeGrantsAccess_PreparesModel() + { + var pav = new ProductAttributeValue { Id = "pav1" }; + var mapping = new ProductAttributeMapping { Id = "pam1" }; + mapping.ProductAttributeValues.Add(pav); + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeValueModel(); + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeValueModel(mapping, pav)) + .ReturnsAsync(model); + + var result = await _controller.ProductAttributeValueEditPopup("pav1", "p1", "pam1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeValueModel(mapping, pav), Times.Once); + _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeValueModel(product, model), Times.Once); + } + + // --- ProductAttributeValueEditPopup (POST) ------------------------------------------------------ + // HasAccess added here: Vendor's original never checked ownership on this POST at all (only its GET + // sibling did); Admin's original had no check on either action. + + [TestMethod] + public async Task ProductAttributeValueEditPopup_Post_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var model = new ProductModel.ProductAttributeValueModel(); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeValueEditPopup("missing", model)); + } + + [TestMethod] + public async Task ProductAttributeValueEditPopup_Post_ScopeDeniesAccess_ReturnsPermissionsContent_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductAttributeValueModel { Id = "pav1", ProductAttributeMappingId = "pam1" }; + + var result = await _controller.ProductAttributeValueEditPopup("p1", model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.UpdateProductAttributeValueModel(It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeValueEditPopup_Post_ScopeGrantsAccess_ValueMissing_RedirectsToList() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeValueModel { Id = "missing-pav", ProductAttributeMappingId = "pam1" }; + + var result = await _controller.ProductAttributeValueEditPopup("p1", model); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + } + + [TestMethod] + public async Task ProductAttributeValueEditPopup_Post_ScopeGrantsAccess_ValidModel_Updates() + { + var pav = new ProductAttributeValue { Id = "pav1" }; + var mapping = new ProductAttributeMapping { Id = "pam1" }; + mapping.ProductAttributeValues.Add(pav); + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeValueModel { Id = "pav1", ProductAttributeMappingId = "pam1" }; + + var result = await _controller.ProductAttributeValueEditPopup("p1", model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.UpdateProductAttributeValueModel(pav, model), Times.Once); + } + + [TestMethod] + public async Task ProductAttributeValueEditPopup_Post_ScopeGrantsAccess_InvalidModelState_ReturnsView() + { + var pav = new ProductAttributeValue { Id = "pav1" }; + var mapping = new ProductAttributeMapping { Id = "pam1" }; + mapping.ProductAttributeValues.Add(pav); + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _controller.ModelState.AddModelError("x", "err"); + var model = new ProductModel.ProductAttributeValueModel { Id = "pav1", ProductAttributeMappingId = "pam1" }; + + var result = await _controller.ProductAttributeValueEditPopup("p1", model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.UpdateProductAttributeValueModel(It.IsAny(), + It.IsAny()), Times.Never); + _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeValueModel(product, model), Times.Once); + } + + // --- ProductAttributeValueDelete ----------------------------------------------------------------- + // HasAccess added here: this action takes only simple string parameters, so no model-level validator + // ever runs for it. Admin's original had no ownership check at all. + + [TestMethod] + public async Task ProductAttributeValueDelete_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var attrServiceMock = new Mock(); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeValueDelete("pav1", "pam1", "missing", attrServiceMock.Object)); + } + + [TestMethod] + public async Task ProductAttributeValueDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var attrServiceMock = new Mock(); + + var result = await _controller.ProductAttributeValueDelete("pav1", "pam1", "p1", attrServiceMock.Object); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + attrServiceMock.Verify( + s => s.DeleteProductAttributeValue(It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeValueDelete_ScopeGrantsAccess_ValueMissing_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var attrServiceMock = new Mock(); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeValueDelete("missing-pav", "pam1", "p1", attrServiceMock.Object)); + } + + [TestMethod] + public async Task ProductAttributeValueDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var pav = new ProductAttributeValue { Id = "pav1" }; + var mapping = new ProductAttributeMapping { Id = "pam1" }; + mapping.ProductAttributeValues.Add(pav); + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var attrServiceMock = new Mock(); + + var result = await _controller.ProductAttributeValueDelete("pav1", "pam1", "p1", attrServiceMock.Object); + + Assert.IsInstanceOfType(result); + attrServiceMock.Verify(s => s.DeleteProductAttributeValue(pav, "p1", "pam1"), Times.Once); + } + + [TestMethod] + public async Task ProductAttributeValueDelete_ScopeGrantsAccess_InvalidModelState_ReturnsErrorJson_DoesNotDelete() + { + var pav = new ProductAttributeValue { Id = "pav1" }; + var mapping = new ProductAttributeMapping { Id = "pam1" }; + mapping.ProductAttributeValues.Add(pav); + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _controller.ModelState.AddModelError("x", "err"); + var attrServiceMock = new Mock(); + + var result = await _controller.ProductAttributeValueDelete("pav1", "pam1", "p1", attrServiceMock.Object); + + Assert.IsInstanceOfType(result); + attrServiceMock.Verify( + s => s.DeleteProductAttributeValue(It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + // --- AssociateProductToAttributeValuePopup (GET) ------------------------------------------------ + + [TestMethod] + public async Task AssociateProductToAttributeValuePopup_Get_CallsPrepareAssociateProductToAttributeValueModel() + { + // PrepareAssociateProductToAttributeValueModel() now reads scope internally (Task 9). + _productViewModelServiceMock.Setup(s => s.PrepareAssociateProductToAttributeValueModel()) + .ReturnsAsync(new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel()); + + var result = await _controller.AssociateProductToAttributeValuePopup(); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.PrepareAssociateProductToAttributeValueModel(), + Times.Once); + } + + // --- AssociateProductToAttributeValuePopupList (POST) ------------------------------------------- + + [TestMethod] + public async Task AssociateProductToAttributeValuePopupList_DefaultStoreIdSet_AppliesSearchStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store1"); + var model = new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel(); + _productViewModelServiceMock.Setup(s => s.PrepareProductModel(model, 1, 10)) + .ReturnsAsync((new List(), 0)); + + var result = await _controller.AssociateProductToAttributeValuePopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 1, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("store1", model.SearchStoreId); + } + + [TestMethod] + public async Task AssociateProductToAttributeValuePopupList_DefaultStoreIdNull_LeavesSearchStoreIdUntouched() + { + var model = new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel + { SearchStoreId = "preset" }; + _productViewModelServiceMock.Setup(s => s.PrepareProductModel(model, 1, 10)) + .ReturnsAsync((new List(), 0)); + + var result = await _controller.AssociateProductToAttributeValuePopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 1, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("preset", model.SearchStoreId); + } + + // --- AssociateProductToAttributeValuePopup (POST) ----------------------------------------------- + // HasAccess added here: Admin's original had no ownership check on the associated product at all. + + [TestMethod] + public async Task AssociateProductToAttributeValuePopup_Post_AssociatedProductMissing_ReturnsContent() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var model = new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel + { AssociatedToProductId = "missing" }; + + var result = await _controller.AssociateProductToAttributeValuePopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("Cannot load a product", content.Content); + _scopeMock.Verify(s => s.HasAccess(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task AssociateProductToAttributeValuePopup_Post_ScopeDeniesAccess_ReturnsPermissionsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel + { AssociatedToProductId = "p1" }; + + var result = await _controller.AssociateProductToAttributeValuePopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task AssociateProductToAttributeValuePopup_Post_ScopeGrantsAccess_ReturnsEmptyContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel + { AssociatedToProductId = "p1" }; + + var result = await _controller.AssociateProductToAttributeValuePopup(model); + + Assert.IsInstanceOfType(result); + var content = result as ContentResult; + Assert.AreEqual("", content.Content); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index 1d7405934..4dd8a7c65 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -1096,1123 +1096,6 @@ public async Task BulkEditUpdate_ProductsWithEmptyIds_AreIgnoredWithoutServiceCa s => s.UpdateBulkEdit(It.IsAny>()), Times.Never); } - // --- ProductAttributeMappingList ------------------------------------------------------------- - // HasAccess applied uniformly on every action in this region. Store's original checked ownership - // (CanAccessProduct) on List/PopupGET/PopupPOST/Delete/ValidationRulesPopupGET but not on - // ValidationRulesPopup(POST). Vendor's original checked (CheckAccessToProduct/HasAccessToProduct) on - // List/PopupGET/Delete/ValidationRulesPopupGET, but NOT on ProductAttributeMappingPopup(POST) or - // ValidationRulesPopup(POST) - letting a vendor edit an attribute mapping (or its validation rules) on - // any product, not just their own, by posting a known productId. - - [TestMethod] - public async Task ProductAttributeMappingList_ScopeDeniesAccess_ReturnsErrorJson() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - - var result = await _controller.ProductAttributeMappingList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( - s => s.PrepareProductAttributeMappingModels(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductAttributeMappingList_ScopeDeniesAccess_UsesScopeResourceKeyPrefix() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); - - var result = await _controller.ProductAttributeMappingList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); - } - - [TestMethod] - public async Task ProductAttributeMappingList_ScopeGrantsAccess_ReturnsGrid() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - var attributes = new List { new() { Id = "pam1" } }; - _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModels(product)) - .ReturnsAsync(attributes); - - var result = await _controller.ProductAttributeMappingList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - var json = result as JsonResult; - Assert.IsNotNull(json); - var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.AreEqual(1, gridModel.Total); - } - - // --- ProductAttributeMappingPopup (GET) ------------------------------------------------------ - - [TestMethod] - public async Task ProductAttributeMappingPopup_Get_ScopeDeniesAccess_ReturnsPermissionsContent() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - var result = await _controller.ProductAttributeMappingPopup("p1", null); - - var content = result as ContentResult; - Assert.IsNotNull(content); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( - s => s.PrepareProductAttributeMappingModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductAttributeMappingPopup_Get_ScopeGrantsAccess_NoMappingId_PreparesNewModel() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModel(product)) - .ReturnsAsync(new ProductModel.ProductAttributeMappingModel()); - - var result = await _controller.ProductAttributeMappingPopup("p1", null); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeMappingModel(product), Times.Once); - } - - [TestMethod] - public async Task ProductAttributeMappingPopup_Get_ScopeGrantsAccess_MappingId_PreparesEditModel() - { - var mapping = new ProductAttributeMapping { Id = "pam1" }; - var product = new Product { Id = "p1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModel(product, mapping)) - .ReturnsAsync(new ProductModel.ProductAttributeMappingModel()); - - var result = await _controller.ProductAttributeMappingPopup("p1", "pam1"); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeMappingModel(product, mapping), Times.Once); - } - - // --- ProductAttributeMappingPopup (POST) ----------------------------------------------------- - // HasAccess added here: Vendor's original had no ownership check on this POST at all. - - [TestMethod] - public async Task ProductAttributeMappingPopup_Post_ScopeDeniesAccess_DoesNotInsertOrUpdate() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.ProductAttributeMappingModel { ProductId = "p1" }; - - var result = await _controller.ProductAttributeMappingPopup(model); - - var content = result as ContentResult; - Assert.IsNotNull(content); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( - s => s.InsertProductAttributeMappingModel(It.IsAny()), - Times.Never); - _productViewModelServiceMock.Verify( - s => s.UpdateProductAttributeMappingModel(It.IsAny()), - Times.Never); - } - - [TestMethod] - public async Task ProductAttributeMappingPopup_Post_ScopeGrantsAccess_NoId_Inserts() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductAttributeMappingModel { ProductId = "p1" }; - - var result = await _controller.ProductAttributeMappingPopup(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.InsertProductAttributeMappingModel(model), Times.Once); - _productViewModelServiceMock.Verify( - s => s.UpdateProductAttributeMappingModel(It.IsAny()), - Times.Never); - } - - [TestMethod] - public async Task ProductAttributeMappingPopup_Post_ScopeGrantsAccess_WithId_Updates() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductAttributeMappingModel { Id = "pam1", ProductId = "p1" }; - - var result = await _controller.ProductAttributeMappingPopup(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.UpdateProductAttributeMappingModel(model), Times.Once); - } - - [TestMethod] - public async Task ProductAttributeMappingPopup_Post_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); - var model = new ProductModel.ProductAttributeMappingModel { ProductId = "missing" }; - - await Assert.ThrowsExactlyAsync(() => _controller.ProductAttributeMappingPopup(model)); - } - - [TestMethod] - public async Task ProductAttributeMappingPopup_Post_InvalidModelState_ReturnsView_DoesNotCheckAccess() - { - _controller.ModelState.AddModelError("x", "err"); - var model = new ProductModel.ProductAttributeMappingModel { ProductId = "p1" }; - _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModel(model)).ReturnsAsync(model); - - var result = await _controller.ProductAttributeMappingPopup(model); - - Assert.IsInstanceOfType(result); - _productServiceMock.Verify(p => p.GetProductById(It.IsAny(), It.IsAny()), Times.Never); - _scopeMock.Verify(s => s.HasAccess(It.IsAny()), Times.Never); - } - - // --- ProductAttributeMappingDelete ----------------------------------------------------------- - - [TestMethod] - public async Task ProductAttributeMappingDelete_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); - var attrServiceMock = new Mock(); - - await Assert.ThrowsExactlyAsync( - () => _controller.ProductAttributeMappingDelete("pam1", "missing", attrServiceMock.Object)); - } - - [TestMethod] - public async Task ProductAttributeMappingDelete_MappingNotFound_Throws() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - var attrServiceMock = new Mock(); - - await Assert.ThrowsExactlyAsync( - () => _controller.ProductAttributeMappingDelete("missing-pam", "p1", attrServiceMock.Object)); - } - - [TestMethod] - public async Task ProductAttributeMappingDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() - { - var mapping = new ProductAttributeMapping { Id = "pam1" }; - var product = new Product { Id = "p1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var attrServiceMock = new Mock(); - - var result = await _controller.ProductAttributeMappingDelete("pam1", "p1", attrServiceMock.Object); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - attrServiceMock.Verify( - s => s.DeleteProductAttributeMapping(It.IsAny(), It.IsAny()), - Times.Never); - } - - [TestMethod] - public async Task ProductAttributeMappingDelete_ScopeGrantsAccess_Deletes() - { - var mapping = new ProductAttributeMapping { Id = "pam1" }; - var product = new Product { Id = "p1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var attrServiceMock = new Mock(); - - var result = await _controller.ProductAttributeMappingDelete("pam1", "p1", attrServiceMock.Object); - - Assert.IsInstanceOfType(result); - attrServiceMock.Verify(s => s.DeleteProductAttributeMapping(mapping, "p1"), Times.Once); - } - - // --- ProductAttributeValidationRulesPopup (GET) ----------------------------------------------- - - [TestMethod] - public async Task ProductAttributeValidationRulesPopup_Get_ScopeDeniesAccess_ReturnsPermissionsContent() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - var result = await _controller.ProductAttributeValidationRulesPopup("pam1", "p1"); - - var content = result as ContentResult; - Assert.IsNotNull(content); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - } - - [TestMethod] - public async Task ProductAttributeValidationRulesPopup_Get_ScopeGrantsAccess_MappingMissing_ReturnsContent() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - - var result = await _controller.ProductAttributeValidationRulesPopup("missing-pam", "p1"); - - var content = result as ContentResult; - Assert.IsNotNull(content); - Assert.AreEqual("No attribute value found with the specified id", content.Content); - } - - [TestMethod] - public async Task ProductAttributeValidationRulesPopup_Get_ScopeGrantsAccess_PreparesModel() - { - var mapping = new ProductAttributeMapping { Id = "pam1" }; - var product = new Product { Id = "p1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModel(mapping)) - .ReturnsAsync(new ProductModel.ProductAttributeMappingModel()); - - var result = await _controller.ProductAttributeValidationRulesPopup("pam1", "p1"); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeMappingModel(mapping), Times.Once); - } - - // --- ProductAttributeValidationRulesPopup (POST) ---------------------------------------------- - // HasAccess added here: none of the three original hosts checked ownership on this POST at all - a - // store/vendor user could update an attribute mapping's validation rules on any product by posting a - // known productId/model.Id. - - [TestMethod] - public async Task ProductAttributeValidationRulesPopup_Post_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); - var model = new ProductModel.ProductAttributeMappingModel { ProductId = "missing" }; - - await Assert.ThrowsExactlyAsync( - () => _controller.ProductAttributeValidationRulesPopup(model)); - } - - [TestMethod] - public async Task ProductAttributeValidationRulesPopup_Post_MappingNotFound_Throws() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - var model = new ProductModel.ProductAttributeMappingModel { Id = "missing-pam", ProductId = "p1" }; - - await Assert.ThrowsExactlyAsync( - () => _controller.ProductAttributeValidationRulesPopup(model)); - } - - [TestMethod] - public async Task ProductAttributeValidationRulesPopup_Post_ScopeDeniesAccess_ReturnsPermissionsContent_DoesNotUpdate() - { - var mapping = new ProductAttributeMapping { Id = "pam1" }; - var product = new Product { Id = "p1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.ProductAttributeMappingModel { Id = "pam1", ProductId = "p1" }; - - var result = await _controller.ProductAttributeValidationRulesPopup(model); - - var content = result as ContentResult; - Assert.IsNotNull(content); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( - s => s.UpdateProductAttributeValidationRulesModel(It.IsAny(), - It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductAttributeValidationRulesPopup_Post_ScopeGrantsAccess_ValidModel_Updates() - { - var mapping = new ProductAttributeMapping { Id = "pam1" }; - var product = new Product { Id = "p1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductAttributeMappingModel { Id = "pam1", ProductId = "p1" }; - - var result = await _controller.ProductAttributeValidationRulesPopup(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( - s => s.UpdateProductAttributeValidationRulesModel(mapping, model), Times.Once); - } - - [TestMethod] - public async Task ProductAttributeValidationRulesPopup_Post_ScopeGrantsAccess_InvalidModelState_ReturnsView() - { - var mapping = new ProductAttributeMapping { Id = "pam1" }; - var product = new Product { Id = "p1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _controller.ModelState.AddModelError("x", "err"); - var model = new ProductModel.ProductAttributeMappingModel { Id = "pam1", ProductId = "p1" }; - _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModel(mapping)).ReturnsAsync(model); - - var result = await _controller.ProductAttributeValidationRulesPopup(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( - s => s.UpdateProductAttributeValidationRulesModel(It.IsAny(), - It.IsAny()), Times.Never); - } - - // --- ProductAttributeConditionPopup (GET) ---------------------------------------------------- - // HasAccess added uniformly. Admin's original had no ownership check on either action of this - // region at all. - - [TestMethod] - public async Task ProductAttributeConditionPopup_Get_ScopeDeniesAccess_ReturnsPermissionsContent() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - var result = await _controller.ProductAttributeConditionPopup("p1", "pam1"); - - var content = result as ContentResult; - Assert.IsNotNull(content); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( - s => s.PrepareProductAttributeConditionModel(It.IsAny(), It.IsAny()), - Times.Never); - } - - [TestMethod] - public async Task ProductAttributeConditionPopup_Get_ScopeDeniesAccess_UsesScopeResourceKeyPrefix() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); - - var result = await _controller.ProductAttributeConditionPopup("p1", "pam1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); - } - - [TestMethod] - public async Task ProductAttributeConditionPopup_Get_ScopeGrantsAccess_MappingMissing_ReturnsContent() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - - var result = await _controller.ProductAttributeConditionPopup("p1", "missing-pam"); - - var content = result as ContentResult; - Assert.IsNotNull(content); - Assert.AreEqual("No attribute value found with the specified id", content.Content); - _productViewModelServiceMock.Verify( - s => s.PrepareProductAttributeConditionModel(It.IsAny(), It.IsAny()), - Times.Never); - } - - [TestMethod] - public async Task ProductAttributeConditionPopup_Get_ScopeGrantsAccess_PreparesModel() - { - var mapping = new ProductAttributeMapping { Id = "pam1" }; - var product = new Product { Id = "p1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeConditionModel(product, mapping)) - .ReturnsAsync(new ProductAttributeConditionModel()); - - var result = await _controller.ProductAttributeConditionPopup("p1", "pam1"); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeConditionModel(product, mapping), - Times.Once); - } - - // --- ProductAttributeConditionPopup (POST) --------------------------------------------------- - // HasAccess added here: Vendor's original never checked ownership on this POST at all (only its - // GET sibling did, via CheckAccessToProduct), letting a vendor update an attribute condition on - // any product by posting a known productId/productAttributeMappingId. Admin's original had no - // check on either action. - - [TestMethod] - public async Task ProductAttributeConditionPopup_Post_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); - var model = new ProductAttributeConditionModel { ProductId = "missing" }; - - await Assert.ThrowsExactlyAsync(() => _controller.ProductAttributeConditionPopup(model)); - } - - [TestMethod] - public async Task ProductAttributeConditionPopup_Post_MappingNotFound_ReturnsContent_DoesNotCheckAccess() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - var model = new ProductAttributeConditionModel { ProductId = "p1", ProductAttributeMappingId = "missing-pam" }; - - var result = await _controller.ProductAttributeConditionPopup(model); - - var content = result as ContentResult; - Assert.IsNotNull(content); - Assert.AreEqual("No attribute value found with the specified id", content.Content); - _scopeMock.Verify(s => s.HasAccess(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductAttributeConditionPopup_Post_ScopeDeniesAccess_ReturnsPermissionsContent_DoesNotUpdate() - { - var mapping = new ProductAttributeMapping { Id = "pam1" }; - var product = new Product { Id = "p1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductAttributeConditionModel { ProductId = "p1", ProductAttributeMappingId = "pam1" }; - - var result = await _controller.ProductAttributeConditionPopup(model); - - var content = result as ContentResult; - Assert.IsNotNull(content); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( - s => s.UpdateProductAttributeConditionModel(It.IsAny(), It.IsAny(), - It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductAttributeConditionPopup_Post_ScopeGrantsAccess_Updates() - { - var mapping = new ProductAttributeMapping { Id = "pam1" }; - var product = new Product { Id = "p1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductAttributeConditionModel { ProductId = "p1", ProductAttributeMappingId = "pam1" }; - - var result = await _controller.ProductAttributeConditionPopup(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.UpdateProductAttributeConditionModel(product, mapping, model), - Times.Once); - } - - // --- EditAttributeValues (GET) ------------------------------------------------------------------ - // HasAccess applied uniformly. Admin's original had no ownership check on this action at all. - - [TestMethod] - public async Task EditAttributeValues_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); - var attrServiceMock = new Mock(); - - await Assert.ThrowsExactlyAsync( - () => _controller.EditAttributeValues("pam1", "missing", attrServiceMock.Object)); - } - - [TestMethod] - public async Task EditAttributeValues_ScopeDeniesAccess_ReturnsPermissionsContent() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var attrServiceMock = new Mock(); - - var result = await _controller.EditAttributeValues("pam1", "p1", attrServiceMock.Object); - - var content = result as ContentResult; - Assert.IsNotNull(content); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - } - - [TestMethod] - public async Task EditAttributeValues_ScopeGrantsAccess_MappingMissing_Throws() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var attrServiceMock = new Mock(); - - await Assert.ThrowsExactlyAsync( - () => _controller.EditAttributeValues("missing-pam", "p1", attrServiceMock.Object)); - } - - [TestMethod] - public async Task EditAttributeValues_ScopeGrantsAccess_PreparesListModel() - { - var mapping = new ProductAttributeMapping { Id = "pam1", ProductAttributeId = "pa1" }; - var product = new Product { Id = "p1", Name = "Product 1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var attrServiceMock = new Mock(); - attrServiceMock.Setup(s => s.GetProductAttributeById("pa1")) - .ReturnsAsync(new ProductAttribute { Id = "pa1", Name = "Color" }); - - var result = await _controller.EditAttributeValues("pam1", "p1", attrServiceMock.Object); - - var view = result as ViewResult; - Assert.IsNotNull(view); - var model = view.Model as ProductModel.ProductAttributeValueListModel; - Assert.IsNotNull(model); - Assert.AreEqual("p1", model.ProductId); - Assert.AreEqual("Product 1", model.ProductName); - Assert.AreEqual("Color", model.ProductAttributeName); - Assert.AreEqual("pam1", model.ProductAttributeMappingId); - } - - // --- ProductAttributeValueList (POST) ----------------------------------------------------------- - - [TestMethod] - public async Task ProductAttributeValueList_ScopeDeniesAccess_ReturnsErrorJson() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - var result = await _controller.ProductAttributeValueList("pam1", "p1", new Grand.Web.Common.DataSource.DataSourceRequest()); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( - s => s.PrepareProductAttributeValueModels(It.IsAny(), It.IsAny()), - Times.Never); - } - - [TestMethod] - public async Task ProductAttributeValueList_ScopeGrantsAccess_MappingMissing_Throws() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - - await Assert.ThrowsExactlyAsync( - () => _controller.ProductAttributeValueList("missing-pam", "p1", new Grand.Web.Common.DataSource.DataSourceRequest())); - } - - [TestMethod] - public async Task ProductAttributeValueList_ScopeGrantsAccess_ReturnsGrid() - { - var mapping = new ProductAttributeMapping { Id = "pam1" }; - var product = new Product { Id = "p1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var values = new List { new() { Id = "pav1" } }; - _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeValueModels(product, mapping)) - .ReturnsAsync(values); - - var result = await _controller.ProductAttributeValueList("pam1", "p1", new Grand.Web.Common.DataSource.DataSourceRequest()); - - var json = result as JsonResult; - Assert.IsNotNull(json); - var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.AreEqual(1, gridModel.Total); - } - - // --- ProductAttributeValueCreatePopup (GET) ----------------------------------------------------- - - [TestMethod] - public async Task ProductAttributeValueCreatePopup_Get_ScopeDeniesAccess_ReturnsPermissionsContent() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - var result = await _controller.ProductAttributeValueCreatePopup("pam1", "p1"); - - var content = result as ContentResult; - Assert.IsNotNull(content); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( - s => s.PrepareProductAttributeValueModel(It.IsAny(), It.IsAny()), - Times.Never); - } - - [TestMethod] - public async Task ProductAttributeValueCreatePopup_Get_ScopeGrantsAccess_MappingMissing_Throws() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - - await Assert.ThrowsExactlyAsync( - () => _controller.ProductAttributeValueCreatePopup("missing-pam", "p1")); - } - - [TestMethod] - public async Task ProductAttributeValueCreatePopup_Get_ScopeGrantsAccess_PreparesModel() - { - var mapping = new ProductAttributeMapping { Id = "pam1" }; - var product = new Product { Id = "p1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeValueModel(product, mapping)) - .ReturnsAsync(new ProductModel.ProductAttributeValueModel()); - - var result = await _controller.ProductAttributeValueCreatePopup("pam1", "p1"); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeValueModel(product, mapping), Times.Once); - } - - // --- ProductAttributeValueCreatePopup (POST) ---------------------------------------------------- - // HasAccess added explicitly. Admin has no ownership concept at all (GlobalAdminDataScope.HasAccess is - // always true), so Admin was never at risk here. The real risk is to VENDOR: Vendor's original relied - // solely on ProductAttributeValueModel : IProductValidVendor triggering ValidationFilter's - // ProductValidVendor check + Vendor's own `if (ModelState.IsValid)` gate - no explicit action-level - // check. Moving to the shared AdminShared model (no IProductValidVendor) would have silently dropped - // that guard for Vendor; scope.HasAccess replaces it explicitly and uniformly. - - [TestMethod] - public async Task ProductAttributeValueCreatePopup_Post_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); - var model = new ProductModel.ProductAttributeValueModel { ProductId = "missing" }; - - await Assert.ThrowsExactlyAsync( - () => _controller.ProductAttributeValueCreatePopup(model)); - } - - [TestMethod] - public async Task ProductAttributeValueCreatePopup_Post_ScopeDeniesAccess_ReturnsPermissionsContent_DoesNotInsert() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.ProductAttributeValueModel { ProductId = "p1" }; - - var result = await _controller.ProductAttributeValueCreatePopup(model); - - var content = result as ContentResult; - Assert.IsNotNull(content); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( - s => s.InsertProductAttributeValueModel(It.IsAny()), - Times.Never); - } - - [TestMethod] - public async Task ProductAttributeValueCreatePopup_Post_ScopeDeniesAccess_UsesVendorResourceKeyPrefix() - { - // The host/action combination this row's fix actually protects: Vendor losing its - // IProductValidVendor-driven guard in the merge. See the comment above the HasAccess check in - // BaseProductController.ProductAttributeValueCreatePopup(POST). - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); - var model = new ProductModel.ProductAttributeValueModel { ProductId = "p1" }; - - var result = await _controller.ProductAttributeValueCreatePopup(model); - - var content = result as ContentResult; - Assert.IsNotNull(content); - _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); - _productViewModelServiceMock.Verify( - s => s.InsertProductAttributeValueModel(It.IsAny()), - Times.Never); - } - - [TestMethod] - public async Task ProductAttributeValueCreatePopup_Post_ScopeGrantsAccess_MappingMissing_RedirectsToList() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductAttributeValueModel { ProductId = "p1", ProductAttributeMappingId = "missing-pam" }; - - var result = await _controller.ProductAttributeValueCreatePopup(model); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("List", redirect.ActionName); - Assert.AreEqual("Product", redirect.ControllerName); - } - - [TestMethod] - public async Task ProductAttributeValueCreatePopup_Post_ScopeGrantsAccess_ValidModel_Inserts() - { - var mapping = new ProductAttributeMapping { Id = "pam1" }; - var product = new Product { Id = "p1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductAttributeValueModel { ProductId = "p1", ProductAttributeMappingId = "pam1" }; - - var result = await _controller.ProductAttributeValueCreatePopup(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.InsertProductAttributeValueModel(model), Times.Once); - } - - [TestMethod] - public async Task ProductAttributeValueCreatePopup_Post_ScopeGrantsAccess_InvalidModelState_ReturnsView() - { - var mapping = new ProductAttributeMapping { Id = "pam1" }; - var product = new Product { Id = "p1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _controller.ModelState.AddModelError("x", "err"); - var model = new ProductModel.ProductAttributeValueModel { ProductId = "p1", ProductAttributeMappingId = "pam1" }; - - var result = await _controller.ProductAttributeValueCreatePopup(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( - s => s.InsertProductAttributeValueModel(It.IsAny()), - Times.Never); - _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeValueModel(product, model), Times.Once); - } - - // --- ProductAttributeValueEditPopup (GET) ------------------------------------------------------- - - [TestMethod] - public async Task ProductAttributeValueEditPopup_Get_ScopeDeniesAccess_ReturnsPermissionsContent() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - var result = await _controller.ProductAttributeValueEditPopup("pav1", "p1", "pam1"); - - var content = result as ContentResult; - Assert.IsNotNull(content); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - } - - [TestMethod] - public async Task ProductAttributeValueEditPopup_Get_ScopeGrantsAccess_MappingMissing_RedirectsToList() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - - var result = await _controller.ProductAttributeValueEditPopup("pav1", "p1", "missing-pam"); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("List", redirect.ActionName); - } - - [TestMethod] - public async Task ProductAttributeValueEditPopup_Get_ScopeGrantsAccess_ValueMissing_RedirectsToList() - { - var mapping = new ProductAttributeMapping { Id = "pam1" }; - var product = new Product { Id = "p1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - - var result = await _controller.ProductAttributeValueEditPopup("missing-pav", "p1", "pam1"); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("List", redirect.ActionName); - } - - [TestMethod] - public async Task ProductAttributeValueEditPopup_Get_ScopeGrantsAccess_PreparesModel() - { - var pav = new ProductAttributeValue { Id = "pav1" }; - var mapping = new ProductAttributeMapping { Id = "pam1" }; - mapping.ProductAttributeValues.Add(pav); - var product = new Product { Id = "p1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductAttributeValueModel(); - _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeValueModel(mapping, pav)) - .ReturnsAsync(model); - - var result = await _controller.ProductAttributeValueEditPopup("pav1", "p1", "pam1"); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeValueModel(mapping, pav), Times.Once); - _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeValueModel(product, model), Times.Once); - } - - // --- ProductAttributeValueEditPopup (POST) ------------------------------------------------------ - // HasAccess added here: Vendor's original never checked ownership on this POST at all (only its GET - // sibling did); Admin's original had no check on either action. - - [TestMethod] - public async Task ProductAttributeValueEditPopup_Post_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); - var model = new ProductModel.ProductAttributeValueModel(); - - await Assert.ThrowsExactlyAsync( - () => _controller.ProductAttributeValueEditPopup("missing", model)); - } - - [TestMethod] - public async Task ProductAttributeValueEditPopup_Post_ScopeDeniesAccess_ReturnsPermissionsContent_DoesNotUpdate() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.ProductAttributeValueModel { Id = "pav1", ProductAttributeMappingId = "pam1" }; - - var result = await _controller.ProductAttributeValueEditPopup("p1", model); - - var content = result as ContentResult; - Assert.IsNotNull(content); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( - s => s.UpdateProductAttributeValueModel(It.IsAny(), - It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductAttributeValueEditPopup_Post_ScopeGrantsAccess_ValueMissing_RedirectsToList() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductAttributeValueModel { Id = "missing-pav", ProductAttributeMappingId = "pam1" }; - - var result = await _controller.ProductAttributeValueEditPopup("p1", model); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("List", redirect.ActionName); - } - - [TestMethod] - public async Task ProductAttributeValueEditPopup_Post_ScopeGrantsAccess_ValidModel_Updates() - { - var pav = new ProductAttributeValue { Id = "pav1" }; - var mapping = new ProductAttributeMapping { Id = "pam1" }; - mapping.ProductAttributeValues.Add(pav); - var product = new Product { Id = "p1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductAttributeValueModel { Id = "pav1", ProductAttributeMappingId = "pam1" }; - - var result = await _controller.ProductAttributeValueEditPopup("p1", model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.UpdateProductAttributeValueModel(pav, model), Times.Once); - } - - [TestMethod] - public async Task ProductAttributeValueEditPopup_Post_ScopeGrantsAccess_InvalidModelState_ReturnsView() - { - var pav = new ProductAttributeValue { Id = "pav1" }; - var mapping = new ProductAttributeMapping { Id = "pam1" }; - mapping.ProductAttributeValues.Add(pav); - var product = new Product { Id = "p1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _controller.ModelState.AddModelError("x", "err"); - var model = new ProductModel.ProductAttributeValueModel { Id = "pav1", ProductAttributeMappingId = "pam1" }; - - var result = await _controller.ProductAttributeValueEditPopup("p1", model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( - s => s.UpdateProductAttributeValueModel(It.IsAny(), - It.IsAny()), Times.Never); - _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeValueModel(product, model), Times.Once); - } - - // --- ProductAttributeValueDelete ----------------------------------------------------------------- - // HasAccess added here: this action takes only simple string parameters, so no model-level validator - // ever runs for it. Admin's original had no ownership check at all. - - [TestMethod] - public async Task ProductAttributeValueDelete_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); - var attrServiceMock = new Mock(); - - await Assert.ThrowsExactlyAsync( - () => _controller.ProductAttributeValueDelete("pav1", "pam1", "missing", attrServiceMock.Object)); - } - - [TestMethod] - public async Task ProductAttributeValueDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var attrServiceMock = new Mock(); - - var result = await _controller.ProductAttributeValueDelete("pav1", "pam1", "p1", attrServiceMock.Object); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - attrServiceMock.Verify( - s => s.DeleteProductAttributeValue(It.IsAny(), It.IsAny(), - It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductAttributeValueDelete_ScopeGrantsAccess_ValueMissing_Throws() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var attrServiceMock = new Mock(); - - await Assert.ThrowsExactlyAsync( - () => _controller.ProductAttributeValueDelete("missing-pav", "pam1", "p1", attrServiceMock.Object)); - } - - [TestMethod] - public async Task ProductAttributeValueDelete_ScopeGrantsAccess_ValidModel_Deletes() - { - var pav = new ProductAttributeValue { Id = "pav1" }; - var mapping = new ProductAttributeMapping { Id = "pam1" }; - mapping.ProductAttributeValues.Add(pav); - var product = new Product { Id = "p1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var attrServiceMock = new Mock(); - - var result = await _controller.ProductAttributeValueDelete("pav1", "pam1", "p1", attrServiceMock.Object); - - Assert.IsInstanceOfType(result); - attrServiceMock.Verify(s => s.DeleteProductAttributeValue(pav, "p1", "pam1"), Times.Once); - } - - [TestMethod] - public async Task ProductAttributeValueDelete_ScopeGrantsAccess_InvalidModelState_ReturnsErrorJson_DoesNotDelete() - { - var pav = new ProductAttributeValue { Id = "pav1" }; - var mapping = new ProductAttributeMapping { Id = "pam1" }; - mapping.ProductAttributeValues.Add(pav); - var product = new Product { Id = "p1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _controller.ModelState.AddModelError("x", "err"); - var attrServiceMock = new Mock(); - - var result = await _controller.ProductAttributeValueDelete("pav1", "pam1", "p1", attrServiceMock.Object); - - Assert.IsInstanceOfType(result); - attrServiceMock.Verify( - s => s.DeleteProductAttributeValue(It.IsAny(), It.IsAny(), - It.IsAny()), Times.Never); - } - - // --- AssociateProductToAttributeValuePopup (GET) ------------------------------------------------ - - [TestMethod] - public async Task AssociateProductToAttributeValuePopup_Get_CallsPrepareAssociateProductToAttributeValueModel() - { - // PrepareAssociateProductToAttributeValueModel() now reads scope internally (Task 9). - _productViewModelServiceMock.Setup(s => s.PrepareAssociateProductToAttributeValueModel()) - .ReturnsAsync(new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel()); - - var result = await _controller.AssociateProductToAttributeValuePopup(); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.PrepareAssociateProductToAttributeValueModel(), - Times.Once); - } - - // --- AssociateProductToAttributeValuePopupList (POST) ------------------------------------------- - - [TestMethod] - public async Task AssociateProductToAttributeValuePopupList_DefaultStoreIdSet_AppliesSearchStoreId() - { - _scopeMock.Setup(s => s.DefaultStoreId).Returns("store1"); - var model = new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel(); - _productViewModelServiceMock.Setup(s => s.PrepareProductModel(model, 1, 10)) - .ReturnsAsync((new List(), 0)); - - var result = await _controller.AssociateProductToAttributeValuePopupList( - new Grand.Web.Common.DataSource.DataSourceRequest { Page = 1, PageSize = 10 }, model); - - Assert.IsInstanceOfType(result); - Assert.AreEqual("store1", model.SearchStoreId); - } - - [TestMethod] - public async Task AssociateProductToAttributeValuePopupList_DefaultStoreIdNull_LeavesSearchStoreIdUntouched() - { - var model = new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel - { SearchStoreId = "preset" }; - _productViewModelServiceMock.Setup(s => s.PrepareProductModel(model, 1, 10)) - .ReturnsAsync((new List(), 0)); - - var result = await _controller.AssociateProductToAttributeValuePopupList( - new Grand.Web.Common.DataSource.DataSourceRequest { Page = 1, PageSize = 10 }, model); - - Assert.IsInstanceOfType(result); - Assert.AreEqual("preset", model.SearchStoreId); - } - - // --- AssociateProductToAttributeValuePopup (POST) ----------------------------------------------- - // HasAccess added here: Admin's original had no ownership check on the associated product at all. - - [TestMethod] - public async Task AssociateProductToAttributeValuePopup_Post_AssociatedProductMissing_ReturnsContent() - { - _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); - var model = new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel - { AssociatedToProductId = "missing" }; - - var result = await _controller.AssociateProductToAttributeValuePopup(model); - - var content = result as ContentResult; - Assert.IsNotNull(content); - Assert.AreEqual("Cannot load a product", content.Content); - _scopeMock.Verify(s => s.HasAccess(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task AssociateProductToAttributeValuePopup_Post_ScopeDeniesAccess_ReturnsPermissionsContent() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel - { AssociatedToProductId = "p1" }; - - var result = await _controller.AssociateProductToAttributeValuePopup(model); - - var content = result as ContentResult; - Assert.IsNotNull(content); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - } - - [TestMethod] - public async Task AssociateProductToAttributeValuePopup_Post_ScopeGrantsAccess_ReturnsEmptyContent() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel - { AssociatedToProductId = "p1" }; - - var result = await _controller.AssociateProductToAttributeValuePopup(model); - - Assert.IsInstanceOfType(result); - var content = result as ContentResult; - Assert.AreEqual("", content.Content); - } - // --- ProductAttributeCombinationList (POST) ------------------------------------------------------- [TestMethod] diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AttributeValueButtons.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/ProductAttributeMapping/Partials/WidgetZone.AttributeValueButtons.cshtml similarity index 100% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AttributeValueButtons.cshtml rename to src/Web/Grand.Web.Admin/Areas/Admin/Views/ProductAttributeMapping/Partials/WidgetZone.AttributeValueButtons.cshtml diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductAttributeMappingController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductAttributeMappingController.cs new file mode 100644 index 000000000..2515f4d78 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Controllers/ProductAttributeMappingController.cs @@ -0,0 +1,23 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Domain.Catalog; +using Grand.Web.Admin.Extensions; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Admin.Controllers; + +[AuthorizeAdmin] +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaAdmin)] +[AuthorizeMenu] +public class ProductAttributeMappingController( + IProductViewModelService productViewModelService, + IProductService productService, + ILanguageService languageService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductAttributeMappingController(productViewModelService, productService, languageService, translationService, scope); diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAttributeMappingController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAttributeMappingController.cs new file mode 100644 index 000000000..852efd3ce --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAttributeMappingController.cs @@ -0,0 +1,513 @@ +using Grand.Business.Core.Extensions; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Domain.Catalog; +using Grand.Domain.Permissions; +using Grand.SharedKernel.Extensions; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.AdminShared.Controllers; + +[PermissionAuthorize(PermissionSystemName.Products)] +[AutoValidateAntiforgeryToken] +public abstract class BaseProductAttributeMappingController( + IProductViewModelService productViewModelService, + IProductService productService, + ILanguageService languageService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseController +{ + #region Product attributes + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ProductAttributeMappingList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // CanView on this read: a product shared with other stores opens read-only on Store, so its + // tabs must load; the mutating actions below keep the strict HasAccess. History: + // HasAccess applied uniformly across this region (Admin's Global scope is a no-op). Store originally + // checked ownership here (CanAccessProduct) and Vendor did too (CheckAccessToProduct), but neither + // host checked ProductAttributeMappingPopup(POST) or ProductAttributeValidationRulesPopup(POST) at + // all - a store/vendor user could edit an attribute mapping's name/values or its validation rules on + // any product (not just their own/in-scope one) by posting a known productId. Vendor's + // ProductAttributeMappingPopup(POST) had no check either, despite its own GET sibling checking. + if (!await scope.CanView(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var attributesModel = await productViewModelService.PrepareProductAttributeMappingModels(product); + var gridModel = new DataSourceResult { + Data = attributesModel, + Total = attributesModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ProductAttributeMappingPopup(string productId, string productAttributeMappingId) + { + var product = await productService.GetProductById(productId); + + // See the List-action comment above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (string.IsNullOrEmpty(productAttributeMappingId)) + { + var model = await productViewModelService.PrepareProductAttributeMappingModel(product); + return View(model); + } + + var productAttributeMapping = + product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); + var editModel = await productViewModelService.PrepareProductAttributeMappingModel(product, + productAttributeMapping); + return View(editModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeMappingPopup(ProductModel.ProductAttributeMappingModel model) + { + if (ModelState.IsValid) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // HasAccess here too: Vendor's original never checked ownership on this POST at all (only its + // GET sibling did), letting a vendor insert/update an attribute mapping on any product. See the + // List-action comment. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (string.IsNullOrEmpty(model.Id)) + await productViewModelService.InsertProductAttributeMappingModel(model); + else + await productViewModelService.UpdateProductAttributeMappingModel(model); + + return Content(""); + } + + Error(ModelState); + model = await productViewModelService.PrepareProductAttributeMappingModel(model); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeMappingDelete(string id, string productId, + [FromServices] IProductAttributeService productAttributeService) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == id); + if (productAttributeMapping == null) + throw new ArgumentException("No product attribute mapping found with the specified id"); + + // See the List-action comment above. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + await productAttributeService.DeleteProductAttributeMapping(productAttributeMapping, product.Id); + return new JsonResult(""); + } + + //edit + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ProductAttributeValidationRulesPopup(string id, string productId) + { + var product = await productService.GetProductById(productId); + + // See the List-action comment above. Store's original used ErrorForKendoGridJson here even though + // this action returns a View (not a grid), which would have rendered raw JSON as page content on + // denial - using Content(...) instead, consistent with the other popup GET action in this region. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == id); + if (productAttributeMapping == null) + return Content("No attribute value found with the specified id"); + + var model = await productViewModelService.PrepareProductAttributeMappingModel(productAttributeMapping); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeValidationRulesPopup( + ProductModel.ProductAttributeMappingModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.Id); + if (productAttributeMapping == null) + throw new ArgumentException("No attribute value found with the specified id"); + + // HasAccess here too: none of the three original hosts checked ownership on this POST at all - + // a store/vendor user could update an attribute mapping's validation rules (min/max length, + // allowed file extensions, default value) on any product by posting a known productId/model.Id. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.UpdateProductAttributeValidationRulesModel(productAttributeMapping, model); + return Content(""); + } + + Error(ModelState); + model = await productViewModelService.PrepareProductAttributeMappingModel(productAttributeMapping); + return View(model); + } + + #endregion + + #region Product attributes. Condition + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ProductAttributeConditionPopup(string productId, string productAttributeMappingId) + { + var product = await productService.GetProductById(productId); + + // HasAccess applied uniformly across this region (Admin's Global scope is a no-op). Admin's original + // had no ownership check on either action of this region. See the "Product attributes" region above + // for the same pattern. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productAttributeMapping = + product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); + if (productAttributeMapping == null) + //No attribute value found with the specified id + return Content("No attribute value found with the specified id"); + + var model = await productViewModelService.PrepareProductAttributeConditionModel(product, + productAttributeMapping); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeConditionPopup(ProductAttributeConditionModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + var productAttributeMapping = + product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.ProductAttributeMappingId); + if (productAttributeMapping == null) + return Content("No attribute value found with the specified id"); + + // ModelState.IsValid: Vendor's original wrapped this action in a ModelState.IsValid check and + // returned ModelState.GetErrors() on failure; Admin's and Store's originals had no such check. + // That check was not inert: Vendor's ProductAttributeConditionModel implements the + // IProductValidVendor marker interface, which the global ValidationFilter resolves to + // ProductValidVendor (a FluentValidation rule checking product.VendorId == CurrentVendor.Id) - + // real ownership enforcement, not a no-op. Dropping it here is safe because the scope.HasAccess + // (product) call below now performs the equivalent check directly against the loaded entity, + // which HasAccess.cs's own doc comment argues is more robust than re-deriving ownership from a + // request field. + // + // HasAccess here too: Vendor's original never checked ownership on this POST at all (only its GET + // sibling did, via CheckAccessToProduct), letting a vendor update an attribute condition on any + // product by posting a known productId/productAttributeMappingId. Admin's original had no check on + // either action. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + await productViewModelService.UpdateProductAttributeConditionModel(product, productAttributeMapping, model); + return Content(""); + } + + #endregion + + #region Product attribute values + + //list + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task EditAttributeValues(string productAttributeMappingId, string productId, + [FromServices] IProductAttributeService productAttributeService) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // HasAccess applied uniformly across this region (Admin's Global scope is a no-op). Admin's + // original had no ownership check on this action at all. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productAttributeMapping = + product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); + if (productAttributeMapping == null) + throw new ArgumentException("No product attribute mapping found with the specified id"); + + var productAttribute = + await productAttributeService.GetProductAttributeById(productAttributeMapping.ProductAttributeId); + var model = new ProductModel.ProductAttributeValueListModel { + ProductName = product.Name, + ProductId = product.Id, + ProductAttributeName = productAttribute.Name, + ProductAttributeMappingId = productAttributeMappingId + }; + + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeValueList(string productAttributeMappingId, string productId, + DataSourceRequest command) + { + var product = await productService.GetProductById(productId); + + // See the EditAttributeValues comment above. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson( + translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productAttributeMapping = + product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); + if (productAttributeMapping == null) + throw new ArgumentException("No product attribute mapping found with the specified id"); + + var values = + await productViewModelService.PrepareProductAttributeValueModels(product, productAttributeMapping); + var gridModel = new DataSourceResult { + Data = values, + Total = values.Count + }; + return Json(gridModel); + } + + //create + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ProductAttributeValueCreatePopup(string productAttributeMappingId, + string productId) + { + var product = await productService.GetProductById(productId); + + // Content(...), not ErrorForKendoGridJson: Store's original used ErrorForKendoGridJson here even + // though this action returns a View (not a grid), which would have rendered raw JSON as page + // content on denial - using Content(...) instead, consistent with the sibling GET popup actions + // in this region and elsewhere in this file. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productAttributeMapping = + product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); + if (productAttributeMapping == null) + throw new ArgumentException("No product attribute mapping found with the specified id"); + + var model = + await productViewModelService.PrepareProductAttributeValueModel(product, productAttributeMapping); + //locales + await AddLocales(languageService, model.Locales); + + return View(model); + } + + [HttpPost] + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ProductAttributeValueCreatePopup(ProductModel.ProductAttributeValueModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // HasAccess added explicitly rather than relying on validation-layer side effects. Admin has no + // ownership concept at all (GlobalAdminDataScope.HasAccess is always true), so Admin was never at + // risk here despite having neither an explicit check nor a validator. Store's shared + // ProductAttributeValueModelValidator (BaseStoreAccessValidator<...>) enforces ownership whenever + // StaffStoreId is set, so Store's original had validator-layer coverage. The actual risk this + // guards against is to VENDOR: Vendor's original model implements IProductValidVendor, so the + // global ValidationFilter resolves IValidator (ProductValidVendor) and adds a + // ModelState error when product.VendorId doesn't match the current vendor - a real check, not a + // no-op - but ValidationFilter never short-circuits a non-JSON POST (see + // ValidationFilter.OnActionExecutionAsync), so that protection only actually held because Vendor's + // original action gated the insert behind `if (ModelState.IsValid)`. Once this action moves to the + // shared AdminShared model (which does not implement IProductValidVendor), Vendor would silently + // lose that guard in the merge unless replaced - scope.HasAccess is that replacement, and now + // applies uniformly (a no-op for Admin, equivalent-or-stronger for Store/Vendor) instead of leaning + // on a marker-interface side effect that only one host had. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productAttributeMapping = + product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.ProductAttributeMappingId); + if (productAttributeMapping == null) + //No product attribute found with the specified id + return RedirectToAction("List", "Product"); + + if (ModelState.IsValid) + { + await productViewModelService.InsertProductAttributeValueModel(model); + return Content(""); + } + + //If we got this far, something failed, redisplay form + await productViewModelService.PrepareProductAttributeValueModel(product, model); + return View(model); + } + + //edit + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ProductAttributeValueEditPopup(string id, string productId, + string productAttributeMappingId) + { + var product = await productService.GetProductById(productId); + + // See the EditAttributeValues comment above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var pa = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); + if (pa == null) + return RedirectToAction("List", "Product"); + + var pav = pa.ProductAttributeValues.FirstOrDefault(x => x.Id == id); + if (pav == null) + //No attribute value found with the specified id + return RedirectToAction("List", "Product"); + + var model = await productViewModelService.PrepareProductAttributeValueModel(pa, pav); + //locales + await AddLocales(languageService, model.Locales, (locale, languageId) => + { + locale.Name = pav.GetTranslation(x => x.Name, languageId, false); + }); + //pictures + await productViewModelService.PrepareProductAttributeValueModel(product, model); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeValueEditPopup(string productId, + ProductModel.ProductAttributeValueModel model) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // See the ProductAttributeValueCreatePopup(POST) comment above re: the validator's coverage gap - + // the same reasoning applies here (this action shares the same model type). Unlike CreatePopup(POST), + // Vendor's original for THIS action already had an explicit inline check + // (`if (product == null || !_contextAccessor.WorkContext.HasAccessToProduct(product)) throw ...`), + // so scope.HasAccess is a mechanical substitution there, not a fix. Admin's original had no check + // on either action of this pair. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var pav = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.ProductAttributeMappingId) + ?.ProductAttributeValues.FirstOrDefault(x => x.Id == model.Id); + if (pav == null) + //No attribute value found with the specified id + return RedirectToAction("List", "Product"); + + if (ModelState.IsValid) + { + await productViewModelService.UpdateProductAttributeValueModel(pav, model); + return Content(""); + } + + //If we got this far, something failed, redisplay form + await productViewModelService.PrepareProductAttributeValueModel(product, model); + return View(model); + } + + //delete + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeValueDelete(string id, string pam, string productId, + [FromServices] IProductAttributeService productAttributeService) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // HasAccess added here: this action takes only simple string parameters (no complex-typed POST + // body), so none of the model-level validators discussed above ever run for it. Admin's original + // had no ownership check at all; Vendor's/Store's explicit inline checks are what scope.HasAccess + // now replaces uniformly. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson( + translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var pav = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == pam)?.ProductAttributeValues + .FirstOrDefault(x => x.Id == id); + if (pav == null) + throw new ArgumentException("No product attribute value found with the specified id"); + + if (ModelState.IsValid) + { + await productAttributeService.DeleteProductAttributeValue(pav, productId, pam); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + public async Task AssociateProductToAttributeValuePopup() + { + // PrepareAssociateProductToAttributeValueModel() now reads scope internally (Task 9): + // StaffStoreId for Store; null for Admin/Vendor (no store concept). + var model = + await productViewModelService.PrepareAssociateProductToAttributeValueModel(); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task AssociateProductToAttributeValuePopupList(DataSourceRequest command, + ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel model) + { + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + + var (products, totalCount) = + await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = products.ToList(), + Total = totalCount + }; + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task AssociateProductToAttributeValuePopup( + ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel model) + { + var associatedProduct = await productService.GetProductById(model.AssociatedToProductId); + if (associatedProduct == null) + return Content("Cannot load a product"); + + // HasAccess on the associated product: Admin's original had no check here. Vendor's/Store's + // originals checked ownership of the *associated* product (the one about to be referenced as an + // AssociatedToProductId value) - there is no separate "owning" product in scope for this action, so + // the associated product is the only entity available to check. + if (!await scope.HasAccess(associatedProduct)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + return Content(""); + } + + #endregion +} diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index 72aac0992..232dd530a 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -691,491 +691,6 @@ private async Task> FilterBulkEditProductsByAccess( #endregion - #region Product attributes - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductAttributeMappingList(DataSourceRequest command, string productId) - { - var product = await productService.GetProductById(productId); - - // CanView on this read: a product shared with other stores opens read-only on Store, so its - // tabs must load; the mutating actions below keep the strict HasAccess. History: - // HasAccess applied uniformly across this region (Admin's Global scope is a no-op). Store originally - // checked ownership here (CanAccessProduct) and Vendor did too (CheckAccessToProduct), but neither - // host checked ProductAttributeMappingPopup(POST) or ProductAttributeValidationRulesPopup(POST) at - // all - a store/vendor user could edit an attribute mapping's name/values or its validation rules on - // any product (not just their own/in-scope one) by posting a known productId. Vendor's - // ProductAttributeMappingPopup(POST) had no check either, despite its own GET sibling checking. - if (!await scope.CanView(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var attributesModel = await productViewModelService.PrepareProductAttributeMappingModels(product); - var gridModel = new DataSourceResult { - Data = attributesModel, - Total = attributesModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeMappingPopup(string productId, string productAttributeMappingId) - { - var product = await productService.GetProductById(productId); - - // See the List-action comment above. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (string.IsNullOrEmpty(productAttributeMappingId)) - { - var model = await productViewModelService.PrepareProductAttributeMappingModel(product); - return View(model); - } - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - var editModel = await productViewModelService.PrepareProductAttributeMappingModel(product, - productAttributeMapping); - return View(editModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeMappingPopup(ProductModel.ProductAttributeMappingModel model) - { - if (ModelState.IsValid) - { - var product = await productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - // HasAccess here too: Vendor's original never checked ownership on this POST at all (only its - // GET sibling did), letting a vendor insert/update an attribute mapping on any product. See the - // List-action comment. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (string.IsNullOrEmpty(model.Id)) - await productViewModelService.InsertProductAttributeMappingModel(model); - else - await productViewModelService.UpdateProductAttributeMappingModel(model); - - return Content(""); - } - - Error(ModelState); - model = await productViewModelService.PrepareProductAttributeMappingModel(model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeMappingDelete(string id, string productId, - [FromServices] IProductAttributeService productAttributeService) - { - var product = await productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == id); - if (productAttributeMapping == null) - throw new ArgumentException("No product attribute mapping found with the specified id"); - - // See the List-action comment above. - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - await productAttributeService.DeleteProductAttributeMapping(productAttributeMapping, product.Id); - return new JsonResult(""); - } - - //edit - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeValidationRulesPopup(string id, string productId) - { - var product = await productService.GetProductById(productId); - - // See the List-action comment above. Store's original used ErrorForKendoGridJson here even though - // this action returns a View (not a grid), which would have rendered raw JSON as page content on - // denial - using Content(...) instead, consistent with the other popup GET action in this region. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == id); - if (productAttributeMapping == null) - return Content("No attribute value found with the specified id"); - - var model = await productViewModelService.PrepareProductAttributeMappingModel(productAttributeMapping); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeValidationRulesPopup( - ProductModel.ProductAttributeMappingModel model) - { - var product = await productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.Id); - if (productAttributeMapping == null) - throw new ArgumentException("No attribute value found with the specified id"); - - // HasAccess here too: none of the three original hosts checked ownership on this POST at all - - // a store/vendor user could update an attribute mapping's validation rules (min/max length, - // allowed file extensions, default value) on any product by posting a known productId/model.Id. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await productViewModelService.UpdateProductAttributeValidationRulesModel(productAttributeMapping, model); - return Content(""); - } - - Error(ModelState); - model = await productViewModelService.PrepareProductAttributeMappingModel(productAttributeMapping); - return View(model); - } - - #endregion - - #region Product attributes. Condition - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeConditionPopup(string productId, string productAttributeMappingId) - { - var product = await productService.GetProductById(productId); - - // HasAccess applied uniformly across this region (Admin's Global scope is a no-op). Admin's original - // had no ownership check on either action of this region. See the "Product attributes" region above - // for the same pattern. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - if (productAttributeMapping == null) - //No attribute value found with the specified id - return Content("No attribute value found with the specified id"); - - var model = await productViewModelService.PrepareProductAttributeConditionModel(product, - productAttributeMapping); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeConditionPopup(ProductAttributeConditionModel model) - { - var product = await productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.ProductAttributeMappingId); - if (productAttributeMapping == null) - return Content("No attribute value found with the specified id"); - - // ModelState.IsValid: Vendor's original wrapped this action in a ModelState.IsValid check and - // returned ModelState.GetErrors() on failure; Admin's and Store's originals had no such check. - // That check was not inert: Vendor's ProductAttributeConditionModel implements the - // IProductValidVendor marker interface, which the global ValidationFilter resolves to - // ProductValidVendor (a FluentValidation rule checking product.VendorId == CurrentVendor.Id) - - // real ownership enforcement, not a no-op. Dropping it here is safe because the scope.HasAccess - // (product) call below now performs the equivalent check directly against the loaded entity, - // which HasAccess.cs's own doc comment argues is more robust than re-deriving ownership from a - // request field. - // - // HasAccess here too: Vendor's original never checked ownership on this POST at all (only its GET - // sibling did, via CheckAccessToProduct), letting a vendor update an attribute condition on any - // product by posting a known productId/productAttributeMappingId. Admin's original had no check on - // either action. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - await productViewModelService.UpdateProductAttributeConditionModel(product, productAttributeMapping, model); - return Content(""); - } - - #endregion - - #region Product attribute values - - //list - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task EditAttributeValues(string productAttributeMappingId, string productId, - [FromServices] IProductAttributeService productAttributeService) - { - var product = await productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - // HasAccess applied uniformly across this region (Admin's Global scope is a no-op). Admin's - // original had no ownership check on this action at all. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - if (productAttributeMapping == null) - throw new ArgumentException("No product attribute mapping found with the specified id"); - - var productAttribute = - await productAttributeService.GetProductAttributeById(productAttributeMapping.ProductAttributeId); - var model = new ProductModel.ProductAttributeValueListModel { - ProductName = product.Name, - ProductId = product.Id, - ProductAttributeName = productAttribute.Name, - ProductAttributeMappingId = productAttributeMappingId - }; - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeValueList(string productAttributeMappingId, string productId, - DataSourceRequest command) - { - var product = await productService.GetProductById(productId); - - // See the EditAttributeValues comment above. - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson( - translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - if (productAttributeMapping == null) - throw new ArgumentException("No product attribute mapping found with the specified id"); - - var values = - await productViewModelService.PrepareProductAttributeValueModels(product, productAttributeMapping); - var gridModel = new DataSourceResult { - Data = values, - Total = values.Count - }; - return Json(gridModel); - } - - //create - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeValueCreatePopup(string productAttributeMappingId, - string productId) - { - var product = await productService.GetProductById(productId); - - // Content(...), not ErrorForKendoGridJson: Store's original used ErrorForKendoGridJson here even - // though this action returns a View (not a grid), which would have rendered raw JSON as page - // content on denial - using Content(...) instead, consistent with the sibling GET popup actions - // in this region and elsewhere in this file. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - if (productAttributeMapping == null) - throw new ArgumentException("No product attribute mapping found with the specified id"); - - var model = - await productViewModelService.PrepareProductAttributeValueModel(product, productAttributeMapping); - //locales - await AddLocales(languageService, model.Locales); - - return View(model); - } - - [HttpPost] - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeValueCreatePopup(ProductModel.ProductAttributeValueModel model) - { - var product = await productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - // HasAccess added explicitly rather than relying on validation-layer side effects. Admin has no - // ownership concept at all (GlobalAdminDataScope.HasAccess is always true), so Admin was never at - // risk here despite having neither an explicit check nor a validator. Store's shared - // ProductAttributeValueModelValidator (BaseStoreAccessValidator<...>) enforces ownership whenever - // StaffStoreId is set, so Store's original had validator-layer coverage. The actual risk this - // guards against is to VENDOR: Vendor's original model implements IProductValidVendor, so the - // global ValidationFilter resolves IValidator (ProductValidVendor) and adds a - // ModelState error when product.VendorId doesn't match the current vendor - a real check, not a - // no-op - but ValidationFilter never short-circuits a non-JSON POST (see - // ValidationFilter.OnActionExecutionAsync), so that protection only actually held because Vendor's - // original action gated the insert behind `if (ModelState.IsValid)`. Once this action moves to the - // shared AdminShared model (which does not implement IProductValidVendor), Vendor would silently - // lose that guard in the merge unless replaced - scope.HasAccess is that replacement, and now - // applies uniformly (a no-op for Admin, equivalent-or-stronger for Store/Vendor) instead of leaning - // on a marker-interface side effect that only one host had. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.ProductAttributeMappingId); - if (productAttributeMapping == null) - //No product attribute found with the specified id - return RedirectToAction("List", "Product"); - - if (ModelState.IsValid) - { - await productViewModelService.InsertProductAttributeValueModel(model); - return Content(""); - } - - //If we got this far, something failed, redisplay form - await productViewModelService.PrepareProductAttributeValueModel(product, model); - return View(model); - } - - //edit - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeValueEditPopup(string id, string productId, - string productAttributeMappingId) - { - var product = await productService.GetProductById(productId); - - // See the EditAttributeValues comment above. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var pa = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - if (pa == null) - return RedirectToAction("List", "Product"); - - var pav = pa.ProductAttributeValues.FirstOrDefault(x => x.Id == id); - if (pav == null) - //No attribute value found with the specified id - return RedirectToAction("List", "Product"); - - var model = await productViewModelService.PrepareProductAttributeValueModel(pa, pav); - //locales - await AddLocales(languageService, model.Locales, (locale, languageId) => - { - locale.Name = pav.GetTranslation(x => x.Name, languageId, false); - }); - //pictures - await productViewModelService.PrepareProductAttributeValueModel(product, model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeValueEditPopup(string productId, - ProductModel.ProductAttributeValueModel model) - { - var product = await productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - // See the ProductAttributeValueCreatePopup(POST) comment above re: the validator's coverage gap - - // the same reasoning applies here (this action shares the same model type). Unlike CreatePopup(POST), - // Vendor's original for THIS action already had an explicit inline check - // (`if (product == null || !_contextAccessor.WorkContext.HasAccessToProduct(product)) throw ...`), - // so scope.HasAccess is a mechanical substitution there, not a fix. Admin's original had no check - // on either action of this pair. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var pav = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.ProductAttributeMappingId) - ?.ProductAttributeValues.FirstOrDefault(x => x.Id == model.Id); - if (pav == null) - //No attribute value found with the specified id - return RedirectToAction("List", "Product"); - - if (ModelState.IsValid) - { - await productViewModelService.UpdateProductAttributeValueModel(pav, model); - return Content(""); - } - - //If we got this far, something failed, redisplay form - await productViewModelService.PrepareProductAttributeValueModel(product, model); - return View(model); - } - - //delete - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeValueDelete(string id, string pam, string productId, - [FromServices] IProductAttributeService productAttributeService) - { - var product = await productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - // HasAccess added here: this action takes only simple string parameters (no complex-typed POST - // body), so none of the model-level validators discussed above ever run for it. Admin's original - // had no ownership check at all; Vendor's/Store's explicit inline checks are what scope.HasAccess - // now replaces uniformly. - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson( - translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var pav = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == pam)?.ProductAttributeValues - .FirstOrDefault(x => x.Id == id); - if (pav == null) - throw new ArgumentException("No product attribute value found with the specified id"); - - if (ModelState.IsValid) - { - await productAttributeService.DeleteProductAttributeValue(pav, productId, pam); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - public async Task AssociateProductToAttributeValuePopup() - { - // PrepareAssociateProductToAttributeValueModel() now reads scope internally (Task 9): - // StaffStoreId for Store; null for Admin/Vendor (no store concept). - var model = - await productViewModelService.PrepareAssociateProductToAttributeValueModel(); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociateProductToAttributeValuePopupList(DataSourceRequest command, - ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel model) - { - if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; - - var (products, totalCount) = - await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociateProductToAttributeValuePopup( - ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel model) - { - var associatedProduct = await productService.GetProductById(model.AssociatedToProductId); - if (associatedProduct == null) - return Content("Cannot load a product"); - - // HasAccess on the associated product: Admin's original had no check here. Vendor's/Store's - // originals checked ownership of the *associated* product (the one about to be referenced as an - // AssociatedToProductId value) - there is no separate "owning" product in scope for this action, so - // the associated product is the only entity available to check. - if (!await scope.HasAccess(associatedProduct)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - return Content(""); - } - - #endregion - #region Product attribute combinations [PermissionAuthorizeAction(PermissionActionName.Preview)] diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributes.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributes.cshtml index 87bc52694..e733eee22 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributes.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributes.cshtml @@ -9,15 +9,15 @@

@if (!readOnly) { - @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.AddNew"] }
{{ ProductAttribute }}
+ {{ ProductAttribute }}
} @@ -54,7 +54,7 @@ } else { - {{ $texts.validationRulesLink }}
{{{ ValidationRulesString }}}
+ {{ $texts.validationRulesLink }}
{{{ ValidationRulesString }}}
} @@ -62,13 +62,13 @@ { - {{ $texts.edit }}
+ {{ $texts.edit }}
} @*attribute values: drop-down list, radio list, checkboxes, color and image squares; text controls have none*@ - {{ Name }} + {{ Name }} } @@ -133,7 +133,7 @@ $(e.detailElement).attr('data-id', mappingId); var button = $('') .attr('data-id', mappingId) - .attr('href', '@Url.Action("ProductAttributeValueCreatePopup", "Product", new { area = area })/?productAttributeMappingId=' + encodeURIComponent(mappingId) + '&productId=@Model.Id&formId=product-form&btnId=btnRefreshProductAttributesValues') + .attr('href', '@Url.Action("ProductAttributeValueCreatePopup", "ProductAttributeMapping", new { area = area })/?productAttributeMappingId=' + encodeURIComponent(mappingId) + '&productId=@Model.Id&formId=product-form&btnId=btnRefreshProductAttributesValues') .html('@Loc[$"{Scope.ResourceKeyPrefix}.Common.AddNew"]'); $('
').append(button).prependTo(e.detailElement); } diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/AssociateProductToAttributeValuePopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductAttributeMapping/AssociateProductToAttributeValuePopup.cshtml similarity index 96% rename from src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/AssociateProductToAttributeValuePopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductAttributeMapping/AssociateProductToAttributeValuePopup.cshtml index 66264dbc1..e58b0b5cb 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/AssociateProductToAttributeValuePopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductAttributeMapping/AssociateProductToAttributeValuePopup.cshtml @@ -6,7 +6,7 @@ ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.Attributes.Values.Fields.AssociatedProduct.AddNew"]; } - @@ -26,7 +26,7 @@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdateProductAttributeValue.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductAttributeMapping/Partials/CreateOrUpdateProductAttributeValue.cshtml similarity index 96% rename from src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdateProductAttributeValue.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductAttributeMapping/Partials/CreateOrUpdateProductAttributeValue.cshtml index 1f7d95753..ffb2e7ed8 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdateProductAttributeValue.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductAttributeMapping/Partials/CreateOrUpdateProductAttributeValue.cshtml @@ -67,7 +67,7 @@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.AttributeValueButtons.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductAttributeMapping/Partials/WidgetZone.AttributeValueButtons.cshtml similarity index 100% rename from src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.AttributeValueButtons.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductAttributeMapping/Partials/WidgetZone.AttributeValueButtons.cshtml diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductAttributeConditionPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductAttributeMapping/ProductAttributeConditionPopup.cshtml similarity index 98% rename from src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductAttributeConditionPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductAttributeMapping/ProductAttributeConditionPopup.cshtml index 34575eab0..0409adcc7 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductAttributeConditionPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductAttributeMapping/ProductAttributeConditionPopup.cshtml @@ -6,7 +6,7 @@ Layout = ""; ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.Attributes.Condition"]; } - diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductAttributeValueCreatePopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductAttributeMapping/ProductAttributeValueCreatePopup.cshtml similarity index 95% rename from src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductAttributeValueCreatePopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductAttributeMapping/ProductAttributeValueCreatePopup.cshtml index e0fc35253..1edf40e83 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductAttributeValueCreatePopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductAttributeMapping/ProductAttributeValueCreatePopup.cshtml @@ -7,7 +7,7 @@ //page title ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.Attributes.Values.AddNew"]; } - scope) + : BaseProductAttributeMappingController(productViewModelService, productService, languageService, translationService, scope); diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.AttributeValueButtons.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/ProductAttributeMapping/Partials/WidgetZone.AttributeValueButtons.cshtml similarity index 100% rename from src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.AttributeValueButtons.cshtml rename to src/Web/Grand.Web.Vendor/Areas/Vendor/Views/ProductAttributeMapping/Partials/WidgetZone.AttributeValueButtons.cshtml diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductAttributeMappingController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductAttributeMappingController.cs new file mode 100644 index 000000000..65ee93019 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductAttributeMappingController.cs @@ -0,0 +1,23 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Vendor.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Vendor.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaVendor)] +[AuthorizeVendor] +[AuthorizeMenu] +public class ProductAttributeMappingController( + IProductViewModelService productViewModelService, + IProductService productService, + ILanguageService languageService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductAttributeMappingController(productViewModelService, productService, languageService, translationService, scope); From 40d4434647e28043b2d1552b68f1b325322cf445 Mon Sep 17 00:00:00 2001 From: support Date: Thu, 22 Oct 2026 06:41:32 +0200 Subject: [PATCH 11/27] Move the attribute combinations tab into its own controller --- ...ductAttributeCombinationControllerTests.cs | 542 ++++++++++++++++++ .../Controllers/BaseProductControllerTests.cs | 504 ---------------- .../AttributeCombinationPopup.cshtml | 10 +- .../ProductAttributeCombinationController.cs | 22 + ...seProductAttributeCombinationController.cs | 300 ++++++++++ .../Controllers/BaseProductController.cs | 275 --------- ...Attributes.TabAttributeCombinations.cshtml | 12 +- .../Partials/ProductAttributes.cshtml | 0 .../AttributeCombinationPopup.cshtml | 10 +- .../ProductAttributeCombinationController.cs | 22 + .../AttributeCombinationPopup.cshtml | 10 +- .../ProductAttributeCombinationController.cs | 22 + 12 files changed, 929 insertions(+), 800 deletions(-) create mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAttributeCombinationControllerTests.cs rename src/Web/Grand.Web.Admin/Areas/Admin/Views/{Product => ProductAttributeCombination}/AttributeCombinationPopup.cshtml (90%) create mode 100644 src/Web/Grand.Web.Admin/Controllers/ProductAttributeCombinationController.cs create mode 100644 src/Web/Grand.Web.AdminShared/Controllers/BaseProductAttributeCombinationController.cs rename src/Web/Grand.Web.AdminShared/Views/AdminShared/{Product => ProductAttributeCombination}/Partials/ProductAttributes.cshtml (100%) rename src/Web/Grand.Web.Store/Areas/Store/Views/{Product => ProductAttributeCombination}/AttributeCombinationPopup.cshtml (90%) create mode 100644 src/Web/Grand.Web.Store/Controllers/ProductAttributeCombinationController.cs rename src/Web/Grand.Web.Vendor/Areas/Vendor/Views/{Product => ProductAttributeCombination}/AttributeCombinationPopup.cshtml (89%) create mode 100644 src/Web/Grand.Web.Vendor/Controllers/ProductAttributeCombinationController.cs diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAttributeCombinationControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAttributeCombinationControllerTests.cs new file mode 100644 index 000000000..1addc05ff --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAttributeCombinationControllerTests.cs @@ -0,0 +1,542 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Linq.Expressions; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class BaseProductAttributeCombinationControllerTests : ProductControllerTestsBase +{ + // BaseProductAttributeCombinationController is abstract. This minimal subclass exists only so the actions under test + // can be invoked directly. + public class TestProductAttributeCombinationController( + IProductViewModelService productViewModelService, + IProductService productService, + IInventoryManageService inventoryManageService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductAttributeCombinationController(productViewModelService, productService, inventoryManageService, translationService, scope); + + protected override TestProductAttributeCombinationController CreateController() => new( + _productViewModelServiceMock.Object, + _productServiceMock.Object, + _inventoryManageServiceMock.Object, + _translationServiceMock.Object, + _scopeMock.Object); + + // --- ProductAttributeCombinationList (POST) ------------------------------------------------------- + + [TestMethod] + public async Task ProductAttributeCombinationList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeCombinationList(new DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.PrepareProductAttributeCombinationModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeCombinationList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeCombinationModel(product)) + .ReturnsAsync(new List { new() }); + + var result = await _controller.ProductAttributeCombinationList(new DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as DataSourceResult; + Assert.AreEqual(1, gridModel.Total); + } + + // --- ProductAttributeCombinationDelete ------------------------------------------------------------- + + [TestMethod] + public async Task ProductAttributeCombinationDelete_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var attrServiceMock = new Mock(); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeCombinationDelete("c1", "missing", attrServiceMock.Object)); + } + + [TestMethod] + public async Task ProductAttributeCombinationDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + product.ProductAttributeCombinations.Add(new ProductAttributeCombination { Id = "c1" }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var attrServiceMock = new Mock(); + + var result = await _controller.ProductAttributeCombinationDelete("c1", "p1", attrServiceMock.Object); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + attrServiceMock.Verify( + s => s.DeleteProductAttributeCombination(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeCombinationDelete_ScopeGrantsAccess_CombinationMissing_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var attrServiceMock = new Mock(); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeCombinationDelete("missing-c", "p1", attrServiceMock.Object)); + } + + [TestMethod] + public async Task ProductAttributeCombinationDelete_ScopeGrantsAccess_Deletes() + { + var product = new Product { Id = "p1", ManageInventoryMethodId = ManageInventoryMethod.DontManageStock }; + var combination = new ProductAttributeCombination { Id = "c1" }; + product.ProductAttributeCombinations.Add(combination); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var attrServiceMock = new Mock(); + + var result = await _controller.ProductAttributeCombinationDelete("c1", "p1", attrServiceMock.Object); + + Assert.IsInstanceOfType(result); + attrServiceMock.Verify(s => s.DeleteProductAttributeCombination(combination, "p1"), Times.Once); + } + + // --- AttributeCombinationPopup (GET) --------------------------------------------------------------- + + [TestMethod] + public async Task AttributeCombinationPopupGet_ScopeDeniesAccess_ReturnsPermissionsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.AttributeCombinationPopup("p1", "c1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.PrepareProductAttributeCombinationModel(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task AttributeCombinationPopupGet_ScopeGrantsAccess_ReturnsView() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductAttributeCombinationModel { Id = "c1" }; + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeCombinationModel(product, "c1")) + .ReturnsAsync(model); + + var result = await _controller.AttributeCombinationPopup("p1", "c1"); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreSame(model, view.Model); + _productViewModelServiceMock.Verify(s => s.PrepareAddProductAttributeCombinationModel(model, product), + Times.Once); + } + + // --- AttributeCombinationPopup (POST) -------------------------------------------------------------- + + [TestMethod] + public async Task AttributeCombinationPopupPost_MissingProduct_RedirectsToList() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var model = new ProductAttributeCombinationModel(); + + var result = await _controller.AttributeCombinationPopup("missing", model); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + _scopeMock.Verify(s => s.HasAccess(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task AttributeCombinationPopupPost_ScopeDeniesAccess_ReturnsPermissionsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductAttributeCombinationModel(); + + var result = await _controller.AttributeCombinationPopup("p1", model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.InsertOrUpdateProductAttributeCombinationPopup(It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task AttributeCombinationPopupPost_ScopeGrantsAccess_NoWarnings_ReturnsEmptyContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductAttributeCombinationModel(); + _productViewModelServiceMock.Setup(s => s.InsertOrUpdateProductAttributeCombinationPopup(product, model)) + .ReturnsAsync(new List()); + + var result = await _controller.AttributeCombinationPopup("p1", model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("", content.Content); + } + + [TestMethod] + public async Task AttributeCombinationPopupPost_ScopeGrantsAccess_WithWarnings_ReturnsViewWithWarnings() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductAttributeCombinationModel(); + _productViewModelServiceMock.Setup(s => s.InsertOrUpdateProductAttributeCombinationPopup(product, model)) + .ReturnsAsync(new List { "warning" }); + + var result = await _controller.AttributeCombinationPopup("p1", model); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreSame(model, view.Model); + CollectionAssert.Contains(model.Warnings.ToList(), "warning"); + _productViewModelServiceMock.Verify(s => s.PrepareAddProductAttributeCombinationModel(model, product), + Times.Once); + } + + // --- GenerateAllAttributeCombinations --------------------------------------------------------------- + + [TestMethod] + public async Task GenerateAllAttributeCombinations_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + + await Assert.ThrowsExactlyAsync( + () => _controller.GenerateAllAttributeCombinations("missing")); + } + + [TestMethod] + public async Task GenerateAllAttributeCombinations_ScopeDeniesAccess_ReturnsErrorJson_DoesNotGenerate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.GenerateAllAttributeCombinations("p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.GenerateAllAttributeCombinations(It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task GenerateAllAttributeCombinations_ScopeGrantsAccess_Generates() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.GenerateAllAttributeCombinations("p1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.GenerateAllAttributeCombinations(product), Times.Once); + } + + // --- ClearAllAttributeCombinations ----------------------------------------------------------------- + + [TestMethod] + public async Task ClearAllAttributeCombinations_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + + await Assert.ThrowsExactlyAsync( + () => _controller.ClearAllAttributeCombinations("missing")); + } + + [TestMethod] + public async Task ClearAllAttributeCombinations_ScopeDeniesAccess_ReturnsErrorJson_DoesNotClear() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ClearAllAttributeCombinations("p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.ClearAllAttributeCombinations(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ClearAllAttributeCombinations_ScopeGrantsAccess_ValidModel_Clears() + { + var product = new Product + { Id = "p1", ManageInventoryMethodId = ManageInventoryMethod.DontManageStock }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.ClearAllAttributeCombinations("p1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.ClearAllAttributeCombinations(product), Times.Once); + } + + [TestMethod] + public async Task ClearAllAttributeCombinations_ScopeGrantsAccess_InvalidModelState_ReturnsErrorJson_DoesNotClear() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _controller.ModelState.AddModelError("x", "err"); + + var result = await _controller.ClearAllAttributeCombinations("p1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.ClearAllAttributeCombinations(It.IsAny()), Times.Never); + } + + // --- ProductAttributeCombinationTierPriceList (POST) ----------------------------------------------- + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeCombinationTierPriceList(new DataSourceRequest(), "p1", "c1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.PrepareProductAttributeCombinationTierPricesModel(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeCombinationTierPricesModel(product, "c1")) + .ReturnsAsync(new List { new() }); + + var result = await _controller.ProductAttributeCombinationTierPriceList(new DataSourceRequest(), "p1", "c1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as DataSourceResult; + Assert.AreEqual(1, gridModel.Total); + } + + // --- ProductAttributeCombinationTierPriceInsert ----------------------------------------------------- + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceInsert_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeCombinationTierPriceInsert("missing", "c1", + new ProductModel.ProductAttributeCombinationTierPricesModel())); + } + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceInsert_ScopeDeniesAccess_ReturnsContent_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + product.ProductAttributeCombinations.Add(new ProductAttributeCombination { Id = "c1" }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeCombinationTierPriceInsert("p1", "c1", + new ProductModel.ProductAttributeCombinationTierPricesModel()); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.InsertProductAttributeCombinationTierPricesModel(It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceInsert_ScopeGrantsAccess_CombinationMissing_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.ProductAttributeCombinationTierPriceInsert("p1", "missing-c", + new ProductModel.ProductAttributeCombinationTierPricesModel()); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.InsertProductAttributeCombinationTierPricesModel(It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceInsert_ScopeGrantsAccess_Inserts() + { + var product = new Product { Id = "p1" }; + var combination = new ProductAttributeCombination { Id = "c1" }; + product.ProductAttributeCombinations.Add(combination); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeCombinationTierPricesModel(); + + var result = await _controller.ProductAttributeCombinationTierPriceInsert("p1", "c1", model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.InsertProductAttributeCombinationTierPricesModel(product, combination, model), Times.Once); + } + + // --- ProductAttributeCombinationTierPriceUpdate ----------------------------------------------------- + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceUpdate_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeCombinationTierPriceUpdate("missing", "c1", + new ProductModel.ProductAttributeCombinationTierPricesModel())); + } + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceUpdate_ScopeDeniesAccess_ReturnsContent_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + product.ProductAttributeCombinations.Add(new ProductAttributeCombination { Id = "c1" }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeCombinationTierPriceUpdate("p1", "c1", + new ProductModel.ProductAttributeCombinationTierPricesModel()); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.UpdateProductAttributeCombinationTierPricesModel(It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceUpdate_ScopeGrantsAccess_Updates() + { + var product = new Product { Id = "p1" }; + var combination = new ProductAttributeCombination { Id = "c1" }; + product.ProductAttributeCombinations.Add(combination); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeCombinationTierPricesModel(); + + var result = await _controller.ProductAttributeCombinationTierPriceUpdate("p1", "c1", model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.UpdateProductAttributeCombinationTierPricesModel(product, combination, model), Times.Once); + } + + // --- ProductAttributeCombinationTierPriceDelete ----------------------------------------------------- + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceDelete_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeCombinationTierPriceDelete("missing", "c1", "tp1")); + } + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceDelete_ScopeDeniesAccess_ReturnsContent_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + product.ProductAttributeCombinations.Add(new ProductAttributeCombination { Id = "c1" }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeCombinationTierPriceDelete("p1", "c1", "tp1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.DeleteProductAttributeCombinationTierPrices(It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceDelete_ScopeGrantsAccess_TierPriceMissing_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + product.ProductAttributeCombinations.Add(new ProductAttributeCombination { Id = "c1" }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.ProductAttributeCombinationTierPriceDelete("p1", "c1", "missing-tp"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.DeleteProductAttributeCombinationTierPrices(It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceDelete_ScopeGrantsAccess_Deletes() + { + var product = new Product { Id = "p1" }; + var combination = new ProductAttributeCombination { Id = "c1" }; + var tierPrice = new ProductCombinationTierPrices { Id = "tp1" }; + combination.TierPrices.Add(tierPrice); + product.ProductAttributeCombinations.Add(combination); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.ProductAttributeCombinationTierPriceDelete("p1", "c1", "tp1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.DeleteProductAttributeCombinationTierPrices(product, combination, tierPrice), Times.Once); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index 4dd8a7c65..e0b6af392 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -1095,508 +1095,4 @@ public async Task BulkEditUpdate_ProductsWithEmptyIds_AreIgnoredWithoutServiceCa _productViewModelServiceMock.Verify( s => s.UpdateBulkEdit(It.IsAny>()), Times.Never); } - - // --- ProductAttributeCombinationList (POST) ------------------------------------------------------- - - [TestMethod] - public async Task ProductAttributeCombinationList_ScopeDeniesAccess_ReturnsErrorJson() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - - var result = await _controller.ProductAttributeCombinationList(new DataSourceRequest(), "p1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( - s => s.PrepareProductAttributeCombinationModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductAttributeCombinationList_ScopeGrantsAccess_ReturnsGrid() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeCombinationModel(product)) - .ReturnsAsync(new List { new() }); - - var result = await _controller.ProductAttributeCombinationList(new DataSourceRequest(), "p1"); - - var json = result as JsonResult; - Assert.IsNotNull(json); - var gridModel = json.Value as DataSourceResult; - Assert.AreEqual(1, gridModel.Total); - } - - // --- ProductAttributeCombinationDelete ------------------------------------------------------------- - - [TestMethod] - public async Task ProductAttributeCombinationDelete_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); - var attrServiceMock = new Mock(); - - await Assert.ThrowsExactlyAsync( - () => _controller.ProductAttributeCombinationDelete("c1", "missing", attrServiceMock.Object)); - } - - [TestMethod] - public async Task ProductAttributeCombinationDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() - { - var product = new Product { Id = "p1" }; - product.ProductAttributeCombinations.Add(new ProductAttributeCombination { Id = "c1" }); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var attrServiceMock = new Mock(); - - var result = await _controller.ProductAttributeCombinationDelete("c1", "p1", attrServiceMock.Object); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - attrServiceMock.Verify( - s => s.DeleteProductAttributeCombination(It.IsAny(), It.IsAny()), - Times.Never); - } - - [TestMethod] - public async Task ProductAttributeCombinationDelete_ScopeGrantsAccess_CombinationMissing_Throws() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var attrServiceMock = new Mock(); - - await Assert.ThrowsExactlyAsync( - () => _controller.ProductAttributeCombinationDelete("missing-c", "p1", attrServiceMock.Object)); - } - - [TestMethod] - public async Task ProductAttributeCombinationDelete_ScopeGrantsAccess_Deletes() - { - var product = new Product { Id = "p1", ManageInventoryMethodId = ManageInventoryMethod.DontManageStock }; - var combination = new ProductAttributeCombination { Id = "c1" }; - product.ProductAttributeCombinations.Add(combination); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var attrServiceMock = new Mock(); - - var result = await _controller.ProductAttributeCombinationDelete("c1", "p1", attrServiceMock.Object); - - Assert.IsInstanceOfType(result); - attrServiceMock.Verify(s => s.DeleteProductAttributeCombination(combination, "p1"), Times.Once); - } - - // --- AttributeCombinationPopup (GET) --------------------------------------------------------------- - - [TestMethod] - public async Task AttributeCombinationPopupGet_ScopeDeniesAccess_ReturnsPermissionsContent() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - var result = await _controller.AttributeCombinationPopup("p1", "c1"); - - var content = result as ContentResult; - Assert.IsNotNull(content); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( - s => s.PrepareProductAttributeCombinationModel(It.IsAny(), It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task AttributeCombinationPopupGet_ScopeGrantsAccess_ReturnsView() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductAttributeCombinationModel { Id = "c1" }; - _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeCombinationModel(product, "c1")) - .ReturnsAsync(model); - - var result = await _controller.AttributeCombinationPopup("p1", "c1"); - - var view = result as ViewResult; - Assert.IsNotNull(view); - Assert.AreSame(model, view.Model); - _productViewModelServiceMock.Verify(s => s.PrepareAddProductAttributeCombinationModel(model, product), - Times.Once); - } - - // --- AttributeCombinationPopup (POST) -------------------------------------------------------------- - - [TestMethod] - public async Task AttributeCombinationPopupPost_MissingProduct_RedirectsToList() - { - _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); - var model = new ProductAttributeCombinationModel(); - - var result = await _controller.AttributeCombinationPopup("missing", model); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("List", redirect.ActionName); - _scopeMock.Verify(s => s.HasAccess(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task AttributeCombinationPopupPost_ScopeDeniesAccess_ReturnsPermissionsContent() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductAttributeCombinationModel(); - - var result = await _controller.AttributeCombinationPopup("p1", model); - - var content = result as ContentResult; - Assert.IsNotNull(content); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( - s => s.InsertOrUpdateProductAttributeCombinationPopup(It.IsAny(), - It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task AttributeCombinationPopupPost_ScopeGrantsAccess_NoWarnings_ReturnsEmptyContent() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductAttributeCombinationModel(); - _productViewModelServiceMock.Setup(s => s.InsertOrUpdateProductAttributeCombinationPopup(product, model)) - .ReturnsAsync(new List()); - - var result = await _controller.AttributeCombinationPopup("p1", model); - - var content = result as ContentResult; - Assert.IsNotNull(content); - Assert.AreEqual("", content.Content); - } - - [TestMethod] - public async Task AttributeCombinationPopupPost_ScopeGrantsAccess_WithWarnings_ReturnsViewWithWarnings() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductAttributeCombinationModel(); - _productViewModelServiceMock.Setup(s => s.InsertOrUpdateProductAttributeCombinationPopup(product, model)) - .ReturnsAsync(new List { "warning" }); - - var result = await _controller.AttributeCombinationPopup("p1", model); - - var view = result as ViewResult; - Assert.IsNotNull(view); - Assert.AreSame(model, view.Model); - CollectionAssert.Contains(model.Warnings.ToList(), "warning"); - _productViewModelServiceMock.Verify(s => s.PrepareAddProductAttributeCombinationModel(model, product), - Times.Once); - } - - // --- GenerateAllAttributeCombinations --------------------------------------------------------------- - - [TestMethod] - public async Task GenerateAllAttributeCombinations_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); - - await Assert.ThrowsExactlyAsync( - () => _controller.GenerateAllAttributeCombinations("missing")); - } - - [TestMethod] - public async Task GenerateAllAttributeCombinations_ScopeDeniesAccess_ReturnsErrorJson_DoesNotGenerate() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - var result = await _controller.GenerateAllAttributeCombinations("p1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.GenerateAllAttributeCombinations(It.IsAny()), - Times.Never); - } - - [TestMethod] - public async Task GenerateAllAttributeCombinations_ScopeGrantsAccess_Generates() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - - var result = await _controller.GenerateAllAttributeCombinations("p1"); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.GenerateAllAttributeCombinations(product), Times.Once); - } - - // --- ClearAllAttributeCombinations ----------------------------------------------------------------- - - [TestMethod] - public async Task ClearAllAttributeCombinations_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); - - await Assert.ThrowsExactlyAsync( - () => _controller.ClearAllAttributeCombinations("missing")); - } - - [TestMethod] - public async Task ClearAllAttributeCombinations_ScopeDeniesAccess_ReturnsErrorJson_DoesNotClear() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - var result = await _controller.ClearAllAttributeCombinations("p1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.ClearAllAttributeCombinations(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ClearAllAttributeCombinations_ScopeGrantsAccess_ValidModel_Clears() - { - var product = new Product - { Id = "p1", ManageInventoryMethodId = ManageInventoryMethod.DontManageStock }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - - var result = await _controller.ClearAllAttributeCombinations("p1"); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.ClearAllAttributeCombinations(product), Times.Once); - } - - [TestMethod] - public async Task ClearAllAttributeCombinations_ScopeGrantsAccess_InvalidModelState_ReturnsErrorJson_DoesNotClear() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _controller.ModelState.AddModelError("x", "err"); - - var result = await _controller.ClearAllAttributeCombinations("p1"); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.ClearAllAttributeCombinations(It.IsAny()), Times.Never); - } - - // --- ProductAttributeCombinationTierPriceList (POST) ----------------------------------------------- - - [TestMethod] - public async Task ProductAttributeCombinationTierPriceList_ScopeDeniesAccess_ReturnsErrorJson() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - var result = await _controller.ProductAttributeCombinationTierPriceList(new DataSourceRequest(), "p1", "c1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( - s => s.PrepareProductAttributeCombinationTierPricesModel(It.IsAny(), It.IsAny()), - Times.Never); - } - - [TestMethod] - public async Task ProductAttributeCombinationTierPriceList_ScopeGrantsAccess_ReturnsGrid() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeCombinationTierPricesModel(product, "c1")) - .ReturnsAsync(new List { new() }); - - var result = await _controller.ProductAttributeCombinationTierPriceList(new DataSourceRequest(), "p1", "c1"); - - var json = result as JsonResult; - Assert.IsNotNull(json); - var gridModel = json.Value as DataSourceResult; - Assert.AreEqual(1, gridModel.Total); - } - - // --- ProductAttributeCombinationTierPriceInsert ----------------------------------------------------- - - [TestMethod] - public async Task ProductAttributeCombinationTierPriceInsert_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); - - await Assert.ThrowsExactlyAsync( - () => _controller.ProductAttributeCombinationTierPriceInsert("missing", "c1", - new ProductModel.ProductAttributeCombinationTierPricesModel())); - } - - [TestMethod] - public async Task ProductAttributeCombinationTierPriceInsert_ScopeDeniesAccess_ReturnsContent_DoesNotInsert() - { - var product = new Product { Id = "p1" }; - product.ProductAttributeCombinations.Add(new ProductAttributeCombination { Id = "c1" }); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - var result = await _controller.ProductAttributeCombinationTierPriceInsert("p1", "c1", - new ProductModel.ProductAttributeCombinationTierPricesModel()); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( - s => s.InsertProductAttributeCombinationTierPricesModel(It.IsAny(), - It.IsAny(), - It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductAttributeCombinationTierPriceInsert_ScopeGrantsAccess_CombinationMissing_DoesNotInsert() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - - var result = await _controller.ProductAttributeCombinationTierPriceInsert("p1", "missing-c", - new ProductModel.ProductAttributeCombinationTierPricesModel()); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( - s => s.InsertProductAttributeCombinationTierPricesModel(It.IsAny(), - It.IsAny(), - It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductAttributeCombinationTierPriceInsert_ScopeGrantsAccess_Inserts() - { - var product = new Product { Id = "p1" }; - var combination = new ProductAttributeCombination { Id = "c1" }; - product.ProductAttributeCombinations.Add(combination); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductAttributeCombinationTierPricesModel(); - - var result = await _controller.ProductAttributeCombinationTierPriceInsert("p1", "c1", model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( - s => s.InsertProductAttributeCombinationTierPricesModel(product, combination, model), Times.Once); - } - - // --- ProductAttributeCombinationTierPriceUpdate ----------------------------------------------------- - - [TestMethod] - public async Task ProductAttributeCombinationTierPriceUpdate_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); - - await Assert.ThrowsExactlyAsync( - () => _controller.ProductAttributeCombinationTierPriceUpdate("missing", "c1", - new ProductModel.ProductAttributeCombinationTierPricesModel())); - } - - [TestMethod] - public async Task ProductAttributeCombinationTierPriceUpdate_ScopeDeniesAccess_ReturnsContent_DoesNotUpdate() - { - var product = new Product { Id = "p1" }; - product.ProductAttributeCombinations.Add(new ProductAttributeCombination { Id = "c1" }); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - var result = await _controller.ProductAttributeCombinationTierPriceUpdate("p1", "c1", - new ProductModel.ProductAttributeCombinationTierPricesModel()); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( - s => s.UpdateProductAttributeCombinationTierPricesModel(It.IsAny(), - It.IsAny(), - It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductAttributeCombinationTierPriceUpdate_ScopeGrantsAccess_Updates() - { - var product = new Product { Id = "p1" }; - var combination = new ProductAttributeCombination { Id = "c1" }; - product.ProductAttributeCombinations.Add(combination); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductAttributeCombinationTierPricesModel(); - - var result = await _controller.ProductAttributeCombinationTierPriceUpdate("p1", "c1", model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( - s => s.UpdateProductAttributeCombinationTierPricesModel(product, combination, model), Times.Once); - } - - // --- ProductAttributeCombinationTierPriceDelete ----------------------------------------------------- - - [TestMethod] - public async Task ProductAttributeCombinationTierPriceDelete_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); - - await Assert.ThrowsExactlyAsync( - () => _controller.ProductAttributeCombinationTierPriceDelete("missing", "c1", "tp1")); - } - - [TestMethod] - public async Task ProductAttributeCombinationTierPriceDelete_ScopeDeniesAccess_ReturnsContent_DoesNotDelete() - { - var product = new Product { Id = "p1" }; - product.ProductAttributeCombinations.Add(new ProductAttributeCombination { Id = "c1" }); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - var result = await _controller.ProductAttributeCombinationTierPriceDelete("p1", "c1", "tp1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( - s => s.DeleteProductAttributeCombinationTierPrices(It.IsAny(), - It.IsAny(), - It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductAttributeCombinationTierPriceDelete_ScopeGrantsAccess_TierPriceMissing_DoesNotDelete() - { - var product = new Product { Id = "p1" }; - product.ProductAttributeCombinations.Add(new ProductAttributeCombination { Id = "c1" }); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - - var result = await _controller.ProductAttributeCombinationTierPriceDelete("p1", "c1", "missing-tp"); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( - s => s.DeleteProductAttributeCombinationTierPrices(It.IsAny(), - It.IsAny(), - It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductAttributeCombinationTierPriceDelete_ScopeGrantsAccess_Deletes() - { - var product = new Product { Id = "p1" }; - var combination = new ProductAttributeCombination { Id = "c1" }; - var tierPrice = new ProductCombinationTierPrices { Id = "tp1" }; - combination.TierPrices.Add(tierPrice); - product.ProductAttributeCombinations.Add(combination); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - - var result = await _controller.ProductAttributeCombinationTierPriceDelete("p1", "c1", "tp1"); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( - s => s.DeleteProductAttributeCombinationTierPrices(product, combination, tierPrice), Times.Once); - } } diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/AttributeCombinationPopup.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/ProductAttributeCombination/AttributeCombinationPopup.cshtml similarity index 90% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/AttributeCombinationPopup.cshtml rename to src/Web/Grand.Web.Admin/Areas/Admin/Views/ProductAttributeCombination/AttributeCombinationPopup.cshtml index 5374afc69..0a5bf577c 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/AttributeCombinationPopup.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/ProductAttributeCombination/AttributeCombinationPopup.cshtml @@ -7,7 +7,7 @@ Layout = ""; ViewBag.Title = string.IsNullOrEmpty(Model.Id) ? Loc["Admin.Catalog.Products.ProductAttributes.AttributeCombinations.AddTitle"] : Loc["Admin.Catalog.Products.ProductAttributes.AttributeCombinations.Update"]; } - new SelectListItem(group.Name ?? "", group.Id))); } diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductAttributeCombinationController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductAttributeCombinationController.cs new file mode 100644 index 000000000..8f8082f58 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Controllers/ProductAttributeCombinationController.cs @@ -0,0 +1,22 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.Admin.Extensions; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Admin.Controllers; + +[AuthorizeAdmin] +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaAdmin)] +[AuthorizeMenu] +public class ProductAttributeCombinationController( + IProductViewModelService productViewModelService, + IProductService productService, + IInventoryManageService inventoryManageService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductAttributeCombinationController(productViewModelService, productService, inventoryManageService, translationService, scope); diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAttributeCombinationController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAttributeCombinationController.cs new file mode 100644 index 000000000..2e72b4438 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAttributeCombinationController.cs @@ -0,0 +1,300 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Domain.Permissions; +using Grand.SharedKernel.Extensions; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.AdminShared.Controllers; + +[PermissionAuthorize(PermissionSystemName.Products)] +[AutoValidateAntiforgeryToken] +public abstract class BaseProductAttributeCombinationController( + IProductViewModelService productViewModelService, + IProductService productService, + IInventoryManageService inventoryManageService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseController +{ + #region Product attribute combinations + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ProductAttributeCombinationList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // CanView on this read: a product shared with other stores opens read-only on Store, so its + // tabs must load; the mutating actions below keep the strict HasAccess. History: + // HasAccess added here: Admin's original had no ownership check on this grid at all. Store's + // original used CanAccessProduct + a hardcoded "Admin.Catalog.Products.Permissions" resource key + // (even though it's the Store host); Vendor's original used a local CheckAccessToProduct helper + // that returned a plain hardcoded string ("This is not your product" / "Product not exists") + // rather than a resource key. scope.HasAccess plus the scope.ResourceKeyPrefix-qualified resource + // key normalizes all three to the convention used throughout this file. + if (!await scope.CanView(product)) + return ErrorForKendoGridJson( + translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var combinationsModel = await productViewModelService.PrepareProductAttributeCombinationModel(product); + var gridModel = new DataSourceResult { + Data = combinationsModel, + Total = combinationsModel.Count + }; + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeCombinationDelete(string id, string productId, + [FromServices] IProductAttributeService productAttributeService) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // HasAccess added here, checked before the combination lookup: matches the + // ProductAttributeValueDelete precedent above (deny before revealing whether the sub-entity + // exists). Vendor's original combined the null-check and the ownership check into a single throw + // (an unhandled exception on denial); normalized here to the file's ErrorForKendoGridJson + // convention - this is a Kendo grid row-delete action - matching how Store's original reported + // denial (though Store checked access after the combination lookup, not before). + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson( + translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == id); + if (combination == null) + throw new ArgumentException("No product attribute combination found with the specified id"); + + await productAttributeService.DeleteProductAttributeCombination(combination, productId); + if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes) + { + var pr = await productService.GetProductById(productId); + pr.StockQuantity = pr.ProductAttributeCombinations.Sum(x => x.StockQuantity); + pr.ReservedQuantity = pr.ProductAttributeCombinations.Sum(x => x.ReservedQuantity); + await inventoryManageService.UpdateStockProduct(pr, false); + } + + return new JsonResult(""); + } + + //edit + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task AttributeCombinationPopup(string productId, string id) + { + var product = await productService.GetProductById(productId); + + // Content(...), not ErrorForKendoGridJson: Store's original used ErrorForKendoGridJson here even + // though this action returns a View inside a magnificPopup modal (not a grid), which would render + // raw JSON as page content on denial - using Content(...) instead, consistent with the + // EditAttributeValues GET popup correction in region "Product attribute values" above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var model = await productViewModelService.PrepareProductAttributeCombinationModel(product, id); + await productViewModelService.PrepareAddProductAttributeCombinationModel(model, product); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task AttributeCombinationPopup(string productId, + ProductAttributeCombinationModel model) + { + var product = await productService.GetProductById(productId); + if (product == null) + //No product found with the specified id + return RedirectToAction("List", "Product"); + + // Content(...) on denial, matching Store's original. Vendor's original folded denial into the + // same redirect used for a missing product; Content(...) surfaces the permissions message instead + // of silently redirecting, matching every other Edit-scoped action in this region. + // ProductAttributeCombinationModel (Grand.Web.AdminShared.Models.Catalog) does not implement + // IProductValidVendor and has no registered FluentValidation validator, so there is no + // validator-layer ownership check being displaced here - scope.HasAccess is the only guard. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var warnings = await productViewModelService.InsertOrUpdateProductAttributeCombinationPopup(product, model); + if (!warnings.Any()) return Content(""); + //If we got this far, something failed, redisplay form + await productViewModelService.PrepareAddProductAttributeCombinationModel(model, product); + model.Warnings = warnings; + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task GenerateAllAttributeCombinations(string productId) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // ErrorForKendoGridJson(...), not Content(...): this action is invoked via a dataType:'json' ajax + // call (see CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml). Store's original + // returned Content(resource) on denial - a plain string the client's dataType:'json' parser + // cannot parse - which threw a JSON-parse exception and fell into the ajax error callback, + // showing a generic "Error while generating attribute combinations" alert. Vendor's original + // folded denial into the null-product throw (also an unhandled exception). Normalized here to a + // real JSON response, which avoids that parse-exception/generic-alert path - but note the + // .cshtml's ajax success callback for this action ignores the response body entirely (it just + // unconditionally refreshes the grid), so the denial still isn't surfaced to the user; it's a + // silent no-op instead of a visible (if wrong) error. Server-side enforcement is correct either + // way - nothing is generated on denial - this is a pre-existing view-layer gap, out of scope for + // this controller-only row (flagged for Phase 2 view work). + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson( + translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + await productViewModelService.GenerateAllAttributeCombinations(product); + + return Json(new { Success = true }); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ClearAllAttributeCombinations(string productId) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // See the GenerateAllAttributeCombinations comment above - same dataType:'json' contract, same fix. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson( + translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.ClearAllAttributeCombinations(product); + + if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes) + { + product.StockQuantity = 0; + product.ReservedQuantity = 0; + await inventoryManageService.UpdateStockProduct(product, false); + } + + return Json(new { Success = true }); + } + + return ErrorForKendoGridJson(ModelState); + } + + #endregion + + #region Product Attribute combination - tier prices + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeCombinationTierPriceList(DataSourceRequest command, + string productId, string productAttributeCombinationId) + { + var product = await productService.GetProductById(productId); + + // HasAccess added here: Admin's original had no ownership check at all on this region (any of its + // four actions). Store checked CanAccessProduct on every action. Vendor's List/Delete used explicit + // checks (CheckAccessToProduct / HasAccessToProduct); Vendor's Insert/Update relied entirely on + // ProductAttributeCombinationTierPricesModel implementing IProductValidVendor (the global + // ValidationFilter resolves ProductValidVendor, a FluentValidation rule comparing + // product.VendorId to CurrentVendor.Id, and the action only proceeded inside `if + // (ModelState.IsValid)`) - a real check, not a no-op, but the shared AdminShared model used here + // does not implement that marker interface, so merging Vendor's Insert/Update as-is would silently + // drop vendor ownership enforcement. scope.HasAccess replaces it uniformly across all four actions + // (no-op for Admin, equivalent-or-stronger for Store/Vendor). + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson( + translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var tierPriceModel = + await productViewModelService.PrepareProductAttributeCombinationTierPricesModel(product, + productAttributeCombinationId); + var gridModel = new DataSourceResult { + Data = tierPriceModel, + Total = tierPriceModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeCombinationTierPriceInsert(string productId, + string productAttributeCombinationId, ProductModel.ProductAttributeCombinationTierPricesModel model) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // See the List comment above - this is the action where Vendor's IProductValidVendor-driven check + // would have silently disappeared without an explicit scope.HasAccess call. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var combination = + product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == productAttributeCombinationId); + if (combination != null) + await productViewModelService.InsertProductAttributeCombinationTierPricesModel(product, combination, + model); + + return new JsonResult(""); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeCombinationTierPriceUpdate(string productId, + string productAttributeCombinationId, ProductModel.ProductAttributeCombinationTierPricesModel model) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // See the List comment above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == productAttributeCombinationId); + if (combination != null) + await productViewModelService.UpdateProductAttributeCombinationTierPricesModel(product, combination, + model); + + return new JsonResult(""); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeCombinationTierPriceDelete(string productId, + string productAttributeCombinationId, string id) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // HasAccess here matches Store's CanAccessProduct and Vendor's HasAccessToProduct checks (both + // already present on Delete in the originals); normalized to scope.HasAccess for Admin too. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == productAttributeCombinationId); + if (combination != null) + { + var tierPrice = combination.TierPrices.FirstOrDefault(x => x.Id == id); + if (tierPrice != null) + await productViewModelService.DeleteProductAttributeCombinationTierPrices(product, combination, + tierPrice); + } + + return new JsonResult(""); + } + + #endregion +} diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index 232dd530a..97837b410 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -690,279 +690,4 @@ private async Task> FilterBulkEditProductsByAccess( } #endregion - - #region Product attribute combinations - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductAttributeCombinationList(DataSourceRequest command, string productId) - { - var product = await productService.GetProductById(productId); - - // CanView on this read: a product shared with other stores opens read-only on Store, so its - // tabs must load; the mutating actions below keep the strict HasAccess. History: - // HasAccess added here: Admin's original had no ownership check on this grid at all. Store's - // original used CanAccessProduct + a hardcoded "Admin.Catalog.Products.Permissions" resource key - // (even though it's the Store host); Vendor's original used a local CheckAccessToProduct helper - // that returned a plain hardcoded string ("This is not your product" / "Product not exists") - // rather than a resource key. scope.HasAccess plus the scope.ResourceKeyPrefix-qualified resource - // key normalizes all three to the convention used throughout this file. - if (!await scope.CanView(product)) - return ErrorForKendoGridJson( - translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var combinationsModel = await productViewModelService.PrepareProductAttributeCombinationModel(product); - var gridModel = new DataSourceResult { - Data = combinationsModel, - Total = combinationsModel.Count - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeCombinationDelete(string id, string productId, - [FromServices] IProductAttributeService productAttributeService) - { - var product = await productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - // HasAccess added here, checked before the combination lookup: matches the - // ProductAttributeValueDelete precedent above (deny before revealing whether the sub-entity - // exists). Vendor's original combined the null-check and the ownership check into a single throw - // (an unhandled exception on denial); normalized here to the file's ErrorForKendoGridJson - // convention - this is a Kendo grid row-delete action - matching how Store's original reported - // denial (though Store checked access after the combination lookup, not before). - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson( - translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == id); - if (combination == null) - throw new ArgumentException("No product attribute combination found with the specified id"); - - await productAttributeService.DeleteProductAttributeCombination(combination, productId); - if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes) - { - var pr = await productService.GetProductById(productId); - pr.StockQuantity = pr.ProductAttributeCombinations.Sum(x => x.StockQuantity); - pr.ReservedQuantity = pr.ProductAttributeCombinations.Sum(x => x.ReservedQuantity); - await inventoryManageService.UpdateStockProduct(pr, false); - } - - return new JsonResult(""); - } - - //edit - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task AttributeCombinationPopup(string productId, string id) - { - var product = await productService.GetProductById(productId); - - // Content(...), not ErrorForKendoGridJson: Store's original used ErrorForKendoGridJson here even - // though this action returns a View inside a magnificPopup modal (not a grid), which would render - // raw JSON as page content on denial - using Content(...) instead, consistent with the - // EditAttributeValues GET popup correction in region "Product attribute values" above. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var model = await productViewModelService.PrepareProductAttributeCombinationModel(product, id); - await productViewModelService.PrepareAddProductAttributeCombinationModel(model, product); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AttributeCombinationPopup(string productId, - ProductAttributeCombinationModel model) - { - var product = await productService.GetProductById(productId); - if (product == null) - //No product found with the specified id - return RedirectToAction("List", "Product"); - - // Content(...) on denial, matching Store's original. Vendor's original folded denial into the - // same redirect used for a missing product; Content(...) surfaces the permissions message instead - // of silently redirecting, matching every other Edit-scoped action in this region. - // ProductAttributeCombinationModel (Grand.Web.AdminShared.Models.Catalog) does not implement - // IProductValidVendor and has no registered FluentValidation validator, so there is no - // validator-layer ownership check being displaced here - scope.HasAccess is the only guard. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var warnings = await productViewModelService.InsertOrUpdateProductAttributeCombinationPopup(product, model); - if (!warnings.Any()) return Content(""); - //If we got this far, something failed, redisplay form - await productViewModelService.PrepareAddProductAttributeCombinationModel(model, product); - model.Warnings = warnings; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task GenerateAllAttributeCombinations(string productId) - { - var product = await productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - // ErrorForKendoGridJson(...), not Content(...): this action is invoked via a dataType:'json' ajax - // call (see CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml). Store's original - // returned Content(resource) on denial - a plain string the client's dataType:'json' parser - // cannot parse - which threw a JSON-parse exception and fell into the ajax error callback, - // showing a generic "Error while generating attribute combinations" alert. Vendor's original - // folded denial into the null-product throw (also an unhandled exception). Normalized here to a - // real JSON response, which avoids that parse-exception/generic-alert path - but note the - // .cshtml's ajax success callback for this action ignores the response body entirely (it just - // unconditionally refreshes the grid), so the denial still isn't surfaced to the user; it's a - // silent no-op instead of a visible (if wrong) error. Server-side enforcement is correct either - // way - nothing is generated on denial - this is a pre-existing view-layer gap, out of scope for - // this controller-only row (flagged for Phase 2 view work). - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson( - translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - await productViewModelService.GenerateAllAttributeCombinations(product); - - return Json(new { Success = true }); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ClearAllAttributeCombinations(string productId) - { - var product = await productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - // See the GenerateAllAttributeCombinations comment above - same dataType:'json' contract, same fix. - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson( - translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await productViewModelService.ClearAllAttributeCombinations(product); - - if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes) - { - product.StockQuantity = 0; - product.ReservedQuantity = 0; - await inventoryManageService.UpdateStockProduct(product, false); - } - - return Json(new { Success = true }); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Product Attribute combination - tier prices - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeCombinationTierPriceList(DataSourceRequest command, - string productId, string productAttributeCombinationId) - { - var product = await productService.GetProductById(productId); - - // HasAccess added here: Admin's original had no ownership check at all on this region (any of its - // four actions). Store checked CanAccessProduct on every action. Vendor's List/Delete used explicit - // checks (CheckAccessToProduct / HasAccessToProduct); Vendor's Insert/Update relied entirely on - // ProductAttributeCombinationTierPricesModel implementing IProductValidVendor (the global - // ValidationFilter resolves ProductValidVendor, a FluentValidation rule comparing - // product.VendorId to CurrentVendor.Id, and the action only proceeded inside `if - // (ModelState.IsValid)`) - a real check, not a no-op, but the shared AdminShared model used here - // does not implement that marker interface, so merging Vendor's Insert/Update as-is would silently - // drop vendor ownership enforcement. scope.HasAccess replaces it uniformly across all four actions - // (no-op for Admin, equivalent-or-stronger for Store/Vendor). - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson( - translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var tierPriceModel = - await productViewModelService.PrepareProductAttributeCombinationTierPricesModel(product, - productAttributeCombinationId); - var gridModel = new DataSourceResult { - Data = tierPriceModel, - Total = tierPriceModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeCombinationTierPriceInsert(string productId, - string productAttributeCombinationId, ProductModel.ProductAttributeCombinationTierPricesModel model) - { - var product = await productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - // See the List comment above - this is the action where Vendor's IProductValidVendor-driven check - // would have silently disappeared without an explicit scope.HasAccess call. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var combination = - product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == productAttributeCombinationId); - if (combination != null) - await productViewModelService.InsertProductAttributeCombinationTierPricesModel(product, combination, - model); - - return new JsonResult(""); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeCombinationTierPriceUpdate(string productId, - string productAttributeCombinationId, ProductModel.ProductAttributeCombinationTierPricesModel model) - { - var product = await productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - // See the List comment above. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == productAttributeCombinationId); - if (combination != null) - await productViewModelService.UpdateProductAttributeCombinationTierPricesModel(product, combination, - model); - - return new JsonResult(""); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeCombinationTierPriceDelete(string productId, - string productAttributeCombinationId, string id) - { - var product = await productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - // HasAccess here matches Store's CanAccessProduct and Vendor's HasAccessToProduct checks (both - // already present on Delete in the originals); normalized to scope.HasAccess for Admin too. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == productAttributeCombinationId); - if (combination != null) - { - var tierPrice = combination.TierPrices.FirstOrDefault(x => x.Id == id); - if (tierPrice != null) - await productViewModelService.DeleteProductAttributeCombinationTierPrices(product, combination, - tierPrice); - } - - return new JsonResult(""); - } - - #endregion } diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml index 3fc030042..13b0745fe 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml @@ -17,7 +17,7 @@

@if (!readOnly) { - @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.AttributeCombinations.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.AttributeCombinations.AddNew"] @@ -27,8 +27,8 @@
{{{ Attributes }}}
+ {{{ Attributes }}}
{{ Warnings }}
} @@ -123,7 +123,7 @@ $.ajax({ cache: false, type: 'POST', - url: '@Url.Action("GenerateAllAttributeCombinations", "Product", new { productId = Model.Id, area = area })', + url: '@Url.Action("GenerateAllAttributeCombinations", "ProductAttributeCombination", new { productId = Model.Id, area = area })', data: postData, dataType: 'json', success: function(data) { @@ -151,7 +151,7 @@ $.ajax({ cache: false, type: 'POST', - url: '@Url.Action("ClearAllAttributeCombinations", "Product", new { productId = Model.Id, area = area })', + url: '@Url.Action("ClearAllAttributeCombinations", "ProductAttributeCombination", new { productId = Model.Id, area = area })', data: postData, dataType: 'json', success: function(data) { diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/ProductAttributes.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductAttributeCombination/Partials/ProductAttributes.cshtml similarity index 100% rename from src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/ProductAttributes.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductAttributeCombination/Partials/ProductAttributes.cshtml diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/AttributeCombinationPopup.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/ProductAttributeCombination/AttributeCombinationPopup.cshtml similarity index 90% rename from src/Web/Grand.Web.Store/Areas/Store/Views/Product/AttributeCombinationPopup.cshtml rename to src/Web/Grand.Web.Store/Areas/Store/Views/ProductAttributeCombination/AttributeCombinationPopup.cshtml index 541f5d8c7..1b454ab35 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/AttributeCombinationPopup.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/ProductAttributeCombination/AttributeCombinationPopup.cshtml @@ -7,7 +7,7 @@ Layout = ""; ViewBag.Title = string.IsNullOrEmpty(Model.Id) ? Loc["Admin.Catalog.Products.ProductAttributes.AttributeCombinations.AddTitle"] : Loc["Admin.Catalog.Products.ProductAttributes.AttributeCombinations.Update"]; } - new SelectListItem(group.Name ?? "", group.Id))); } diff --git a/src/Web/Grand.Web.Store/Controllers/ProductAttributeCombinationController.cs b/src/Web/Grand.Web.Store/Controllers/ProductAttributeCombinationController.cs new file mode 100644 index 000000000..a2d89bd69 --- /dev/null +++ b/src/Web/Grand.Web.Store/Controllers/ProductAttributeCombinationController.cs @@ -0,0 +1,22 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Store.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Store.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaStore)] +[AuthorizeStore] +[AuthorizeMenu] +public class ProductAttributeCombinationController( + IProductViewModelService productViewModelService, + IProductService productService, + IInventoryManageService inventoryManageService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductAttributeCombinationController(productViewModelService, productService, inventoryManageService, translationService, scope); diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/AttributeCombinationPopup.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/ProductAttributeCombination/AttributeCombinationPopup.cshtml similarity index 89% rename from src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/AttributeCombinationPopup.cshtml rename to src/Web/Grand.Web.Vendor/Areas/Vendor/Views/ProductAttributeCombination/AttributeCombinationPopup.cshtml index 814f949ed..58fb67dd1 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/AttributeCombinationPopup.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/ProductAttributeCombination/AttributeCombinationPopup.cshtml @@ -3,7 +3,7 @@ Layout = ""; ViewBag.Title = string.IsNullOrEmpty(Model.Id) ? Loc["Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.AddTitle"] : Loc["Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.Update"]; } - diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductAttributeCombinationController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductAttributeCombinationController.cs new file mode 100644 index 000000000..4c2d5aaba --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductAttributeCombinationController.cs @@ -0,0 +1,22 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Vendor.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Vendor.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaVendor)] +[AuthorizeVendor] +[AuthorizeMenu] +public class ProductAttributeCombinationController( + IProductViewModelService productViewModelService, + IProductService productService, + IInventoryManageService inventoryManageService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductAttributeCombinationController(productViewModelService, productService, inventoryManageService, translationService, scope); From 8d7d576c4b9022c158604cb6fa9afe70fc779e5b Mon Sep 17 00:00:00 2001 From: support Date: Thu, 22 Oct 2026 06:43:27 +0200 Subject: [PATCH 12/27] Drop the tab dependencies left on the product controllers --- .../Controllers/BaseProductControllerTests.cs | 17 ++--------------- .../Controllers/ProductControllerTests.cs | 4 ---- .../Controllers/ProductController.cs | 9 ++------- .../Controllers/BaseProductController.cs | 8 -------- .../Controllers/ProductController.cs | 11 ++--------- .../Controllers/ProductController.cs | 9 ++------- 6 files changed, 8 insertions(+), 50 deletions(-) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index e0b6af392..b3a388005 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -7,13 +7,9 @@ using Grand.Business.Core.Utilities.Catalog; using Grand.Domain; using Grand.Domain.Catalog; -using Grand.Domain.Media; using Grand.Domain.Permissions; -using Grand.Infrastructure.Mapper; -using Grand.Mapping; using Grand.Web.AdminShared.Controllers; using Grand.Web.AdminShared.Interfaces; -using Grand.Web.AdminShared.Mapper; using Grand.Web.AdminShared.Models.Catalog; using Grand.Web.AdminShared.Models.Orders; using Grand.Web.Common.DataSource; @@ -38,30 +34,21 @@ public class BaseProductControllerTests : ProductControllerTestsBase scope) - : BaseProductController(productViewModelService, productService, inventoryManageService, - languageService, translationService, productReservationService, auctionService, - dateTimeService, permissionService, enumTranslationService, scope); + : BaseProductController(productViewModelService, productService, languageService, translationService, + dateTimeService, permissionService, scope); protected override TestProductController CreateController() => new( _productViewModelServiceMock.Object, _productServiceMock.Object, - _inventoryManageServiceMock.Object, _languageServiceMock.Object, _translationServiceMock.Object, - _productReservationServiceMock.Object, - _auctionServiceMock.Object, _dateTimeServiceMock.Object, _permissionServiceMock.Object, - _enumTranslationServiceMock.Object, _scopeMock.Object); // --- Edit (GET) -------------------------------------------------------------------------------- diff --git a/src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerTests.cs b/src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerTests.cs index 365735cc4..a683b3095 100644 --- a/src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerTests.cs +++ b/src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerTests.cs @@ -48,14 +48,10 @@ public void Setup() _controller = new ProductController( productViewModelServiceMock.Object, _productServiceMock.Object, - new Mock().Object, languageServiceMock.Object, translationServiceMock.Object, - new Mock().Object, - new Mock().Object, new Mock().Object, new Mock().Object, - new Mock().Object, _scopeMock.Object); var httpContext = new DefaultHttpContext(); diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductController.cs index 44c0b3013..0a7e0a44f 100644 --- a/src/Web/Grand.Web.Admin/Controllers/ProductController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/ProductController.cs @@ -19,15 +19,10 @@ namespace Grand.Web.Admin.Controllers; public class ProductController( IProductViewModelService productViewModelService, IProductService productService, - IInventoryManageService inventoryManageService, ILanguageService languageService, IRequestTranslationService translationService, - IProductReservationService productReservationService, - IAuctionService auctionService, IDateTimeService dateTimeService, IRequestPermissionService permissionService, - IEnumTranslationService enumTranslationService, IAdminDataScope scope) - : BaseProductController(productViewModelService, productService, inventoryManageService, languageService, - translationService, productReservationService, auctionService, dateTimeService, permissionService, - enumTranslationService, scope); + : BaseProductController(productViewModelService, productService, languageService, translationService, + dateTimeService, permissionService, scope); diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index 97837b410..754402b01 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -8,7 +8,6 @@ using Grand.Business.Core.Utilities.Catalog; using Grand.Domain.Catalog; using Grand.Domain.Common; -using Grand.Domain.Media; using Grand.Domain.Permissions; using Grand.SharedKernel.Extensions; using Grand.Web.AdminShared.Extensions.Mapping; @@ -19,13 +18,10 @@ using Grand.Web.Common.DataSource; using Grand.Web.Common.Extensions; using Grand.Web.Common.Filters; -using Grand.Web.Common.Helpers; using Grand.Web.Common.Localization; using Grand.Web.Common.Security.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Rendering; -using Microsoft.AspNetCore.StaticFiles; namespace Grand.Web.AdminShared.Controllers; @@ -34,14 +30,10 @@ namespace Grand.Web.AdminShared.Controllers; public abstract class BaseProductController( IProductViewModelService productViewModelService, IProductService productService, - IInventoryManageService inventoryManageService, ILanguageService languageService, IRequestTranslationService translationService, - IProductReservationService productReservationService, - IAuctionService auctionService, IDateTimeService dateTimeService, IRequestPermissionService permissionService, - IEnumTranslationService enumTranslationService, IAdminDataScope scope) : BaseController { diff --git a/src/Web/Grand.Web.Store/Controllers/ProductController.cs b/src/Web/Grand.Web.Store/Controllers/ProductController.cs index 7edf79bab..4ff451b3c 100644 --- a/src/Web/Grand.Web.Store/Controllers/ProductController.cs +++ b/src/Web/Grand.Web.Store/Controllers/ProductController.cs @@ -1,8 +1,6 @@ using Grand.Business.Core.Interfaces.Catalog.Products; using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Interfaces.Common.Localization; -using Grand.Business.Core.Interfaces.ExportImport; -using Grand.Business.Core.Interfaces.Storage; using Grand.Domain.Catalog; using Grand.Web.AdminShared.Controllers; using Grand.Web.AdminShared.Interfaces; @@ -21,18 +19,13 @@ namespace Grand.Web.Store.Controllers; public class ProductController( IProductViewModelService productViewModelService, IProductService productService, - IInventoryManageService inventoryManageService, ILanguageService languageService, IRequestTranslationService translationService, - IProductReservationService productReservationService, - IAuctionService auctionService, IDateTimeService dateTimeService, IRequestPermissionService permissionService, - IEnumTranslationService enumTranslationService, IAdminDataScope scope) - : BaseProductController(productViewModelService, productService, inventoryManageService, languageService, - translationService, productReservationService, auctionService, dateTimeService, permissionService, - enumTranslationService, scope) + : BaseProductController(productViewModelService, productService, languageService, translationService, + dateTimeService, permissionService, scope) { // Re-derived from the original Store ProductController.Edit(GET) (pre-Task-11: // src/Web/Grand.Web.Store/Controllers/ProductController.cs:184-189), not retyped from memory - the diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductController.cs index 9cfb555dc..b790514ca 100644 --- a/src/Web/Grand.Web.Vendor/Controllers/ProductController.cs +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductController.cs @@ -19,15 +19,10 @@ namespace Grand.Web.Vendor.Controllers; public class ProductController( IProductViewModelService productViewModelService, IProductService productService, - IInventoryManageService inventoryManageService, ILanguageService languageService, IRequestTranslationService translationService, - IProductReservationService productReservationService, - IAuctionService auctionService, IDateTimeService dateTimeService, IRequestPermissionService permissionService, - IEnumTranslationService enumTranslationService, IAdminDataScope scope) - : BaseProductController(productViewModelService, productService, inventoryManageService, languageService, - translationService, productReservationService, auctionService, dateTimeService, permissionService, - enumTranslationService, scope); + : BaseProductController(productViewModelService, productService, languageService, translationService, + dateTimeService, permissionService, scope); From 49f949e37687d5369b08781fb1af021947f8e609 Mon Sep 17 00:00:00 2001 From: support Date: Thu, 22 Oct 2026 06:46:29 +0200 Subject: [PATCH 13/27] Check the panel attributes and Vendor hooks of every product controller --- .../ProductControllerAttributesTests.cs | 29 ++++++++---- .../ProductControllerAttributesTests.cs | 29 ++++++++---- .../Controllers/ProductControllerTests.cs | 47 +++++++++++++++---- 3 files changed, 78 insertions(+), 27 deletions(-) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerAttributesTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerAttributesTests.cs index aa1495099..8f957909a 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerAttributesTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerAttributesTests.cs @@ -10,22 +10,33 @@ namespace Grand.Web.Admin.Tests.Controllers; public class ProductControllerAttributesTests { [TestMethod] - public void ProductController_CarriesRequiredAuthorizationAndCsrfAttributes() + [DataRow(typeof(ProductController))] + [DataRow(typeof(ProductAssociatedController))] + [DataRow(typeof(ProductAttributeCombinationController))] + [DataRow(typeof(ProductAttributeMappingController))] + [DataRow(typeof(ProductBidController))] + [DataRow(typeof(ProductBundleController))] + [DataRow(typeof(ProductCategoryController))] + [DataRow(typeof(ProductCollectionController))] + [DataRow(typeof(ProductPictureController))] + [DataRow(typeof(ProductPriceController))] + [DataRow(typeof(ProductRelatedController))] + [DataRow(typeof(ProductReservationController))] + [DataRow(typeof(ProductSpecificationController))] + public void ProductControllers_CarryRequiredAuthorizationAndCsrfAttributes(Type type) { - var type = typeof(ProductController); - - Assert.IsTrue(type.IsDefined(typeof(AuthorizeAdminAttribute), true), "Missing [AuthorizeAdmin]."); + Assert.IsTrue(type.IsDefined(typeof(AuthorizeAdminAttribute), true), $"{type.Name}: missing [AuthorizeAdmin]."); Assert.IsTrue(type.IsDefined(typeof(AutoValidateAntiforgeryTokenAttribute), true), - "Missing [AutoValidateAntiforgeryToken] - CSRF protection would be lost."); - Assert.IsTrue(type.IsDefined(typeof(AreaAttribute), true), "Missing [Area]."); - Assert.IsTrue(type.IsDefined(typeof(AuthorizeMenuAttribute), true), "Missing [AuthorizeMenu]."); + $"{type.Name}: missing [AutoValidateAntiforgeryToken] - CSRF protection would be lost."); + Assert.IsTrue(type.IsDefined(typeof(AreaAttribute), true), $"{type.Name}: missing [Area]."); + Assert.IsTrue(type.IsDefined(typeof(AuthorizeMenuAttribute), true), $"{type.Name}: missing [AuthorizeMenu]."); - // Inherited from BaseProductController - PermissionAuthorizeAttribute has no + // Inherited from the Base*Controller in AdminShared - PermissionAuthorizeAttribute has no // AttributeUsage(Inherited = false), so MVC's attribute discovery (inherit: true) picks it up // from the base class without the subclass needing to restate it. var permissionAttr = (PermissionAuthorizeAttribute)Attribute.GetCustomAttribute(type, typeof(PermissionAuthorizeAttribute), true); - Assert.IsNotNull(permissionAttr, "Missing [PermissionAuthorize] (expected via inheritance from BaseProductController)."); + Assert.IsNotNull(permissionAttr, $"{type.Name}: missing [PermissionAuthorize] (expected via inheritance from its base controller)."); Assert.AreEqual(PermissionSystemName.Products, permissionAttr.Permission); } } diff --git a/src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerAttributesTests.cs b/src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerAttributesTests.cs index 0d8ef6ffc..c06342640 100644 --- a/src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerAttributesTests.cs +++ b/src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerAttributesTests.cs @@ -10,22 +10,33 @@ namespace Grand.Web.Store.Tests.Controllers; public class ProductControllerAttributesTests { [TestMethod] - public void ProductController_CarriesRequiredAuthorizationAndCsrfAttributes() + [DataRow(typeof(ProductController))] + [DataRow(typeof(ProductAssociatedController))] + [DataRow(typeof(ProductAttributeCombinationController))] + [DataRow(typeof(ProductAttributeMappingController))] + [DataRow(typeof(ProductBidController))] + [DataRow(typeof(ProductBundleController))] + [DataRow(typeof(ProductCategoryController))] + [DataRow(typeof(ProductCollectionController))] + [DataRow(typeof(ProductPictureController))] + [DataRow(typeof(ProductPriceController))] + [DataRow(typeof(ProductRelatedController))] + [DataRow(typeof(ProductReservationController))] + [DataRow(typeof(ProductSpecificationController))] + public void ProductControllers_CarryRequiredAuthorizationAndCsrfAttributes(Type type) { - var type = typeof(ProductController); - - Assert.IsTrue(type.IsDefined(typeof(AuthorizeStoreAttribute), true), "Missing [AuthorizeStore]."); + Assert.IsTrue(type.IsDefined(typeof(AuthorizeStoreAttribute), true), $"{type.Name}: missing [AuthorizeStore]."); Assert.IsTrue(type.IsDefined(typeof(AutoValidateAntiforgeryTokenAttribute), true), - "Missing [AutoValidateAntiforgeryToken] - CSRF protection would be lost."); - Assert.IsTrue(type.IsDefined(typeof(AreaAttribute), true), "Missing [Area]."); - Assert.IsTrue(type.IsDefined(typeof(AuthorizeMenuAttribute), true), "Missing [AuthorizeMenu]."); + $"{type.Name}: missing [AutoValidateAntiforgeryToken] - CSRF protection would be lost."); + Assert.IsTrue(type.IsDefined(typeof(AreaAttribute), true), $"{type.Name}: missing [Area]."); + Assert.IsTrue(type.IsDefined(typeof(AuthorizeMenuAttribute), true), $"{type.Name}: missing [AuthorizeMenu]."); - // Inherited from BaseProductController - PermissionAuthorizeAttribute has no + // Inherited from the Base*Controller in AdminShared - PermissionAuthorizeAttribute has no // AttributeUsage(Inherited = false), so MVC's attribute discovery (inherit: true) picks it up // from the base class without the subclass needing to restate it. var permissionAttr = (PermissionAuthorizeAttribute)Attribute.GetCustomAttribute(type, typeof(PermissionAuthorizeAttribute), true); - Assert.IsNotNull(permissionAttr, "Missing [PermissionAuthorize] (expected via inheritance from BaseProductController)."); + Assert.IsNotNull(permissionAttr, $"{type.Name}: missing [PermissionAuthorize] (expected via inheritance from its base controller)."); Assert.AreEqual(PermissionSystemName.Products, permissionAttr.Permission); } } diff --git a/src/Tests/Grand.Web.Vendor.Tests/Controllers/ProductControllerTests.cs b/src/Tests/Grand.Web.Vendor.Tests/Controllers/ProductControllerTests.cs index 8e204653d..cd5fc9457 100644 --- a/src/Tests/Grand.Web.Vendor.Tests/Controllers/ProductControllerTests.cs +++ b/src/Tests/Grand.Web.Vendor.Tests/Controllers/ProductControllerTests.cs @@ -3,6 +3,7 @@ using Grand.Web.Common.Security.Authorization; using Grand.Web.Vendor.Controllers; using Microsoft.AspNetCore.Mvc; +using System.Reflection; namespace Grand.Web.Vendor.Tests.Controllers; @@ -10,22 +11,50 @@ namespace Grand.Web.Vendor.Tests.Controllers; public class ProductControllerTests { [TestMethod] - public void ProductController_CarriesRequiredAuthorizationAndCsrfAttributes() + [DataRow(typeof(ProductController))] + [DataRow(typeof(ProductAssociatedController))] + [DataRow(typeof(ProductAttributeCombinationController))] + [DataRow(typeof(ProductAttributeMappingController))] + [DataRow(typeof(ProductBidController))] + [DataRow(typeof(ProductBundleController))] + [DataRow(typeof(ProductCategoryController))] + [DataRow(typeof(ProductCollectionController))] + [DataRow(typeof(ProductPictureController))] + [DataRow(typeof(ProductPriceController))] + [DataRow(typeof(ProductRelatedController))] + [DataRow(typeof(ProductReservationController))] + [DataRow(typeof(ProductSpecificationController))] + public void ProductControllers_CarryRequiredAuthorizationAndCsrfAttributes(Type type) { - var type = typeof(ProductController); - - Assert.IsTrue(type.IsDefined(typeof(AuthorizeVendorAttribute), true), "Missing [AuthorizeVendor]."); + Assert.IsTrue(type.IsDefined(typeof(AuthorizeVendorAttribute), true), $"{type.Name}: missing [AuthorizeVendor]."); Assert.IsTrue(type.IsDefined(typeof(AutoValidateAntiforgeryTokenAttribute), true), - "Missing [AutoValidateAntiforgeryToken] - CSRF protection would be lost."); - Assert.IsTrue(type.IsDefined(typeof(AreaAttribute), true), "Missing [Area]."); - Assert.IsTrue(type.IsDefined(typeof(AuthorizeMenuAttribute), true), "Missing [AuthorizeMenu]."); + $"{type.Name}: missing [AutoValidateAntiforgeryToken] - CSRF protection would be lost."); + Assert.IsTrue(type.IsDefined(typeof(AreaAttribute), true), $"{type.Name}: missing [Area]."); + Assert.IsTrue(type.IsDefined(typeof(AuthorizeMenuAttribute), true), $"{type.Name}: missing [AuthorizeMenu]."); - // Inherited from BaseProductController - PermissionAuthorizeAttribute has no + // Inherited from the Base*Controller in AdminShared - PermissionAuthorizeAttribute has no // AttributeUsage(Inherited = false), so MVC's attribute discovery (inherit: true) picks it up // from the base class without the subclass needing to restate it. var permissionAttr = (PermissionAuthorizeAttribute)Attribute.GetCustomAttribute(type, typeof(PermissionAuthorizeAttribute), true); - Assert.IsNotNull(permissionAttr, "Missing [PermissionAuthorize] (expected via inheritance from BaseProductController)."); + Assert.IsNotNull(permissionAttr, $"{type.Name}: missing [PermissionAuthorize] (expected via inheritance from its base controller)."); Assert.AreEqual(PermissionSystemName.Products, permissionAttr.Permission); } + + // The Vendor-only answers the shared tab controllers leave to the host. A hook that is not overridden + // falls back to the Admin/Store behavior without a build error, so a lost override shows up only here. + [TestMethod] + [DataRow(typeof(ProductRelatedController), "InvalidRelatedProductAddPopupResult")] + [DataRow(typeof(ProductRelatedController), "InvalidSimilarProductAddPopupResult")] + [DataRow(typeof(ProductRelatedController), "InvalidCrossSellProductAddPopupResult")] + [DataRow(typeof(ProductRelatedController), "InvalidRecommendedProductAddPopupResult")] + [DataRow(typeof(ProductBundleController), "InvalidBundleProductAddPopupResult")] + [DataRow(typeof(ProductAssociatedController), "get_AssociatedProductVendorId")] + public void ProductControllers_OverrideTheVendorHooks(Type type, string member) + { + var method = type.GetMethod(member, BindingFlags.Instance | BindingFlags.NonPublic); + + Assert.IsNotNull(method, $"{type.Name}.{member} not found."); + Assert.AreEqual(type, method.DeclaringType, $"{type.Name} does not override {member}."); + } } From d35270ace24bac12f5acd46da6efbab1fcb7c38e Mon Sep 17 00:00:00 2001 From: support Date: Thu, 22 Oct 2026 07:26:50 +0200 Subject: [PATCH 14/27] Move the product specification view-model logic into its own service --- ...BaseProductSpecificationControllerTests.cs | 28 +++--- .../Controllers/ProductControllerTestsBase.cs | 2 + .../ProductSpecificationController.cs | 4 +- .../BaseProductSpecificationController.cs | 10 +- .../IProductSpecificationViewModelService.cs | 18 ++++ .../Interfaces/IProductViewModelService.cs | 10 -- .../ProductSpecificationViewModelService.cs | 98 +++++++++++++++++++ .../Services/ProductViewModelService.cs | 80 --------------- .../Startup/StartupApplication.cs | 1 + .../ProductSpecificationController.cs | 4 +- .../ProductSpecificationController.cs | 4 +- 11 files changed, 144 insertions(+), 115 deletions(-) create mode 100644 src/Web/Grand.Web.AdminShared/Interfaces/IProductSpecificationViewModelService.cs create mode 100644 src/Web/Grand.Web.AdminShared/Services/ProductSpecificationViewModelService.cs diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductSpecificationControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductSpecificationControllerTests.cs index 6bc7d0311..14db1b304 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductSpecificationControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductSpecificationControllerTests.cs @@ -23,14 +23,14 @@ public class BaseProductSpecificationControllerTests : ProductControllerTestsBas // BaseProductSpecificationController is abstract. This minimal subclass exists only so the actions under test // can be invoked directly. public class TestProductSpecificationController( - IProductViewModelService productViewModelService, + IProductSpecificationViewModelService productSpecificationViewModelService, IProductService productService, IRequestTranslationService translationService, IAdminDataScope scope) - : BaseProductSpecificationController(productViewModelService, productService, translationService, scope); + : BaseProductSpecificationController(productSpecificationViewModelService, productService, translationService, scope); protected override TestProductSpecificationController CreateController() => new( - _productViewModelServiceMock.Object, + _productSpecificationViewModelServiceMock.Object, _productServiceMock.Object, _translationServiceMock.Object, _scopeMock.Object); @@ -47,7 +47,7 @@ public async Task ProductSpecAttrList_ScopeDeniesAccess_ReturnsErrorJson() var result = await _controller.ProductSpecAttrList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.PrepareProductSpecificationAttributeModel(It.IsAny()), Times.Never); + _productSpecificationViewModelServiceMock.Verify(s => s.PrepareProductSpecificationAttributeModel(It.IsAny()), Times.Never); } [TestMethod] @@ -56,7 +56,7 @@ public async Task ProductSpecAttrList_ScopeGrantsAccess_ReturnsGrid() var product = new Product { Id = "p1" }; _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - _productViewModelServiceMock.Setup(s => s.PrepareProductSpecificationAttributeModel(product)) + _productSpecificationViewModelServiceMock.Setup(s => s.PrepareProductSpecificationAttributeModel(product)) .ReturnsAsync(new List { new() { Id = "psa1" } }); var result = await _controller.ProductSpecAttrList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); @@ -79,7 +79,7 @@ public async Task ProductSpecAttrPopupGet_ScopeDeniesAccess_ReturnsContent() var result = await _controller.ProductSpecAttrPopup(new Mock().Object, "p1", "psa1"); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( + _productSpecificationViewModelServiceMock.Verify( s => s.PrepareProductSpecificationAttributeModel(It.IsAny()), Times.Never); } @@ -134,7 +134,7 @@ public async Task ProductSpecAttrPopupPost_ProductNotFound_ReturnsContent() var result = await _controller.ProductSpecAttrPopup(new Mock().Object, model); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( + _productSpecificationViewModelServiceMock.Verify( s => s.UpdateProductSpecificationAttributeModel(It.IsAny(), It.IsAny()), Times.Never); } @@ -153,10 +153,10 @@ public async Task ProductSpecAttrPopupPost_ScopeDeniesAccess_ReturnsContent_Does var result = await _controller.ProductSpecAttrPopup(new Mock().Object, model); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( + _productSpecificationViewModelServiceMock.Verify( s => s.InsertProductSpecificationAttributeModel(It.IsAny(), It.IsAny()), Times.Never); - _productViewModelServiceMock.Verify( + _productSpecificationViewModelServiceMock.Verify( s => s.UpdateProductSpecificationAttributeModel(It.IsAny(), It.IsAny()), Times.Never); } @@ -172,7 +172,7 @@ public async Task ProductSpecAttrPopupPost_ScopeGrantsAccess_NewAttribute_Insert var result = await _controller.ProductSpecAttrPopup(new Mock().Object, model); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.InsertProductSpecificationAttributeModel(model, product), Times.Once); + _productSpecificationViewModelServiceMock.Verify(s => s.InsertProductSpecificationAttributeModel(model, product), Times.Once); } [TestMethod] @@ -188,7 +188,7 @@ public async Task ProductSpecAttrPopupPost_ScopeGrantsAccess_ExistingAttribute_U var result = await _controller.ProductSpecAttrPopup(new Mock().Object, model); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.UpdateProductSpecificationAttributeModel(psa, model), Times.Once); + _productSpecificationViewModelServiceMock.Verify(s => s.UpdateProductSpecificationAttributeModel(psa, model), Times.Once); } [TestMethod] @@ -217,7 +217,7 @@ public async Task ProductSpecAttrDelete_ProductNotFound_ReturnsContent() var result = await _controller.ProductSpecAttrDelete(model); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( + _productSpecificationViewModelServiceMock.Verify( s => s.DeleteProductSpecificationAttribute(It.IsAny(), It.IsAny()), Times.Never); } @@ -236,7 +236,7 @@ public async Task ProductSpecAttrDelete_ScopeDeniesAccess_ReturnsContent_DoesNot var result = await _controller.ProductSpecAttrDelete(model); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( + _productSpecificationViewModelServiceMock.Verify( s => s.DeleteProductSpecificationAttribute(It.IsAny(), It.IsAny()), Times.Never); } @@ -254,7 +254,7 @@ public async Task ProductSpecAttrDelete_ScopeGrantsAccess_ValidModel_Deletes() var result = await _controller.ProductSpecAttrDelete(model); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.DeleteProductSpecificationAttribute(product, psa), Times.Once); + _productSpecificationViewModelServiceMock.Verify(s => s.DeleteProductSpecificationAttribute(product, psa), Times.Once); } [TestMethod] diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs index 560bbfe7d..16df665a2 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs @@ -28,6 +28,7 @@ public abstract class ProductControllerTestsBase where TController protected TController _controller; protected Mock _productServiceMock; protected Mock _productViewModelServiceMock; + protected Mock _productSpecificationViewModelServiceMock; protected Mock _translationServiceMock; protected Mock> _scopeMock; protected Mock _permissionServiceMock; @@ -52,6 +53,7 @@ public void Setup() _productServiceMock = new Mock(); _productViewModelServiceMock = new Mock(); + _productSpecificationViewModelServiceMock = new Mock(); _translationServiceMock = new Mock(); _translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns("resource"); diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductSpecificationController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductSpecificationController.cs index 696e219f9..a83a87741 100644 --- a/src/Web/Grand.Web.Admin/Controllers/ProductSpecificationController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/ProductSpecificationController.cs @@ -14,8 +14,8 @@ namespace Grand.Web.Admin.Controllers; [Area(Constants.AreaAdmin)] [AuthorizeMenu] public class ProductSpecificationController( - IProductViewModelService productViewModelService, + IProductSpecificationViewModelService productSpecificationViewModelService, IProductService productService, IRequestTranslationService translationService, IAdminDataScope scope) - : BaseProductSpecificationController(productViewModelService, productService, translationService, scope); + : BaseProductSpecificationController(productSpecificationViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductSpecificationController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductSpecificationController.cs index fb2dc160e..003d43ebf 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductSpecificationController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductSpecificationController.cs @@ -18,7 +18,7 @@ namespace Grand.Web.AdminShared.Controllers; [PermissionAuthorize(PermissionSystemName.Products)] [AutoValidateAntiforgeryToken] public abstract class BaseProductSpecificationController( - IProductViewModelService productViewModelService, + IProductSpecificationViewModelService productSpecificationViewModelService, IProductService productService, IRequestTranslationService translationService, IAdminDataScope scope) @@ -59,7 +59,7 @@ public async Task ProductSpecAttrList(DataSourceRequest command, if (!await scope.CanView(product)) return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - var productrSpecsModel = await productViewModelService.PrepareProductSpecificationAttributeModel(product); + var productrSpecsModel = await productSpecificationViewModelService.PrepareProductSpecificationAttributeModel(product); var gridModel = new DataSourceResult { Data = productrSpecsModel, Total = productrSpecsModel.Count @@ -113,9 +113,9 @@ public async Task ProductSpecAttrPopup( var psa = product.ProductSpecificationAttributes.FirstOrDefault(x => x.Id == model.Id); if (psa == null) - await productViewModelService.InsertProductSpecificationAttributeModel(model, product); + await productSpecificationViewModelService.InsertProductSpecificationAttributeModel(model, product); else - await productViewModelService.UpdateProductSpecificationAttributeModel(psa, model); + await productSpecificationViewModelService.UpdateProductSpecificationAttributeModel(psa, model); return new JsonResult(""); } @@ -162,7 +162,7 @@ public async Task ProductSpecAttrDelete(ProductSpecificationAttri if (psa == null) throw new ArgumentException("No specification attribute found with the specified id"); - await productViewModelService.DeleteProductSpecificationAttribute(product, psa); + await productSpecificationViewModelService.DeleteProductSpecificationAttribute(product, psa); return new JsonResult(""); } diff --git a/src/Web/Grand.Web.AdminShared/Interfaces/IProductSpecificationViewModelService.cs b/src/Web/Grand.Web.AdminShared/Interfaces/IProductSpecificationViewModelService.cs new file mode 100644 index 000000000..77a1f4986 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Interfaces/IProductSpecificationViewModelService.cs @@ -0,0 +1,18 @@ +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Models.Catalog; + +namespace Grand.Web.AdminShared.Interfaces; + +public interface IProductSpecificationViewModelService +{ + //Product specification + Task> PrepareProductSpecificationAttributeModel(Product product); + + Task InsertProductSpecificationAttributeModel(ProductModel.AddProductSpecificationAttributeModel model, + Product product); + + Task UpdateProductSpecificationAttributeModel(ProductSpecificationAttribute psa, + ProductModel.AddProductSpecificationAttributeModel model); + + Task DeleteProductSpecificationAttribute(Product product, ProductSpecificationAttribute psa); +} diff --git a/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs b/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs index 06b9e8a59..4462f16ac 100644 --- a/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs @@ -157,14 +157,4 @@ Task DeleteProductAttributeCombinationTierPrices(Product product, Task UpdateProductPicture(ProductModel.ProductPictureModel model); Task DeleteProductPicture(ProductModel.ProductPictureModel model); - //Product specification - Task> PrepareProductSpecificationAttributeModel(Product product); - - Task InsertProductSpecificationAttributeModel(ProductModel.AddProductSpecificationAttributeModel model, - Product product); - - Task UpdateProductSpecificationAttributeModel(ProductSpecificationAttribute psa, - ProductModel.AddProductSpecificationAttributeModel model); - - Task DeleteProductSpecificationAttribute(Product product, ProductSpecificationAttribute psa); } \ No newline at end of file diff --git a/src/Web/Grand.Web.AdminShared/Services/ProductSpecificationViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/ProductSpecificationViewModelService.cs new file mode 100644 index 000000000..ee3e66892 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Services/ProductSpecificationViewModelService.cs @@ -0,0 +1,98 @@ +using Grand.Business.Core.Extensions; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Catalog.Tax; +using Grand.Domain.Catalog; +using Grand.SharedKernel.Extensions; +using Grand.Web.AdminShared.Extensions.Mapping; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.Common.Localization; +using System.Net; + +namespace Grand.Web.AdminShared.Services; + +public class ProductSpecificationViewModelService( + ISpecificationAttributeService specificationAttributeService, + IEnumTranslationService enumTranslationService) + : IProductSpecificationViewModelService +{ + //Product specification + public virtual async Task> PrepareProductSpecificationAttributeModel( + Product product) + { + var items = new List(); + foreach (var x in product.ProductSpecificationAttributes.OrderBy(x => x.DisplayOrder)) + { + var psaModel = new ProductSpecificationAttributeModel { + Id = x.Id, + AttributeTypeId = (int)x.AttributeTypeId, + AttributeId = x.SpecificationAttributeId, + ProductId = product.Id, + AttributeTypeName = enumTranslationService.GetTranslationEnum(x.AttributeTypeId), + AllowFiltering = x.AllowFiltering, + ShowOnProductPage = x.ShowOnProductPage, + DisplayOrder = x.DisplayOrder, + AttributeName = x.CustomName + }; + + switch (x.AttributeTypeId) + { + case SpecificationAttributeType.Option: + var specificationAttribute = + await specificationAttributeService.GetSpecificationAttributeById(x.SpecificationAttributeId); + if (specificationAttribute != null) + { + psaModel.AttributeName = specificationAttribute.Name; + psaModel.ValueRaw = WebUtility.HtmlEncode(specificationAttribute.SpecificationAttributeOptions + .FirstOrDefault(y => y.Id == x.SpecificationAttributeOptionId)?.Name); + } + + psaModel.SpecificationAttributeOptionId = x.SpecificationAttributeOptionId; + break; + case SpecificationAttributeType.CustomText: + psaModel.ValueRaw = WebUtility.HtmlEncode(x.CustomValue); + break; + case SpecificationAttributeType.CustomHtmlText: + //do not encode? + psaModel.ValueRaw = WebUtility.HtmlEncode(x.CustomValue); + break; + case SpecificationAttributeType.Hyperlink: + psaModel.ValueRaw = x.CustomValue; + break; + } + + items.Add(psaModel); + } + + return items; + } + + public virtual async Task InsertProductSpecificationAttributeModel( + ProductModel.AddProductSpecificationAttributeModel model, Product product) + { + //we allow filtering only for "Option" attribute type + if (model.AttributeTypeId != (int)SpecificationAttributeType.Option) + { + model.AllowFiltering = false; + model.SpecificationAttributeOptionId = null; + } + + var psa = model.ToEntity(); + + await specificationAttributeService.InsertProductSpecificationAttribute(psa, product.Id); + product.ProductSpecificationAttributes.Add(psa); + } + + public virtual async Task UpdateProductSpecificationAttributeModel(ProductSpecificationAttribute psa, + ProductModel.AddProductSpecificationAttributeModel model) + { + psa = model.ToEntity(psa); + await specificationAttributeService.UpdateProductSpecificationAttribute(psa, model.ProductId); + } + + public virtual async Task DeleteProductSpecificationAttribute(Product product, ProductSpecificationAttribute psa) + { + product.ProductSpecificationAttributes.Remove(psa); + await specificationAttributeService.DeleteProductSpecificationAttribute(psa, product.Id); + } +} diff --git a/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs index 6221d4a0b..70e0b35a1 100644 --- a/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs @@ -2506,86 +2506,6 @@ public virtual async Task DeleteProductPicture(ProductModel.ProductPictureModel await pictureService.DeletePicture(picture); } - //Product specification - public virtual async Task> PrepareProductSpecificationAttributeModel( - Product product) - { - var items = new List(); - foreach (var x in product.ProductSpecificationAttributes.OrderBy(x => x.DisplayOrder)) - { - var psaModel = new ProductSpecificationAttributeModel { - Id = x.Id, - AttributeTypeId = (int)x.AttributeTypeId, - AttributeId = x.SpecificationAttributeId, - ProductId = product.Id, - AttributeTypeName = enumTranslationService.GetTranslationEnum(x.AttributeTypeId), - AllowFiltering = x.AllowFiltering, - ShowOnProductPage = x.ShowOnProductPage, - DisplayOrder = x.DisplayOrder, - AttributeName = x.CustomName - }; - - switch (x.AttributeTypeId) - { - case SpecificationAttributeType.Option: - var specificationAttribute = - await specificationAttributeService.GetSpecificationAttributeById(x.SpecificationAttributeId); - if (specificationAttribute != null) - { - psaModel.AttributeName = specificationAttribute.Name; - psaModel.ValueRaw = WebUtility.HtmlEncode(specificationAttribute.SpecificationAttributeOptions - .FirstOrDefault(y => y.Id == x.SpecificationAttributeOptionId)?.Name); - } - - psaModel.SpecificationAttributeOptionId = x.SpecificationAttributeOptionId; - break; - case SpecificationAttributeType.CustomText: - psaModel.ValueRaw = WebUtility.HtmlEncode(x.CustomValue); - break; - case SpecificationAttributeType.CustomHtmlText: - //do not encode? - psaModel.ValueRaw = WebUtility.HtmlEncode(x.CustomValue); - break; - case SpecificationAttributeType.Hyperlink: - psaModel.ValueRaw = x.CustomValue; - break; - } - - items.Add(psaModel); - } - - return items; - } - - public virtual async Task InsertProductSpecificationAttributeModel( - ProductModel.AddProductSpecificationAttributeModel model, Product product) - { - //we allow filtering only for "Option" attribute type - if (model.AttributeTypeId != (int)SpecificationAttributeType.Option) - { - model.AllowFiltering = false; - model.SpecificationAttributeOptionId = null; - } - - var psa = model.ToEntity(); - - await specificationAttributeService.InsertProductSpecificationAttribute(psa, product.Id); - product.ProductSpecificationAttributes.Add(psa); - } - - public virtual async Task UpdateProductSpecificationAttributeModel(ProductSpecificationAttribute psa, - ProductModel.AddProductSpecificationAttributeModel model) - { - psa = model.ToEntity(psa); - await specificationAttributeService.UpdateProductSpecificationAttribute(psa, model.ProductId); - } - - public virtual async Task DeleteProductSpecificationAttribute(Product product, ProductSpecificationAttribute psa) - { - product.ProductSpecificationAttributes.Remove(psa); - await specificationAttributeService.DeleteProductSpecificationAttribute(psa, product.Id); - } - protected virtual async Task UpdatePictureSeoNames(Product product) { var picturesename = pictureService.GetPictureSeName(product.Name); diff --git a/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs index 30b882e20..741157129 100644 --- a/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs +++ b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs @@ -67,6 +67,7 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Web/Grand.Web.Store/Controllers/ProductSpecificationController.cs b/src/Web/Grand.Web.Store/Controllers/ProductSpecificationController.cs index 37b1f1f72..191b48357 100644 --- a/src/Web/Grand.Web.Store/Controllers/ProductSpecificationController.cs +++ b/src/Web/Grand.Web.Store/Controllers/ProductSpecificationController.cs @@ -14,8 +14,8 @@ namespace Grand.Web.Store.Controllers; [AuthorizeStore] [AuthorizeMenu] public class ProductSpecificationController( - IProductViewModelService productViewModelService, + IProductSpecificationViewModelService productSpecificationViewModelService, IProductService productService, IRequestTranslationService translationService, IAdminDataScope scope) - : BaseProductSpecificationController(productViewModelService, productService, translationService, scope); + : BaseProductSpecificationController(productSpecificationViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductSpecificationController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductSpecificationController.cs index 58aad6a4e..bad45468a 100644 --- a/src/Web/Grand.Web.Vendor/Controllers/ProductSpecificationController.cs +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductSpecificationController.cs @@ -14,8 +14,8 @@ namespace Grand.Web.Vendor.Controllers; [AuthorizeVendor] [AuthorizeMenu] public class ProductSpecificationController( - IProductViewModelService productViewModelService, + IProductSpecificationViewModelService productSpecificationViewModelService, IProductService productService, IRequestTranslationService translationService, IAdminDataScope scope) - : BaseProductSpecificationController(productViewModelService, productService, translationService, scope); + : BaseProductSpecificationController(productSpecificationViewModelService, productService, translationService, scope); From 0d44a383a22cf35e16e9afce84244f20e0af7afe Mon Sep 17 00:00:00 2001 From: support Date: Thu, 22 Oct 2026 07:28:46 +0200 Subject: [PATCH 15/27] Move the product pictures view-model logic into its own service --- .../BaseProductPictureControllerTests.cs | 22 ++-- .../Controllers/ProductControllerTestsBase.cs | 2 + .../Controllers/ProductPictureController.cs | 4 +- .../BaseProductPictureController.cs | 12 +- .../IProductPictureViewModelService.cs | 20 +++ .../Interfaces/IProductViewModelService.cs | 10 -- .../ProductPictureViewModelService.cs | 117 ++++++++++++++++++ .../Services/ProductViewModelService.cs | 101 --------------- .../Startup/StartupApplication.cs | 1 + .../Controllers/ProductPictureController.cs | 4 +- .../Controllers/ProductPictureController.cs | 4 +- 11 files changed, 163 insertions(+), 134 deletions(-) create mode 100644 src/Web/Grand.Web.AdminShared/Interfaces/IProductPictureViewModelService.cs create mode 100644 src/Web/Grand.Web.AdminShared/Services/ProductPictureViewModelService.cs diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductPictureControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductPictureControllerTests.cs index 74a940ccb..52ba8b947 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductPictureControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductPictureControllerTests.cs @@ -25,16 +25,16 @@ public class BaseProductPictureControllerTests : ProductControllerTestsBase scope) - : BaseProductPictureController(productViewModelService, productService, languageService, translationService, permissionService, scope); + : BaseProductPictureController(productPictureViewModelService, productService, languageService, translationService, permissionService, scope); protected override TestProductPictureController CreateController() => new( - _productViewModelServiceMock.Object, + _productPictureViewModelServiceMock.Object, _productServiceMock.Object, _languageServiceMock.Object, _translationServiceMock.Object, @@ -54,7 +54,7 @@ public async Task ProductPictureList_ScopeDeniesAccess_ReturnsErrorJson() Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.PrepareProductPicturesModel(It.IsAny()), Times.Never); + _productPictureViewModelServiceMock.Verify(s => s.PrepareProductPicturesModel(It.IsAny()), Times.Never); } [TestMethod] @@ -63,7 +63,7 @@ public async Task ProductPictureList_ScopeGrantsAccess_ReturnsGrid() var product = new Product { Id = "p1" }; _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - _productViewModelServiceMock.Setup(s => s.PrepareProductPicturesModel(product)) + _productPictureViewModelServiceMock.Setup(s => s.PrepareProductPicturesModel(product)) .ReturnsAsync(new List { new() { Id = "pic1" } }); var result = await _controller.ProductPictureList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); @@ -126,7 +126,7 @@ public async Task ProductPicturePopupGet_ScopeGrantsAccess_PictureFound_ReturnsV _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); var model = new ProductModel.ProductPictureModel { Id = "pic1" }; - _productViewModelServiceMock.Setup(s => s.PrepareProductPictureModel(product, pp)) + _productPictureViewModelServiceMock.Setup(s => s.PrepareProductPictureModel(product, pp)) .ReturnsAsync((model, (Picture)null)); var result = await _controller.ProductPicturePopup("p1", "pic1"); @@ -159,7 +159,7 @@ public async Task ProductPicturePopupPost_ScopeDeniesAccess_Throws_DoesNotUpdate // letting any vendor rename/re-alt-text another vendor's product picture by posting its // productId/model.Id. await Assert.ThrowsExactlyAsync(() => _controller.ProductPicturePopup(model)); - _productViewModelServiceMock.Verify(s => s.UpdateProductPicture(It.IsAny()), Times.Never); + _productPictureViewModelServiceMock.Verify(s => s.UpdateProductPicture(It.IsAny()), Times.Never); } [TestMethod] @@ -188,7 +188,7 @@ public async Task ProductPicturePopupPost_ScopeGrantsAccess_ValidModel_Updates() var content = result as ContentResult; Assert.IsNotNull(content); Assert.AreEqual("", content.Content); - _productViewModelServiceMock.Verify(s => s.UpdateProductPicture(model), Times.Once); + _productPictureViewModelServiceMock.Verify(s => s.UpdateProductPicture(model), Times.Once); } [TestMethod] @@ -221,7 +221,7 @@ public async Task ProductPictureDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNo Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.DeleteProductPicture(It.IsAny()), Times.Never); + _productPictureViewModelServiceMock.Verify(s => s.DeleteProductPicture(It.IsAny()), Times.Never); } [TestMethod] @@ -235,7 +235,7 @@ public async Task ProductPictureDelete_ScopeGrantsAccess_ValidModel_Deletes() var result = await _controller.ProductPictureDelete(model); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.DeleteProductPicture(model), Times.Once); + _productPictureViewModelServiceMock.Verify(s => s.DeleteProductPicture(model), Times.Once); } [TestMethod] @@ -250,6 +250,6 @@ public async Task ProductPictureDelete_ScopeGrantsAccess_InvalidModelState_Retur var result = await _controller.ProductPictureDelete(model); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.DeleteProductPicture(It.IsAny()), Times.Never); + _productPictureViewModelServiceMock.Verify(s => s.DeleteProductPicture(It.IsAny()), Times.Never); } } diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs index 16df665a2..0c1f0fbd6 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs @@ -28,6 +28,7 @@ public abstract class ProductControllerTestsBase where TController protected TController _controller; protected Mock _productServiceMock; protected Mock _productViewModelServiceMock; + protected Mock _productPictureViewModelServiceMock; protected Mock _productSpecificationViewModelServiceMock; protected Mock _translationServiceMock; protected Mock> _scopeMock; @@ -53,6 +54,7 @@ public void Setup() _productServiceMock = new Mock(); _productViewModelServiceMock = new Mock(); + _productPictureViewModelServiceMock = new Mock(); _productSpecificationViewModelServiceMock = new Mock(); _translationServiceMock = new Mock(); _translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns("resource"); diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductPictureController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductPictureController.cs index 392d4d2bb..aff93163a 100644 --- a/src/Web/Grand.Web.Admin/Controllers/ProductPictureController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/ProductPictureController.cs @@ -16,10 +16,10 @@ namespace Grand.Web.Admin.Controllers; [Area(Constants.AreaAdmin)] [AuthorizeMenu] public class ProductPictureController( - IProductViewModelService productViewModelService, + IProductPictureViewModelService productPictureViewModelService, IProductService productService, ILanguageService languageService, IRequestTranslationService translationService, IRequestPermissionService permissionService, IAdminDataScope scope) - : BaseProductPictureController(productViewModelService, productService, languageService, translationService, permissionService, scope); + : BaseProductPictureController(productPictureViewModelService, productService, languageService, translationService, permissionService, scope); diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductPictureController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductPictureController.cs index 6c7d9d936..2e6efd2e7 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductPictureController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductPictureController.cs @@ -24,7 +24,7 @@ namespace Grand.Web.AdminShared.Controllers; [PermissionAuthorize(PermissionSystemName.Products)] [AutoValidateAntiforgeryToken] public abstract class BaseProductPictureController( - IProductViewModelService productViewModelService, + IProductPictureViewModelService productPictureViewModelService, IProductService productService, ILanguageService languageService, IRequestTranslationService translationService, @@ -86,7 +86,7 @@ public async Task ProductPictureAdd( values.Add((pictureUrl, picture.Id)); //assign picture to the product - await productViewModelService.InsertProductPicture(product, picture, 0); + await productPictureViewModelService.InsertProductPicture(product, picture, 0); } } @@ -109,7 +109,7 @@ public async Task ProductPictureList(DataSourceRequest command, s if (!await scope.CanView(product)) return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - var productPicturesModel = await productViewModelService.PrepareProductPicturesModel(product); + var productPicturesModel = await productPictureViewModelService.PrepareProductPicturesModel(product); var gridModel = new DataSourceResult { Data = productPicturesModel, Total = productPicturesModel.Count @@ -134,7 +134,7 @@ public async Task ProductPicturePopup(string productId, string id if (pp == null) return Content("Product picture not exist"); - var (model, picture) = await productViewModelService.PrepareProductPictureModel(product, pp); + var (model, picture) = await productPictureViewModelService.PrepareProductPictureModel(product, pp); //locales await AddLocales(languageService, model.Locales, (locale, languageId) => { @@ -165,7 +165,7 @@ public async Task ProductPicturePopup(ProductModel.ProductPicture if (product.ProductPictures.FirstOrDefault(x => x.Id == model.Id) == null) throw new ArgumentException("No product picture found with the specified id"); - await productViewModelService.UpdateProductPicture(model); + await productPictureViewModelService.UpdateProductPicture(model); return Content(""); } @@ -190,7 +190,7 @@ public async Task ProductPictureDelete(ProductModel.ProductPictur if (ModelState.IsValid) { - await productViewModelService.DeleteProductPicture(model); + await productPictureViewModelService.DeleteProductPicture(model); return new JsonResult(""); } diff --git a/src/Web/Grand.Web.AdminShared/Interfaces/IProductPictureViewModelService.cs b/src/Web/Grand.Web.AdminShared/Interfaces/IProductPictureViewModelService.cs new file mode 100644 index 000000000..dc43d3aa4 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Interfaces/IProductPictureViewModelService.cs @@ -0,0 +1,20 @@ +using Grand.Domain.Catalog; +using Grand.Domain.Media; +using Grand.Web.AdminShared.Models.Catalog; + +namespace Grand.Web.AdminShared.Interfaces; + +public interface IProductPictureViewModelService +{ + //Pictures + Task> PrepareProductPicturesModel(Product product); + + Task<(ProductModel.ProductPictureModel model, Picture Picture)> PrepareProductPictureModel(Product product, + ProductPicture productPicture); + + Task InsertProductPicture(Product product, Picture picture, int displayOrder); + + Task UpdateProductPicture(ProductModel.ProductPictureModel model); + + Task DeleteProductPicture(ProductModel.ProductPictureModel model); +} diff --git a/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs b/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs index 4462f16ac..08f22b1d5 100644 --- a/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs @@ -147,14 +147,4 @@ Task UpdateProductAttributeCombinationTierPricesModel(Product product, Task DeleteProductAttributeCombinationTierPrices(Product product, ProductAttributeCombination productAttributeCombination, ProductCombinationTierPrices tierPrice); - //Pictures - Task> PrepareProductPicturesModel(Product product); - - Task<(ProductModel.ProductPictureModel model, Picture Picture)> PrepareProductPictureModel(Product product, - ProductPicture productPicture); - - Task InsertProductPicture(Product product, Picture picture, int displayOrder); - Task UpdateProductPicture(ProductModel.ProductPictureModel model); - Task DeleteProductPicture(ProductModel.ProductPictureModel model); - } \ No newline at end of file diff --git a/src/Web/Grand.Web.AdminShared/Services/ProductPictureViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/ProductPictureViewModelService.cs new file mode 100644 index 000000000..5075c2b63 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Services/ProductPictureViewModelService.cs @@ -0,0 +1,117 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Storage; +using Grand.Domain.Catalog; +using Grand.Domain.Media; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.Common.Extensions; +using System.Net; + +namespace Grand.Web.AdminShared.Services; + +public class ProductPictureViewModelService( + IProductService productService, + IPictureService pictureService) + : IProductPictureViewModelService +{ + //Pictures + public virtual async Task> PrepareProductPicturesModel(Product product) + { + var items = new List(); + foreach (var x in product.ProductPictures.OrderBy(x => x.DisplayOrder)) + { + var picture = await pictureService.GetPictureById(x.PictureId); + var m = new ProductModel.ProductPictureModel { + Id = x.Id, + ProductId = product.Id, + PictureId = x.PictureId, + PictureUrl = picture != null ? await pictureService.GetPictureUrl(picture) : null, + AltAttribute = picture?.AltAttribute, + TitleAttribute = picture?.TitleAttribute, + DisplayOrder = x.DisplayOrder, + IsDefault = x.IsDefault, + Style = picture?.Style, + ExtraField = picture?.ExtraField + }; + items.Add(m); + } + + return items; + } + + public virtual async Task<(ProductModel.ProductPictureModel model, Picture Picture)> PrepareProductPictureModel( + Product product, ProductPicture productPicture) + { + var picture = await pictureService.GetPictureById(productPicture.PictureId); + var model = new ProductModel.ProductPictureModel { + Id = productPicture.Id, + ProductId = product.Id, + PictureId = productPicture.PictureId, + PictureUrl = picture != null ? await pictureService.GetPictureUrl(picture) : null, + AltAttribute = picture?.AltAttribute, + TitleAttribute = picture?.TitleAttribute, + DisplayOrder = productPicture.DisplayOrder, + IsDefault = productPicture.IsDefault, + Style = picture?.Style, + ExtraField = picture?.ExtraField + }; + + return (model, picture); + } + + public virtual async Task InsertProductPicture(Product product, Picture picture, int displayOrder) + { + if (picture == null) + throw new ArgumentException("No picture found with the specified id"); + + if (product.ProductPictures.Any(x => x.PictureId == picture.Id)) + return; + + var productPicture = new ProductPicture { + PictureId = picture.Id, + DisplayOrder = displayOrder + }; + await productService.InsertProductPicture(productPicture, product.Id); + await pictureService.SetSeoFilename(picture, pictureService.GetPictureSeName(product.Name)); + } + + public virtual async Task UpdateProductPicture(ProductModel.ProductPictureModel model) + { + var product = await productService.GetProductById(model.ProductId, true); + + var productPicture = product.ProductPictures.FirstOrDefault(x => x.Id == model.Id); + if (productPicture == null) + throw new ArgumentException("No product picture found with the specified id"); + + var picture = await pictureService.GetPictureById(productPicture.PictureId); + if (picture == null) + throw new ArgumentException("No picture found with the specified id"); + + productPicture.DisplayOrder = model.DisplayOrder; + productPicture.IsDefault = model.IsDefault; + await productService.UpdateProductPicture(productPicture, product.Id); + + //Update picture fields + await pictureService.UpdatePictureField(picture, x => x.AltAttribute, model.AltAttribute); + await pictureService.UpdatePictureField(picture, x => x.TitleAttribute, model.TitleAttribute); + await pictureService.UpdatePictureField(picture, x => x.Locales, model.Locales.ToTranslationProperty()); + await pictureService.UpdatePictureField(picture, x => x.Style, model.Style); + await pictureService.UpdatePictureField(picture, x => x.ExtraField, model.ExtraField); + } + + public virtual async Task DeleteProductPicture(ProductModel.ProductPictureModel model) + { + var product = await productService.GetProductById(model.ProductId, true); + + var productPicture = product.ProductPictures.FirstOrDefault(x => x.Id == model.Id); + if (productPicture == null) + throw new ArgumentException("No product picture found with the specified id"); + + var pictureId = productPicture.PictureId; + await productService.DeleteProductPicture(productPicture, product.Id); + + var picture = await pictureService.GetPictureById(pictureId); + if (picture != null) + await pictureService.DeletePicture(picture); + } +} diff --git a/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs index 70e0b35a1..700f92b4d 100644 --- a/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs @@ -2405,107 +2405,6 @@ public virtual async Task DeleteProductAttributeCombinationTierPrices(Product pr await productAttributeService.UpdateProductAttributeCombination(productAttributeCombination, product.Id); } - //Pictures - public virtual async Task> PrepareProductPicturesModel(Product product) - { - var items = new List(); - foreach (var x in product.ProductPictures.OrderBy(x => x.DisplayOrder)) - { - var picture = await pictureService.GetPictureById(x.PictureId); - var m = new ProductModel.ProductPictureModel { - Id = x.Id, - ProductId = product.Id, - PictureId = x.PictureId, - PictureUrl = picture != null ? await pictureService.GetPictureUrl(picture) : null, - AltAttribute = picture?.AltAttribute, - TitleAttribute = picture?.TitleAttribute, - DisplayOrder = x.DisplayOrder, - IsDefault = x.IsDefault, - Style = picture?.Style, - ExtraField = picture?.ExtraField - }; - items.Add(m); - } - - return items; - } - - public virtual async Task<(ProductModel.ProductPictureModel model, Picture Picture)> PrepareProductPictureModel( - Product product, ProductPicture productPicture) - { - var picture = await pictureService.GetPictureById(productPicture.PictureId); - var model = new ProductModel.ProductPictureModel { - Id = productPicture.Id, - ProductId = product.Id, - PictureId = productPicture.PictureId, - PictureUrl = picture != null ? await pictureService.GetPictureUrl(picture) : null, - AltAttribute = picture?.AltAttribute, - TitleAttribute = picture?.TitleAttribute, - DisplayOrder = productPicture.DisplayOrder, - IsDefault = productPicture.IsDefault, - Style = picture?.Style, - ExtraField = picture?.ExtraField - }; - - return (model, picture); - } - - public virtual async Task InsertProductPicture(Product product, Picture picture, int displayOrder) - { - if (picture == null) - throw new ArgumentException("No picture found with the specified id"); - - if (product.ProductPictures.Any(x => x.PictureId == picture.Id)) - return; - - var productPicture = new ProductPicture { - PictureId = picture.Id, - DisplayOrder = displayOrder - }; - await productService.InsertProductPicture(productPicture, product.Id); - await pictureService.SetSeoFilename(picture, pictureService.GetPictureSeName(product.Name)); - } - - public virtual async Task UpdateProductPicture(ProductModel.ProductPictureModel model) - { - var product = await productService.GetProductById(model.ProductId, true); - - var productPicture = product.ProductPictures.FirstOrDefault(x => x.Id == model.Id); - if (productPicture == null) - throw new ArgumentException("No product picture found with the specified id"); - - var picture = await pictureService.GetPictureById(productPicture.PictureId); - if (picture == null) - throw new ArgumentException("No picture found with the specified id"); - - productPicture.DisplayOrder = model.DisplayOrder; - productPicture.IsDefault = model.IsDefault; - await productService.UpdateProductPicture(productPicture, product.Id); - - //Update picture fields - await pictureService.UpdatePictureField(picture, x => x.AltAttribute, model.AltAttribute); - await pictureService.UpdatePictureField(picture, x => x.TitleAttribute, model.TitleAttribute); - await pictureService.UpdatePictureField(picture, x => x.Locales, model.Locales.ToTranslationProperty()); - await pictureService.UpdatePictureField(picture, x => x.Style, model.Style); - await pictureService.UpdatePictureField(picture, x => x.ExtraField, model.ExtraField); - } - - public virtual async Task DeleteProductPicture(ProductModel.ProductPictureModel model) - { - var product = await productService.GetProductById(model.ProductId, true); - - var productPicture = product.ProductPictures.FirstOrDefault(x => x.Id == model.Id); - if (productPicture == null) - throw new ArgumentException("No product picture found with the specified id"); - - var pictureId = productPicture.PictureId; - await productService.DeleteProductPicture(productPicture, product.Id); - - var picture = await pictureService.GetPictureById(pictureId); - if (picture != null) - await pictureService.DeletePicture(picture); - } - protected virtual async Task UpdatePictureSeoNames(Product product) { var picturesename = pictureService.GetPictureSeName(product.Name); diff --git a/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs index 741157129..de630dc99 100644 --- a/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs +++ b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs @@ -68,6 +68,7 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Web/Grand.Web.Store/Controllers/ProductPictureController.cs b/src/Web/Grand.Web.Store/Controllers/ProductPictureController.cs index 883eef413..b4fbfdf51 100644 --- a/src/Web/Grand.Web.Store/Controllers/ProductPictureController.cs +++ b/src/Web/Grand.Web.Store/Controllers/ProductPictureController.cs @@ -16,10 +16,10 @@ namespace Grand.Web.Store.Controllers; [AuthorizeStore] [AuthorizeMenu] public class ProductPictureController( - IProductViewModelService productViewModelService, + IProductPictureViewModelService productPictureViewModelService, IProductService productService, ILanguageService languageService, IRequestTranslationService translationService, IRequestPermissionService permissionService, IAdminDataScope scope) - : BaseProductPictureController(productViewModelService, productService, languageService, translationService, permissionService, scope); + : BaseProductPictureController(productPictureViewModelService, productService, languageService, translationService, permissionService, scope); diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductPictureController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductPictureController.cs index dde7eeca7..95f369c47 100644 --- a/src/Web/Grand.Web.Vendor/Controllers/ProductPictureController.cs +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductPictureController.cs @@ -16,10 +16,10 @@ namespace Grand.Web.Vendor.Controllers; [AuthorizeVendor] [AuthorizeMenu] public class ProductPictureController( - IProductViewModelService productViewModelService, + IProductPictureViewModelService productPictureViewModelService, IProductService productService, ILanguageService languageService, IRequestTranslationService translationService, IRequestPermissionService permissionService, IAdminDataScope scope) - : BaseProductPictureController(productViewModelService, productService, languageService, translationService, permissionService, scope); + : BaseProductPictureController(productPictureViewModelService, productService, languageService, translationService, permissionService, scope); From d7f87c0beff57f6d84744c998b78a16d5fab5d5c Mon Sep 17 00:00:00 2001 From: support Date: Thu, 22 Oct 2026 07:30:52 +0200 Subject: [PATCH 16/27] Move the product categories view-model logic into its own service --- .../BaseProductCategoryControllerTests.cs | 26 +++---- .../Controllers/ProductControllerTestsBase.cs | 2 + .../Controllers/ProductCategoryController.cs | 4 +- .../BaseProductCategoryController.cs | 10 +-- .../IProductCategoryViewModelService.cs | 15 ++++ .../Interfaces/IProductViewModelService.cs | 4 - .../ProductCategoryViewModelService.cs | 75 +++++++++++++++++++ .../Services/ProductViewModelService.cs | 60 --------------- .../Startup/StartupApplication.cs | 1 + .../Controllers/ProductCategoryController.cs | 4 +- .../Controllers/ProductCategoryController.cs | 4 +- 11 files changed, 117 insertions(+), 88 deletions(-) create mode 100644 src/Web/Grand.Web.AdminShared/Interfaces/IProductCategoryViewModelService.cs create mode 100644 src/Web/Grand.Web.AdminShared/Services/ProductCategoryViewModelService.cs diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductCategoryControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductCategoryControllerTests.cs index dbb7d145b..75f6e9ce2 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductCategoryControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductCategoryControllerTests.cs @@ -23,14 +23,14 @@ public class BaseProductCategoryControllerTests : ProductControllerTestsBase scope) - : BaseProductCategoryController(productViewModelService, productService, translationService, scope); + : BaseProductCategoryController(productCategoryViewModelService, productService, translationService, scope); protected override TestProductCategoryController CreateController() => new( - _productViewModelServiceMock.Object, + _productCategoryViewModelServiceMock.Object, _productServiceMock.Object, _translationServiceMock.Object, _scopeMock.Object); @@ -51,7 +51,7 @@ public async Task ProductCategoryList_ScopeDeniesAccess_ReturnsErrorJson() Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.PrepareProductCategoryModel(It.IsAny()), Times.Never); + _productCategoryViewModelServiceMock.Verify(s => s.PrepareProductCategoryModel(It.IsAny()), Times.Never); } // Guards against a real regression found in review: the denial message must be templated via @@ -80,7 +80,7 @@ public async Task ProductCategoryList_ScopeGrantsAccess_ReturnsGrid() var product = new Product { Id = "p1" }; _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - _productViewModelServiceMock.Setup(s => s.PrepareProductCategoryModel(product)) + _productCategoryViewModelServiceMock.Setup(s => s.PrepareProductCategoryModel(product)) .ReturnsAsync(new List { new() { Id = "c1", ProductId = "p1" } }); var result = await _controller.ProductCategoryList( @@ -102,7 +102,7 @@ public async Task ProductCategoryList_ViewableButNotOwned_ReturnsGrid() _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - _productViewModelServiceMock.Setup(s => s.PrepareProductCategoryModel(product)) + _productCategoryViewModelServiceMock.Setup(s => s.PrepareProductCategoryModel(product)) .ReturnsAsync(new List { new() { Id = "c1", ProductId = "p1" } }); var result = await _controller.ProductCategoryList( @@ -125,7 +125,7 @@ public async Task ProductCategoryInsert_ViewableButNotOwned_IsDenied() Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( + _productCategoryViewModelServiceMock.Verify( s => s.InsertProductCategoryModel(It.IsAny()), Times.Never); } @@ -143,7 +143,7 @@ public async Task ProductCategoryInsert_ScopeDeniesAccess_ReturnsErrorJson_DoesN Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.InsertProductCategoryModel(It.IsAny()), Times.Never); + _productCategoryViewModelServiceMock.Verify(s => s.InsertProductCategoryModel(It.IsAny()), Times.Never); } [TestMethod] @@ -157,7 +157,7 @@ public async Task ProductCategoryInsert_ScopeGrantsAccess_ValidModel_Inserts() var result = await _controller.ProductCategoryInsert(model); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.InsertProductCategoryModel(model), Times.Once); + _productCategoryViewModelServiceMock.Verify(s => s.InsertProductCategoryModel(model), Times.Once); } // --- ProductCategoryUpdate --------------------------------------------------------------------- @@ -174,7 +174,7 @@ public async Task ProductCategoryUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesN Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.UpdateProductCategoryModel(It.IsAny()), Times.Never); + _productCategoryViewModelServiceMock.Verify(s => s.UpdateProductCategoryModel(It.IsAny()), Times.Never); } [TestMethod] @@ -188,7 +188,7 @@ public async Task ProductCategoryUpdate_ScopeGrantsAccess_ValidModel_Updates() var result = await _controller.ProductCategoryUpdate(model); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.UpdateProductCategoryModel(model), Times.Once); + _productCategoryViewModelServiceMock.Verify(s => s.UpdateProductCategoryModel(model), Times.Once); } // --- ProductCategoryDelete --------------------------------------------------------------------- @@ -205,7 +205,7 @@ public async Task ProductCategoryDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesN Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.DeleteProductCategory(It.IsAny(), It.IsAny()), Times.Never); + _productCategoryViewModelServiceMock.Verify(s => s.DeleteProductCategory(It.IsAny(), It.IsAny()), Times.Never); } [TestMethod] @@ -219,6 +219,6 @@ public async Task ProductCategoryDelete_ScopeGrantsAccess_ValidModel_Deletes() var result = await _controller.ProductCategoryDelete(model); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.DeleteProductCategory("c1", "p1"), Times.Once); + _productCategoryViewModelServiceMock.Verify(s => s.DeleteProductCategory("c1", "p1"), Times.Once); } } diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs index 0c1f0fbd6..e8b8fe35d 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs @@ -28,6 +28,7 @@ public abstract class ProductControllerTestsBase where TController protected TController _controller; protected Mock _productServiceMock; protected Mock _productViewModelServiceMock; + protected Mock _productCategoryViewModelServiceMock; protected Mock _productPictureViewModelServiceMock; protected Mock _productSpecificationViewModelServiceMock; protected Mock _translationServiceMock; @@ -54,6 +55,7 @@ public void Setup() _productServiceMock = new Mock(); _productViewModelServiceMock = new Mock(); + _productCategoryViewModelServiceMock = new Mock(); _productPictureViewModelServiceMock = new Mock(); _productSpecificationViewModelServiceMock = new Mock(); _translationServiceMock = new Mock(); diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductCategoryController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductCategoryController.cs index 07976a66a..8892ec8eb 100644 --- a/src/Web/Grand.Web.Admin/Controllers/ProductCategoryController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/ProductCategoryController.cs @@ -14,8 +14,8 @@ namespace Grand.Web.Admin.Controllers; [Area(Constants.AreaAdmin)] [AuthorizeMenu] public class ProductCategoryController( - IProductViewModelService productViewModelService, + IProductCategoryViewModelService productCategoryViewModelService, IProductService productService, IRequestTranslationService translationService, IAdminDataScope scope) - : BaseProductCategoryController(productViewModelService, productService, translationService, scope); + : BaseProductCategoryController(productCategoryViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductCategoryController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductCategoryController.cs index 1c9eba016..592cc641a 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductCategoryController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductCategoryController.cs @@ -16,7 +16,7 @@ namespace Grand.Web.AdminShared.Controllers; [PermissionAuthorize(PermissionSystemName.Products)] [AutoValidateAntiforgeryToken] public abstract class BaseProductCategoryController( - IProductViewModelService productViewModelService, + IProductCategoryViewModelService productCategoryViewModelService, IProductService productService, IRequestTranslationService translationService, IAdminDataScope scope) @@ -47,7 +47,7 @@ public async Task ProductCategoryList(DataSourceRequest command, // narrower than "Admin-only" - don't cite it as precedent for skipping templating elsewhere. return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - var productCategoriesModel = await productViewModelService.PrepareProductCategoryModel(product); + var productCategoriesModel = await productCategoryViewModelService.PrepareProductCategoryModel(product); var gridModel = new DataSourceResult { Data = productCategoriesModel, Total = productCategoriesModel.Count @@ -67,7 +67,7 @@ public async Task ProductCategoryInsert(ProductModel.ProductCateg if (ModelState.IsValid) try { - await productViewModelService.InsertProductCategoryModel(model); + await productCategoryViewModelService.InsertProductCategoryModel(model); return new JsonResult(""); } catch (Exception ex) @@ -89,7 +89,7 @@ public async Task ProductCategoryUpdate(ProductModel.ProductCateg if (ModelState.IsValid) try { - await productViewModelService.UpdateProductCategoryModel(model); + await productCategoryViewModelService.UpdateProductCategoryModel(model); return new JsonResult(""); } catch (Exception ex) @@ -110,7 +110,7 @@ public async Task ProductCategoryDelete(ProductModel.ProductCateg if (ModelState.IsValid) { - await productViewModelService.DeleteProductCategory(model.Id, model.ProductId); + await productCategoryViewModelService.DeleteProductCategory(model.Id, model.ProductId); return new JsonResult(""); } diff --git a/src/Web/Grand.Web.AdminShared/Interfaces/IProductCategoryViewModelService.cs b/src/Web/Grand.Web.AdminShared/Interfaces/IProductCategoryViewModelService.cs new file mode 100644 index 000000000..a6c678849 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Interfaces/IProductCategoryViewModelService.cs @@ -0,0 +1,15 @@ +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Models.Catalog; + +namespace Grand.Web.AdminShared.Interfaces; + +public interface IProductCategoryViewModelService +{ + Task> PrepareProductCategoryModel(Product product); + + Task InsertProductCategoryModel(ProductModel.ProductCategoryModel model); + + Task UpdateProductCategoryModel(ProductModel.ProductCategoryModel model); + + Task DeleteProductCategory(string id, string productId); +} diff --git a/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs b/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs index 08f22b1d5..6ecdfd0e6 100644 --- a/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs @@ -36,10 +36,6 @@ Task OutOfStockNotifications(Product product, ProductAttributeCombination combin Task<(IList products, int totalCount)> PrepareProductModel(ProductModel.AddProductModel model, int pageIndex, int pageSize); - Task> PrepareProductCategoryModel(Product product); - Task InsertProductCategoryModel(ProductModel.ProductCategoryModel model); - Task UpdateProductCategoryModel(ProductModel.ProductCategoryModel model); - Task DeleteProductCategory(string id, string productId); Task> PrepareProductCollectionModel(Product product); Task InsertProductCollection(ProductModel.ProductCollectionModel model); Task UpdateProductCollection(ProductModel.ProductCollectionModel model); diff --git a/src/Web/Grand.Web.AdminShared/Services/ProductCategoryViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/ProductCategoryViewModelService.cs new file mode 100644 index 000000000..922ee5c76 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Services/ProductCategoryViewModelService.cs @@ -0,0 +1,75 @@ +using Grand.Business.Core.Interfaces.Catalog.Categories; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using System.Net; + +namespace Grand.Web.AdminShared.Services; + +public class ProductCategoryViewModelService( + IProductService productService, + ICategoryService categoryService, + IProductCategoryService productCategoryService) + : IProductCategoryViewModelService +{ + public virtual async Task> PrepareProductCategoryModel(Product product) + { + var productCategories = product.ProductCategories.OrderBy(x => x.DisplayOrder); + var items = new List(); + foreach (var x in productCategories) + { + var category = await categoryService.GetCategoryById(x.CategoryId); + items.Add(new ProductModel.ProductCategoryModel { + Id = x.Id, + Category = await categoryService.GetFormattedBreadCrumb(category), + ProductId = product.Id, + CategoryId = x.CategoryId, + IsFeaturedProduct = x.IsFeaturedProduct, + DisplayOrder = x.DisplayOrder + }); + } + + return items; + } + + public virtual async Task InsertProductCategoryModel(ProductModel.ProductCategoryModel model) + { + var product = await productService.GetProductById(model.ProductId, true); + if (product != null && product.ProductCategories.All(x => x.CategoryId != model.CategoryId)) + { + var productCategory = new ProductCategory { + CategoryId = model.CategoryId, + DisplayOrder = model.DisplayOrder, + IsFeaturedProduct = model.IsFeaturedProduct + }; + await productCategoryService.InsertProductCategory(productCategory, product.Id); + } + } + + public virtual async Task UpdateProductCategoryModel(ProductModel.ProductCategoryModel model) + { + var product = await productService.GetProductById(model.ProductId, true); + var productCategory = product.ProductCategories.FirstOrDefault(x => x.Id == model.Id); + if (productCategory == null) + throw new ArgumentException("No product category mapping found with the specified id"); + + if (product.ProductCategories.Any(x => x.Id != model.Id && x.CategoryId == model.CategoryId)) + throw new ArgumentException("This category is already mapped with this product"); + + productCategory.CategoryId = model.CategoryId; + productCategory.DisplayOrder = model.DisplayOrder; + productCategory.IsFeaturedProduct = model.IsFeaturedProduct; + await productCategoryService.UpdateProductCategory(productCategory, product.Id); + } + + public virtual async Task DeleteProductCategory(string id, string productId) + { + var product = await productService.GetProductById(productId, true); + var productCategory = product.ProductCategories.FirstOrDefault(x => x.Id == id); + if (productCategory == null) + throw new ArgumentException("No product category mapping found with the specified id"); + + await productCategoryService.DeleteProductCategory(productCategory, product.Id); + } +} diff --git a/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs index 700f92b4d..69a64ac72 100644 --- a/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs @@ -902,66 +902,6 @@ public virtual async Task DeleteSelected(IEnumerable selectedIds) return (products.Select(x => x.ToModel(dateTimeService)).ToList(), products.TotalCount); } - public virtual async Task> PrepareProductCategoryModel(Product product) - { - var productCategories = product.ProductCategories.OrderBy(x => x.DisplayOrder); - var items = new List(); - foreach (var x in productCategories) - { - var category = await categoryService.GetCategoryById(x.CategoryId); - items.Add(new ProductModel.ProductCategoryModel { - Id = x.Id, - Category = await categoryService.GetFormattedBreadCrumb(category), - ProductId = product.Id, - CategoryId = x.CategoryId, - IsFeaturedProduct = x.IsFeaturedProduct, - DisplayOrder = x.DisplayOrder - }); - } - - return items; - } - - public virtual async Task InsertProductCategoryModel(ProductModel.ProductCategoryModel model) - { - var product = await productService.GetProductById(model.ProductId, true); - if (product != null && product.ProductCategories.All(x => x.CategoryId != model.CategoryId)) - { - var productCategory = new ProductCategory { - CategoryId = model.CategoryId, - DisplayOrder = model.DisplayOrder, - IsFeaturedProduct = model.IsFeaturedProduct - }; - await productCategoryService.InsertProductCategory(productCategory, product.Id); - } - } - - public virtual async Task UpdateProductCategoryModel(ProductModel.ProductCategoryModel model) - { - var product = await productService.GetProductById(model.ProductId, true); - var productCategory = product.ProductCategories.FirstOrDefault(x => x.Id == model.Id); - if (productCategory == null) - throw new ArgumentException("No product category mapping found with the specified id"); - - if (product.ProductCategories.Any(x => x.Id != model.Id && x.CategoryId == model.CategoryId)) - throw new ArgumentException("This category is already mapped with this product"); - - productCategory.CategoryId = model.CategoryId; - productCategory.DisplayOrder = model.DisplayOrder; - productCategory.IsFeaturedProduct = model.IsFeaturedProduct; - await productCategoryService.UpdateProductCategory(productCategory, product.Id); - } - - public virtual async Task DeleteProductCategory(string id, string productId) - { - var product = await productService.GetProductById(productId, true); - var productCategory = product.ProductCategories.FirstOrDefault(x => x.Id == id); - if (productCategory == null) - throw new ArgumentException("No product category mapping found with the specified id"); - - await productCategoryService.DeleteProductCategory(productCategory, product.Id); - } - public virtual async Task> PrepareProductCollectionModel(Product product) { var items = new List(); diff --git a/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs index de630dc99..37d0eafdf 100644 --- a/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs +++ b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs @@ -69,6 +69,7 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Web/Grand.Web.Store/Controllers/ProductCategoryController.cs b/src/Web/Grand.Web.Store/Controllers/ProductCategoryController.cs index 61b1b41f2..ad4d7abf5 100644 --- a/src/Web/Grand.Web.Store/Controllers/ProductCategoryController.cs +++ b/src/Web/Grand.Web.Store/Controllers/ProductCategoryController.cs @@ -14,8 +14,8 @@ namespace Grand.Web.Store.Controllers; [AuthorizeStore] [AuthorizeMenu] public class ProductCategoryController( - IProductViewModelService productViewModelService, + IProductCategoryViewModelService productCategoryViewModelService, IProductService productService, IRequestTranslationService translationService, IAdminDataScope scope) - : BaseProductCategoryController(productViewModelService, productService, translationService, scope); + : BaseProductCategoryController(productCategoryViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductCategoryController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductCategoryController.cs index 9e3c44605..89bf06c74 100644 --- a/src/Web/Grand.Web.Vendor/Controllers/ProductCategoryController.cs +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductCategoryController.cs @@ -14,8 +14,8 @@ namespace Grand.Web.Vendor.Controllers; [AuthorizeVendor] [AuthorizeMenu] public class ProductCategoryController( - IProductViewModelService productViewModelService, + IProductCategoryViewModelService productCategoryViewModelService, IProductService productService, IRequestTranslationService translationService, IAdminDataScope scope) - : BaseProductCategoryController(productViewModelService, productService, translationService, scope); + : BaseProductCategoryController(productCategoryViewModelService, productService, translationService, scope); From 73bde06b5071886ef0683dd8197f24748e8cc965 Mon Sep 17 00:00:00 2001 From: support Date: Thu, 22 Oct 2026 07:32:50 +0200 Subject: [PATCH 17/27] Move the product collections view-model logic into its own service --- .../BaseProductCollectionControllerTests.cs | 22 +++--- .../Controllers/ProductControllerTestsBase.cs | 2 + .../ProductCollectionController.cs | 4 +- .../BaseProductCollectionController.cs | 10 +-- .../IProductCollectionViewModelService.cs | 15 ++++ .../Interfaces/IProductViewModelService.cs | 4 -- .../ProductCollectionViewModelService.cs | 72 +++++++++++++++++++ .../Services/ProductViewModelService.cs | 57 --------------- .../Startup/StartupApplication.cs | 1 + .../ProductCollectionController.cs | 4 +- .../ProductCollectionController.cs | 4 +- 11 files changed, 112 insertions(+), 83 deletions(-) create mode 100644 src/Web/Grand.Web.AdminShared/Interfaces/IProductCollectionViewModelService.cs create mode 100644 src/Web/Grand.Web.AdminShared/Services/ProductCollectionViewModelService.cs diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductCollectionControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductCollectionControllerTests.cs index e62e34398..c446a2058 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductCollectionControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductCollectionControllerTests.cs @@ -22,14 +22,14 @@ public class BaseProductCollectionControllerTests : ProductControllerTestsBase scope) - : BaseProductCollectionController(productViewModelService, productService, translationService, scope); + : BaseProductCollectionController(productCollectionViewModelService, productService, translationService, scope); protected override TestProductCollectionController CreateController() => new( - _productViewModelServiceMock.Object, + _productCollectionViewModelServiceMock.Object, _productServiceMock.Object, _translationServiceMock.Object, _scopeMock.Object); @@ -50,7 +50,7 @@ public async Task ProductCollectionList_ScopeDeniesAccess_ReturnsErrorJson() Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.PrepareProductCollectionModel(It.IsAny()), Times.Never); + _productCollectionViewModelServiceMock.Verify(s => s.PrepareProductCollectionModel(It.IsAny()), Times.Never); } // Guards against the same regression class found in "Product categories": the denial message must be @@ -78,7 +78,7 @@ public async Task ProductCollectionList_ScopeGrantsAccess_ReturnsGrid() var product = new Product { Id = "p1" }; _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - _productViewModelServiceMock.Setup(s => s.PrepareProductCollectionModel(product)) + _productCollectionViewModelServiceMock.Setup(s => s.PrepareProductCollectionModel(product)) .ReturnsAsync(new List { new() { Id = "c1", ProductId = "p1" } }); var result = await _controller.ProductCollectionList( @@ -105,7 +105,7 @@ public async Task ProductCollectionInsert_ScopeDeniesAccess_ReturnsErrorJson_Doe Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.InsertProductCollection(It.IsAny()), Times.Never); + _productCollectionViewModelServiceMock.Verify(s => s.InsertProductCollection(It.IsAny()), Times.Never); } [TestMethod] @@ -119,7 +119,7 @@ public async Task ProductCollectionInsert_ScopeGrantsAccess_ValidModel_Inserts() var result = await _controller.ProductCollectionInsert(model); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.InsertProductCollection(model), Times.Once); + _productCollectionViewModelServiceMock.Verify(s => s.InsertProductCollection(model), Times.Once); } // --- ProductCollectionUpdate ---------------------------------------------------------------- @@ -136,7 +136,7 @@ public async Task ProductCollectionUpdate_ScopeDeniesAccess_ReturnsErrorJson_Doe Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.UpdateProductCollection(It.IsAny()), Times.Never); + _productCollectionViewModelServiceMock.Verify(s => s.UpdateProductCollection(It.IsAny()), Times.Never); } [TestMethod] @@ -150,7 +150,7 @@ public async Task ProductCollectionUpdate_ScopeGrantsAccess_ValidModel_Updates() var result = await _controller.ProductCollectionUpdate(model); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.UpdateProductCollection(model), Times.Once); + _productCollectionViewModelServiceMock.Verify(s => s.UpdateProductCollection(model), Times.Once); } // --- ProductCollectionDelete ---------------------------------------------------------------- @@ -167,7 +167,7 @@ public async Task ProductCollectionDelete_ScopeDeniesAccess_ReturnsErrorJson_Doe Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.DeleteProductCollection(It.IsAny(), It.IsAny()), Times.Never); + _productCollectionViewModelServiceMock.Verify(s => s.DeleteProductCollection(It.IsAny(), It.IsAny()), Times.Never); } [TestMethod] @@ -181,6 +181,6 @@ public async Task ProductCollectionDelete_ScopeGrantsAccess_ValidModel_Deletes() var result = await _controller.ProductCollectionDelete(model); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.DeleteProductCollection("c1", "p1"), Times.Once); + _productCollectionViewModelServiceMock.Verify(s => s.DeleteProductCollection("c1", "p1"), Times.Once); } } diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs index e8b8fe35d..22563b765 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs @@ -28,6 +28,7 @@ public abstract class ProductControllerTestsBase where TController protected TController _controller; protected Mock _productServiceMock; protected Mock _productViewModelServiceMock; + protected Mock _productCollectionViewModelServiceMock; protected Mock _productCategoryViewModelServiceMock; protected Mock _productPictureViewModelServiceMock; protected Mock _productSpecificationViewModelServiceMock; @@ -55,6 +56,7 @@ public void Setup() _productServiceMock = new Mock(); _productViewModelServiceMock = new Mock(); + _productCollectionViewModelServiceMock = new Mock(); _productCategoryViewModelServiceMock = new Mock(); _productPictureViewModelServiceMock = new Mock(); _productSpecificationViewModelServiceMock = new Mock(); diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductCollectionController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductCollectionController.cs index c5c9fb3a0..11a74274f 100644 --- a/src/Web/Grand.Web.Admin/Controllers/ProductCollectionController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/ProductCollectionController.cs @@ -14,8 +14,8 @@ namespace Grand.Web.Admin.Controllers; [Area(Constants.AreaAdmin)] [AuthorizeMenu] public class ProductCollectionController( - IProductViewModelService productViewModelService, + IProductCollectionViewModelService productCollectionViewModelService, IProductService productService, IRequestTranslationService translationService, IAdminDataScope scope) - : BaseProductCollectionController(productViewModelService, productService, translationService, scope); + : BaseProductCollectionController(productCollectionViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductCollectionController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductCollectionController.cs index 25de437be..084b0fc6e 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductCollectionController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductCollectionController.cs @@ -15,7 +15,7 @@ namespace Grand.Web.AdminShared.Controllers; [PermissionAuthorize(PermissionSystemName.Products)] [AutoValidateAntiforgeryToken] public abstract class BaseProductCollectionController( - IProductViewModelService productViewModelService, + IProductCollectionViewModelService productCollectionViewModelService, IProductService productService, IRequestTranslationService translationService, IAdminDataScope scope) @@ -40,7 +40,7 @@ public async Task ProductCollectionList(DataSourceRequest command if (!await scope.CanView(product)) return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - var productCollectionsModel = await productViewModelService.PrepareProductCollectionModel(product); + var productCollectionsModel = await productCollectionViewModelService.PrepareProductCollectionModel(product); var gridModel = new DataSourceResult { Data = productCollectionsModel, Total = productCollectionsModel.Count @@ -60,7 +60,7 @@ public async Task ProductCollectionInsert(ProductModel.ProductCol if (ModelState.IsValid) try { - await productViewModelService.InsertProductCollection(model); + await productCollectionViewModelService.InsertProductCollection(model); return new JsonResult(""); } catch (Exception ex) @@ -82,7 +82,7 @@ public async Task ProductCollectionUpdate(ProductModel.ProductCol if (ModelState.IsValid) try { - await productViewModelService.UpdateProductCollection(model); + await productCollectionViewModelService.UpdateProductCollection(model); return new JsonResult(""); } catch (Exception ex) @@ -103,7 +103,7 @@ public async Task ProductCollectionDelete(ProductModel.ProductCol if (ModelState.IsValid) { - await productViewModelService.DeleteProductCollection(model.Id, model.ProductId); + await productCollectionViewModelService.DeleteProductCollection(model.Id, model.ProductId); return new JsonResult(""); } diff --git a/src/Web/Grand.Web.AdminShared/Interfaces/IProductCollectionViewModelService.cs b/src/Web/Grand.Web.AdminShared/Interfaces/IProductCollectionViewModelService.cs new file mode 100644 index 000000000..f57badf35 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Interfaces/IProductCollectionViewModelService.cs @@ -0,0 +1,15 @@ +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Models.Catalog; + +namespace Grand.Web.AdminShared.Interfaces; + +public interface IProductCollectionViewModelService +{ + Task> PrepareProductCollectionModel(Product product); + + Task InsertProductCollection(ProductModel.ProductCollectionModel model); + + Task UpdateProductCollection(ProductModel.ProductCollectionModel model); + + Task DeleteProductCollection(string id, string productId); +} diff --git a/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs b/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs index 6ecdfd0e6..e8bf78985 100644 --- a/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs @@ -36,10 +36,6 @@ Task OutOfStockNotifications(Product product, ProductAttributeCombination combin Task<(IList products, int totalCount)> PrepareProductModel(ProductModel.AddProductModel model, int pageIndex, int pageSize); - Task> PrepareProductCollectionModel(Product product); - Task InsertProductCollection(ProductModel.ProductCollectionModel model); - Task UpdateProductCollection(ProductModel.ProductCollectionModel model); - Task DeleteProductCollection(string id, string productId); Task InsertRelatedProductModel(ProductModel.AddRelatedProductModel model); Task UpdateRelatedProductModel(ProductModel.RelatedProductModel model); Task DeleteRelatedProductModel(ProductModel.RelatedProductModel model); diff --git a/src/Web/Grand.Web.AdminShared/Services/ProductCollectionViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/ProductCollectionViewModelService.cs new file mode 100644 index 000000000..acead794b --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Services/ProductCollectionViewModelService.cs @@ -0,0 +1,72 @@ +using Grand.Business.Core.Interfaces.Catalog.Collections; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using System.Net; + +namespace Grand.Web.AdminShared.Services; + +public class ProductCollectionViewModelService( + IProductService productService, + ICollectionService collectionService, + IProductCollectionService productCollectionService) + : IProductCollectionViewModelService +{ + public virtual async Task> PrepareProductCollectionModel(Product product) + { + var items = new List(); + foreach (var x in product.ProductCollections.OrderBy(x => x.DisplayOrder)) + items.Add(new ProductModel.ProductCollectionModel { + Id = x.Id, + Collection = (await collectionService.GetCollectionById(x.CollectionId)).Name, + ProductId = product.Id, + CollectionId = x.CollectionId, + IsFeaturedProduct = x.IsFeaturedProduct, + DisplayOrder = x.DisplayOrder + }); + return items; + } + + public virtual async Task InsertProductCollection(ProductModel.ProductCollectionModel model) + { + var collectionId = model.CollectionId; + var product = await productService.GetProductById(model.ProductId, true); + if (product != null && product.ProductCollections.All(x => x.CollectionId != collectionId)) + { + var productCollection = new ProductCollection { + CollectionId = collectionId, + DisplayOrder = model.DisplayOrder, + IsFeaturedProduct = model.IsFeaturedProduct + }; + await productCollectionService.InsertProductCollection(productCollection, model.ProductId); + } + } + + public virtual async Task UpdateProductCollection(ProductModel.ProductCollectionModel model) + { + var product = await productService.GetProductById(model.ProductId, true); + var productCollection = product.ProductCollections.FirstOrDefault(x => x.Id == model.Id); + if (productCollection == null) + throw new ArgumentException("No product collection mapping found with the specified id"); + + if (product.ProductCollections.Any(x => x.Id != model.Id && x.CollectionId == model.CollectionId)) + throw new ArgumentException("This collection is already mapped with this product"); + + productCollection.CollectionId = model.CollectionId; + productCollection.DisplayOrder = model.DisplayOrder; + productCollection.IsFeaturedProduct = model.IsFeaturedProduct; + + await productCollectionService.UpdateProductCollection(productCollection, product.Id); + } + + public virtual async Task DeleteProductCollection(string id, string productId) + { + var product = await productService.GetProductById(productId, true); + var productCollection = product.ProductCollections.FirstOrDefault(x => x.Id == id); + if (productCollection == null) + throw new ArgumentException("No product collection mapping found with the specified id"); + + await productCollectionService.DeleteProductCollection(productCollection, product.Id); + } +} diff --git a/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs index 69a64ac72..2f8082b2f 100644 --- a/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs @@ -902,63 +902,6 @@ public virtual async Task DeleteSelected(IEnumerable selectedIds) return (products.Select(x => x.ToModel(dateTimeService)).ToList(), products.TotalCount); } - public virtual async Task> PrepareProductCollectionModel(Product product) - { - var items = new List(); - foreach (var x in product.ProductCollections.OrderBy(x => x.DisplayOrder)) - items.Add(new ProductModel.ProductCollectionModel { - Id = x.Id, - Collection = (await collectionService.GetCollectionById(x.CollectionId)).Name, - ProductId = product.Id, - CollectionId = x.CollectionId, - IsFeaturedProduct = x.IsFeaturedProduct, - DisplayOrder = x.DisplayOrder - }); - return items; - } - - public virtual async Task InsertProductCollection(ProductModel.ProductCollectionModel model) - { - var collectionId = model.CollectionId; - var product = await productService.GetProductById(model.ProductId, true); - if (product != null && product.ProductCollections.All(x => x.CollectionId != collectionId)) - { - var productCollection = new ProductCollection { - CollectionId = collectionId, - DisplayOrder = model.DisplayOrder, - IsFeaturedProduct = model.IsFeaturedProduct - }; - await productCollectionService.InsertProductCollection(productCollection, model.ProductId); - } - } - - public virtual async Task UpdateProductCollection(ProductModel.ProductCollectionModel model) - { - var product = await productService.GetProductById(model.ProductId, true); - var productCollection = product.ProductCollections.FirstOrDefault(x => x.Id == model.Id); - if (productCollection == null) - throw new ArgumentException("No product collection mapping found with the specified id"); - - if (product.ProductCollections.Any(x => x.Id != model.Id && x.CollectionId == model.CollectionId)) - throw new ArgumentException("This collection is already mapped with this product"); - - productCollection.CollectionId = model.CollectionId; - productCollection.DisplayOrder = model.DisplayOrder; - productCollection.IsFeaturedProduct = model.IsFeaturedProduct; - - await productCollectionService.UpdateProductCollection(productCollection, product.Id); - } - - public virtual async Task DeleteProductCollection(string id, string productId) - { - var product = await productService.GetProductById(productId, true); - var productCollection = product.ProductCollections.FirstOrDefault(x => x.Id == id); - if (productCollection == null) - throw new ArgumentException("No product collection mapping found with the specified id"); - - await productCollectionService.DeleteProductCollection(productCollection, product.Id); - } - public virtual async Task InsertRelatedProductModel(ProductModel.AddRelatedProductModel model) { var productId1 = await productService.GetProductById(model.ProductId, true); diff --git a/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs index 37d0eafdf..0c9485a14 100644 --- a/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs +++ b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs @@ -70,6 +70,7 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Web/Grand.Web.Store/Controllers/ProductCollectionController.cs b/src/Web/Grand.Web.Store/Controllers/ProductCollectionController.cs index b2bdbfb34..402b4430a 100644 --- a/src/Web/Grand.Web.Store/Controllers/ProductCollectionController.cs +++ b/src/Web/Grand.Web.Store/Controllers/ProductCollectionController.cs @@ -14,8 +14,8 @@ namespace Grand.Web.Store.Controllers; [AuthorizeStore] [AuthorizeMenu] public class ProductCollectionController( - IProductViewModelService productViewModelService, + IProductCollectionViewModelService productCollectionViewModelService, IProductService productService, IRequestTranslationService translationService, IAdminDataScope scope) - : BaseProductCollectionController(productViewModelService, productService, translationService, scope); + : BaseProductCollectionController(productCollectionViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductCollectionController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductCollectionController.cs index be6571f24..0b72fa7e3 100644 --- a/src/Web/Grand.Web.Vendor/Controllers/ProductCollectionController.cs +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductCollectionController.cs @@ -14,8 +14,8 @@ namespace Grand.Web.Vendor.Controllers; [AuthorizeVendor] [AuthorizeMenu] public class ProductCollectionController( - IProductViewModelService productViewModelService, + IProductCollectionViewModelService productCollectionViewModelService, IProductService productService, IRequestTranslationService translationService, IAdminDataScope scope) - : BaseProductCollectionController(productViewModelService, productService, translationService, scope); + : BaseProductCollectionController(productCollectionViewModelService, productService, translationService, scope); From f5548ef5de84c7fc3cc8d5e9ede585b88bbf112b Mon Sep 17 00:00:00 2001 From: support Date: Thu, 22 Oct 2026 07:35:00 +0200 Subject: [PATCH 18/27] Move the product bids view-model logic into its own service --- .../BaseProductBidControllerTests.cs | 12 +++--- .../Controllers/ProductControllerTestsBase.cs | 2 + .../Controllers/ProductBidController.cs | 4 +- .../Controllers/BaseProductBidController.cs | 4 +- .../Interfaces/IProductBidViewModelService.cs | 9 +++++ .../Interfaces/IProductViewModelService.cs | 3 -- .../Services/ProductBidViewModelService.cs | 38 +++++++++++++++++++ .../Services/ProductViewModelService.cs | 18 --------- .../Startup/StartupApplication.cs | 1 + .../Controllers/ProductBidController.cs | 4 +- .../Controllers/ProductBidController.cs | 4 +- 11 files changed, 64 insertions(+), 35 deletions(-) create mode 100644 src/Web/Grand.Web.AdminShared/Interfaces/IProductBidViewModelService.cs create mode 100644 src/Web/Grand.Web.AdminShared/Services/ProductBidViewModelService.cs diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductBidControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductBidControllerTests.cs index 328524115..0981f8607 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductBidControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductBidControllerTests.cs @@ -23,15 +23,15 @@ public class BaseProductBidControllerTests : ProductControllerTestsBase scope) - : BaseProductBidController(productViewModelService, productService, translationService, auctionService, scope); + : BaseProductBidController(productBidViewModelService, productService, translationService, auctionService, scope); protected override TestProductBidController CreateController() => new( - _productViewModelServiceMock.Object, + _productBidViewModelServiceMock.Object, _productServiceMock.Object, _translationServiceMock.Object, _auctionServiceMock.Object, @@ -62,7 +62,7 @@ public async Task ListBids_ScopeDeniesAccess_ReturnsKendoGridError_DoesNotQuery( Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( + _productBidViewModelServiceMock.Verify( s => s.PrepareBidMode(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } @@ -73,7 +73,7 @@ public async Task ListBids_ScopeGrantsAccess_ReturnsBids() _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _productViewModelServiceMock.Setup(s => s.PrepareBidMode("p1", 0, 10)) + _productBidViewModelServiceMock.Setup(s => s.PrepareBidMode("p1", 0, 10)) .ReturnsAsync((Enumerable.Empty(), 0)); var result = await _controller.ListBids( @@ -102,7 +102,7 @@ public async Task ListBids_ViewableButNotOwned_ReturnsEmptyGrid_DoesNotQuery() Assert.IsNotNull(gridModel); Assert.AreEqual(0, gridModel.Total); _translationServiceMock.Verify(t => t.GetResource(It.IsAny()), Times.Never); - _productViewModelServiceMock.Verify( + _productBidViewModelServiceMock.Verify( s => s.PrepareBidMode(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs index 22563b765..d84610d9d 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs @@ -28,6 +28,7 @@ public abstract class ProductControllerTestsBase where TController protected TController _controller; protected Mock _productServiceMock; protected Mock _productViewModelServiceMock; + protected Mock _productBidViewModelServiceMock; protected Mock _productCollectionViewModelServiceMock; protected Mock _productCategoryViewModelServiceMock; protected Mock _productPictureViewModelServiceMock; @@ -56,6 +57,7 @@ public void Setup() _productServiceMock = new Mock(); _productViewModelServiceMock = new Mock(); + _productBidViewModelServiceMock = new Mock(); _productCollectionViewModelServiceMock = new Mock(); _productCategoryViewModelServiceMock = new Mock(); _productPictureViewModelServiceMock = new Mock(); diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductBidController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductBidController.cs index c5f18311a..1b4e1d586 100644 --- a/src/Web/Grand.Web.Admin/Controllers/ProductBidController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/ProductBidController.cs @@ -14,9 +14,9 @@ namespace Grand.Web.Admin.Controllers; [Area(Constants.AreaAdmin)] [AuthorizeMenu] public class ProductBidController( - IProductViewModelService productViewModelService, + IProductBidViewModelService productBidViewModelService, IProductService productService, IRequestTranslationService translationService, IAuctionService auctionService, IAdminDataScope scope) - : BaseProductBidController(productViewModelService, productService, translationService, auctionService, scope); + : BaseProductBidController(productBidViewModelService, productService, translationService, auctionService, scope); diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductBidController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductBidController.cs index 73a7a5b3c..e097239c1 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductBidController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductBidController.cs @@ -16,7 +16,7 @@ namespace Grand.Web.AdminShared.Controllers; [PermissionAuthorize(PermissionSystemName.Products)] [AutoValidateAntiforgeryToken] public abstract class BaseProductBidController( - IProductViewModelService productViewModelService, + IProductBidViewModelService productBidViewModelService, IProductService productService, IRequestTranslationService translationService, IAuctionService auctionService, @@ -46,7 +46,7 @@ public async Task ListBids(DataSourceRequest command, string prod return Json(new DataSourceResult { Data = Array.Empty(), Total = 0 }); var (bidModels, totalCount) = - await productViewModelService.PrepareBidMode(productId, command.Page, command.PageSize); + await productBidViewModelService.PrepareBidMode(productId, command.Page, command.PageSize); var gridModel = new DataSourceResult { Data = bidModels.ToList(), Total = totalCount diff --git a/src/Web/Grand.Web.AdminShared/Interfaces/IProductBidViewModelService.cs b/src/Web/Grand.Web.AdminShared/Interfaces/IProductBidViewModelService.cs new file mode 100644 index 000000000..9765205a9 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Interfaces/IProductBidViewModelService.cs @@ -0,0 +1,9 @@ +using Grand.Web.AdminShared.Models.Catalog; + +namespace Grand.Web.AdminShared.Interfaces; + +public interface IProductBidViewModelService +{ + Task<(IEnumerable bidModels, int totalCount)> PrepareBidMode(string productId, int pageIndex, + int pageSize); +} diff --git a/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs b/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs index e8bf78985..d63168efb 100644 --- a/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs @@ -71,9 +71,6 @@ Task OutOfStockNotifications(Product product, ProductAttributeCombination combin //tierprices Task> PrepareTierPriceModel(Product product); - Task<(IEnumerable bidModels, int totalCount)> PrepareBidMode(string productId, int pageIndex, - int pageSize); - Task PrepareProductAttributeMappingModel(Product product); Task PrepareProductAttributeMappingModel( diff --git a/src/Web/Grand.Web.AdminShared/Services/ProductBidViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/ProductBidViewModelService.cs new file mode 100644 index 000000000..ae69eafe1 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Services/ProductBidViewModelService.cs @@ -0,0 +1,38 @@ +using Grand.Business.Core.Interfaces.Catalog.Prices; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.Common.Extensions; +using System.Net; + +namespace Grand.Web.AdminShared.Services; + +public class ProductBidViewModelService( + IDateTimeService dateTimeService, + IContextAccessor contextAccessor, + ICustomerService customerService, + IAuctionService auctionService, + IPriceFormatter priceFormatter) + : IProductBidViewModelService +{ + public virtual async Task<(IEnumerable bidModels, int totalCount)> PrepareBidMode( + string productId, int pageIndex, int pageSize) + { + var bids = await auctionService.GetBidsByProductId(productId, pageIndex - 1, pageSize); + var bidsModel = new List(); + foreach (var x in bids) + bidsModel.Add(new ProductModel.BidModel { + BidId = x.Id, + ProductId = x.ProductId, + Amount = priceFormatter.FormatPrice(x.Amount, contextAccessor.WorkContext.WorkingCurrency), + Date = dateTimeService.ConvertToUserTime(x.Date, DateTimeKind.Utc), + CustomerId = x.CustomerId, + Email = (await customerService.GetCustomerById(x.CustomerId))?.Email, + OrderId = x.OrderId + }); + return (bidsModel, bids.TotalCount); + } +} diff --git a/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs index 2f8082b2f..4774bc2cc 100644 --- a/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs @@ -1353,24 +1353,6 @@ public virtual async Task DeleteBulkEdit(IEnumerable produ return items; } - public virtual async Task<(IEnumerable bidModels, int totalCount)> PrepareBidMode( - string productId, int pageIndex, int pageSize) - { - var bids = await auctionService.GetBidsByProductId(productId, pageIndex - 1, pageSize); - var bidsModel = new List(); - foreach (var x in bids) - bidsModel.Add(new ProductModel.BidModel { - BidId = x.Id, - ProductId = x.ProductId, - Amount = priceFormatter.FormatPrice(x.Amount, contextAccessor.WorkContext.WorkingCurrency), - Date = dateTimeService.ConvertToUserTime(x.Date, DateTimeKind.Utc), - CustomerId = x.CustomerId, - Email = (await customerService.GetCustomerById(x.CustomerId))?.Email, - OrderId = x.OrderId - }); - return (bidsModel, bids.TotalCount); - } - public virtual async Task PrepareProductAttributeMappingModel( Product product) { diff --git a/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs index 0c9485a14..38daf4566 100644 --- a/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs +++ b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs @@ -71,6 +71,7 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Web/Grand.Web.Store/Controllers/ProductBidController.cs b/src/Web/Grand.Web.Store/Controllers/ProductBidController.cs index 24d92f9bd..ff79450d3 100644 --- a/src/Web/Grand.Web.Store/Controllers/ProductBidController.cs +++ b/src/Web/Grand.Web.Store/Controllers/ProductBidController.cs @@ -14,9 +14,9 @@ namespace Grand.Web.Store.Controllers; [AuthorizeStore] [AuthorizeMenu] public class ProductBidController( - IProductViewModelService productViewModelService, + IProductBidViewModelService productBidViewModelService, IProductService productService, IRequestTranslationService translationService, IAuctionService auctionService, IAdminDataScope scope) - : BaseProductBidController(productViewModelService, productService, translationService, auctionService, scope); + : BaseProductBidController(productBidViewModelService, productService, translationService, auctionService, scope); diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductBidController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductBidController.cs index 4af7ea3fa..874ccfea1 100644 --- a/src/Web/Grand.Web.Vendor/Controllers/ProductBidController.cs +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductBidController.cs @@ -14,9 +14,9 @@ namespace Grand.Web.Vendor.Controllers; [AuthorizeVendor] [AuthorizeMenu] public class ProductBidController( - IProductViewModelService productViewModelService, + IProductBidViewModelService productBidViewModelService, IProductService productService, IRequestTranslationService translationService, IAuctionService auctionService, IAdminDataScope scope) - : BaseProductBidController(productViewModelService, productService, translationService, auctionService, scope); + : BaseProductBidController(productBidViewModelService, productService, translationService, auctionService, scope); From 4e0ed82ccfcd9e5ffddade020e7898eb032d7992 Mon Sep 17 00:00:00 2001 From: support Date: Thu, 22 Oct 2026 07:40:08 +0200 Subject: [PATCH 19/27] Move the product prices view-model logic into its own service --- .../BaseProductPriceControllerTests.cs | 16 +-- .../Controllers/ProductControllerTestsBase.cs | 2 + .../ProductPriceViewModelServiceTests.cs | 90 +++++++++++++++++ .../Services/ProductViewModelServiceTests.cs | 32 ------ .../Controllers/ProductPriceController.cs | 4 +- .../Controllers/BaseProductPriceController.cs | 12 +-- .../IProductPriceViewModelService.cs | 12 +++ .../Interfaces/IProductViewModelService.cs | 4 - .../Services/ProductPriceViewModelService.cs | 98 +++++++++++++++++++ .../Services/ProductViewModelService.cs | 75 -------------- .../Startup/StartupApplication.cs | 1 + .../Controllers/ProductPriceController.cs | 4 +- .../Controllers/ProductPriceController.cs | 4 +- 13 files changed, 223 insertions(+), 131 deletions(-) create mode 100644 src/Tests/Grand.Web.Admin.Tests/Services/ProductPriceViewModelServiceTests.cs create mode 100644 src/Web/Grand.Web.AdminShared/Interfaces/IProductPriceViewModelService.cs create mode 100644 src/Web/Grand.Web.AdminShared/Services/ProductPriceViewModelService.cs diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductPriceControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductPriceControllerTests.cs index 92808e9f4..e196c2117 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductPriceControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductPriceControllerTests.cs @@ -23,15 +23,15 @@ public class BaseProductPriceControllerTests : ProductControllerTestsBase scope) - : BaseProductPriceController(productViewModelService, productService, translationService, dateTimeService, scope); + : BaseProductPriceController(productPriceViewModelService, productService, translationService, dateTimeService, scope); protected override TestProductPriceController CreateController() => new( - _productViewModelServiceMock.Object, + _productPriceViewModelServiceMock.Object, _productServiceMock.Object, _translationServiceMock.Object, _dateTimeServiceMock.Object, @@ -208,7 +208,7 @@ public async Task TierPriceList_ScopeDeniesAccess_ReturnsErrorJson() Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( + _productPriceViewModelServiceMock.Verify( s => s.PrepareTierPriceModel(It.IsAny()), Times.Never); } @@ -235,7 +235,7 @@ public async Task TierPriceList_ScopeGrantsAccess_ReturnsGrid() _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); var tierPrices = new List { new() { Id = "tp1" } }; - _productViewModelServiceMock.Setup(s => s.PrepareTierPriceModel(product)).ReturnsAsync(tierPrices); + _productPriceViewModelServiceMock.Setup(s => s.PrepareTierPriceModel(product)).ReturnsAsync(tierPrices); var result = await _controller.TierPriceList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); @@ -255,7 +255,7 @@ public async Task TierPriceCreatePopup_Get_PreparesModel() var result = await _controller.TierPriceCreatePopup("p1"); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( + _productPriceViewModelServiceMock.Verify( s => s.PrepareTierPriceModel(It.Is(m => m.ProductId == "p1")), Times.Once); } @@ -316,7 +316,7 @@ public async Task TierPriceEditPopup_Get_ScopeDeniesAccess_ReturnsNotYourProduct var content = result as ContentResult; Assert.IsNotNull(content); Assert.AreEqual("This is not your product", content.Content); - _productViewModelServiceMock.Verify( + _productPriceViewModelServiceMock.Verify( s => s.PrepareTierPriceModel(It.IsAny()), Times.Never); } @@ -346,7 +346,7 @@ public async Task TierPriceEditPopup_Get_ScopeGrantsAccess_PreparesModel() var result = await _controller.TierPriceEditPopup("tp1", "p1"); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( + _productPriceViewModelServiceMock.Verify( s => s.PrepareTierPriceModel(It.Is(m => m.ProductId == "p1")), Times.Once); } diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs index d84610d9d..2e9a76451 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs @@ -28,6 +28,7 @@ public abstract class ProductControllerTestsBase where TController protected TController _controller; protected Mock _productServiceMock; protected Mock _productViewModelServiceMock; + protected Mock _productPriceViewModelServiceMock; protected Mock _productBidViewModelServiceMock; protected Mock _productCollectionViewModelServiceMock; protected Mock _productCategoryViewModelServiceMock; @@ -57,6 +58,7 @@ public void Setup() _productServiceMock = new Mock(); _productViewModelServiceMock = new Mock(); + _productPriceViewModelServiceMock = new Mock(); _productBidViewModelServiceMock = new Mock(); _productCollectionViewModelServiceMock = new Mock(); _productCategoryViewModelServiceMock = new Mock(); diff --git a/src/Tests/Grand.Web.Admin.Tests/Services/ProductPriceViewModelServiceTests.cs b/src/Tests/Grand.Web.Admin.Tests/Services/ProductPriceViewModelServiceTests.cs new file mode 100644 index 000000000..9c9dea095 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Services/ProductPriceViewModelServiceTests.cs @@ -0,0 +1,90 @@ +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Stores; +using Grand.Domain; +using Grand.Domain.Catalog; +using Grand.Domain.Customers; +using Grand.Domain.Directory; +using Grand.Domain.Stores; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Services; +using Grand.Web.Common.Localization; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Admin.Tests.Services; + +[TestClass] +public class ProductPriceViewModelServiceTests +{ + private Mock _groupServiceMock; + private ProductPriceViewModelService _productPriceViewModelService; + private Mock> _scopeMock; + private Mock _storeServiceMock; + private Mock _translationServiceMock; + + [TestInitialize] + public void Setup() + { + _groupServiceMock = new Mock(); + _storeServiceMock = new Mock(); + _translationServiceMock = new Mock(); + _translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns("resource"); + + var currencyServiceMock = new Mock(); + currencyServiceMock.Setup(c => c.GetCurrencyById(It.IsAny())).ReturnsAsync((Currency)null); + currencyServiceMock.Setup(c => c.GetAllCurrencies(It.IsAny(), It.IsAny())).ReturnsAsync(new List()); + + _storeServiceMock.Setup(s => s.GetAllStores()).ReturnsAsync(new List()); + + _groupServiceMock.Setup(g => g.GetAllCustomerGroups(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new PagedList()); + + // Default: Admin's Global scope - no default store/vendor, store dropdown shows. + _scopeMock = new Mock>(); + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Admin"); + _scopeMock.Setup(s => s.ShowStoreSelector).Returns(true); + _scopeMock.Setup(s => s.DefaultVendorId).Returns((string)null); + + _productPriceViewModelService = new ProductPriceViewModelService( + currencyServiceMock.Object, + new Mock().Object, + _translationServiceMock.Object, + _groupServiceMock.Object, + _storeServiceMock.Object, + _scopeMock.Object); + } + + [TestMethod] + public async Task PrepareTierPriceModel_VendorScope_HidesStoreDropdown() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); + _scopeMock.Setup(s => s.ShowStoreSelector).Returns(false); + _storeServiceMock.Setup(s => s.GetAllStores()).ReturnsAsync(new List { + new() { Id = "store1", Shortcut = "Store 1" } + }); + + var model = new ProductModel.TierPriceModel(); + await _productPriceViewModelService.PrepareTierPriceModel(model); + + Assert.IsFalse(model.AvailableStores.Any(), + "Vendor doesn't pick stores - no dropdown should be populated at all, matching the original Vendor service's PrepareTierPriceModel."); + } + + [TestMethod] + public async Task PrepareTierPriceModel_GlobalScope_IncludesStoreDropdown() + { + // Default Setup() scope: Admin's Global scope (DefaultStoreId null, ShowStoreSelector true). + _storeServiceMock.Setup(s => s.GetAllStores()).ReturnsAsync(new List { + new() { Id = "store1", Shortcut = "Store 1" } + }); + + var model = new ProductModel.TierPriceModel(); + await _productPriceViewModelService.PrepareTierPriceModel(model); + + Assert.IsTrue(model.AvailableStores.Any(x => x.Value == "store1"), + "Admin should offer a store dropdown for tier prices."); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs b/src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs index 994504124..f2c987928 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs @@ -272,38 +272,6 @@ public async Task PrepareProductListModel_VendorScope_HidesHomepageOptionAndStor "Vendor doesn't pick stores - no dropdown should be populated at all."); } - [TestMethod] - public async Task PrepareTierPriceModel_VendorScope_HidesStoreDropdown() - { - _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); - _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); - _scopeMock.Setup(s => s.ShowStoreSelector).Returns(false); - _storeServiceMock.Setup(s => s.GetAllStores()).ReturnsAsync(new List { - new() { Id = "store1", Shortcut = "Store 1" } - }); - - var model = new ProductModel.TierPriceModel(); - await _productViewModelService.PrepareTierPriceModel(model); - - Assert.IsFalse(model.AvailableStores.Any(), - "Vendor doesn't pick stores - no dropdown should be populated at all, matching the original Vendor service's PrepareTierPriceModel."); - } - - [TestMethod] - public async Task PrepareTierPriceModel_GlobalScope_IncludesStoreDropdown() - { - // Default Setup() scope: Admin's Global scope (DefaultStoreId null, ShowStoreSelector true). - _storeServiceMock.Setup(s => s.GetAllStores()).ReturnsAsync(new List { - new() { Id = "store1", Shortcut = "Store 1" } - }); - - var model = new ProductModel.TierPriceModel(); - await _productViewModelService.PrepareTierPriceModel(model); - - Assert.IsTrue(model.AvailableStores.Any(x => x.Value == "store1"), - "Admin should offer a store dropdown for tier prices."); - } - private void SetupSearchProducts() { _productServiceMock.Setup(p => p.SearchProducts( diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductPriceController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductPriceController.cs index 558d3949e..cda6de000 100644 --- a/src/Web/Grand.Web.Admin/Controllers/ProductPriceController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/ProductPriceController.cs @@ -15,9 +15,9 @@ namespace Grand.Web.Admin.Controllers; [Area(Constants.AreaAdmin)] [AuthorizeMenu] public class ProductPriceController( - IProductViewModelService productViewModelService, + IProductPriceViewModelService productPriceViewModelService, IProductService productService, IRequestTranslationService translationService, IDateTimeService dateTimeService, IAdminDataScope scope) - : BaseProductPriceController(productViewModelService, productService, translationService, dateTimeService, scope); + : BaseProductPriceController(productPriceViewModelService, productService, translationService, dateTimeService, scope); diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductPriceController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductPriceController.cs index a0801ea91..4cb24b716 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductPriceController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductPriceController.cs @@ -19,7 +19,7 @@ namespace Grand.Web.AdminShared.Controllers; [PermissionAuthorize(PermissionSystemName.Products)] [AutoValidateAntiforgeryToken] public abstract class BaseProductPriceController( - IProductViewModelService productViewModelService, + IProductPriceViewModelService productPriceViewModelService, IProductService productService, IRequestTranslationService translationService, IDateTimeService dateTimeService, @@ -184,7 +184,7 @@ public async Task TierPriceList(DataSourceRequest command, string // PrepareTierPriceModel(product) now reads scope internally (Task 9); scope.DefaultStoreId // is null for Admin/Vendor (Global/VendorProduct scopes) and the staff store for Store. - var tierPricesModel = await productViewModelService.PrepareTierPriceModel(product); + var tierPricesModel = await productPriceViewModelService.PrepareTierPriceModel(product); var gridModel = new DataSourceResult { Data = tierPricesModel, Total = tierPricesModel.Count @@ -198,7 +198,7 @@ public async Task TierPriceCreatePopup(string productId) var model = new ProductModel.TierPriceModel { ProductId = productId }; - await productViewModelService.PrepareTierPriceModel(model); + await productPriceViewModelService.PrepareTierPriceModel(model); return View(model); } @@ -225,7 +225,7 @@ public async Task TierPriceCreatePopup(ProductModel.TierPriceMode Error(ModelState); //If we got this far, something failed, redisplay form - await productViewModelService.PrepareTierPriceModel(model); + await productPriceViewModelService.PrepareTierPriceModel(model); return View(model); } @@ -249,7 +249,7 @@ public async Task TierPriceEditPopup(string id, string productId) var model = tierPrice.ToModel(dateTimeService); model.ProductId = productId; - await productViewModelService.PrepareTierPriceModel(model); + await productPriceViewModelService.PrepareTierPriceModel(model); return View(model); } @@ -279,7 +279,7 @@ public async Task TierPriceEditPopup(string productId, ProductMod Error(ModelState); //stores - await productViewModelService.PrepareTierPriceModel(model); + await productPriceViewModelService.PrepareTierPriceModel(model); return View(model); } diff --git a/src/Web/Grand.Web.AdminShared/Interfaces/IProductPriceViewModelService.cs b/src/Web/Grand.Web.AdminShared/Interfaces/IProductPriceViewModelService.cs new file mode 100644 index 000000000..22fa8c183 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Interfaces/IProductPriceViewModelService.cs @@ -0,0 +1,12 @@ +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Models.Catalog; + +namespace Grand.Web.AdminShared.Interfaces; + +public interface IProductPriceViewModelService +{ + Task PrepareTierPriceModel(ProductModel.TierPriceModel model); + + //tierprices + Task> PrepareTierPriceModel(Product product); +} diff --git a/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs b/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs index d63168efb..a736e610e 100644 --- a/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs @@ -19,7 +19,6 @@ Task OutOfStockNotifications(Product product, ProductAttributeCombination combin Task PrepareAddProductAttributeCombinationModel(ProductAttributeCombinationModel model, Product product); Task SaveProductWarehouseInventory(Product product, IList model); - Task PrepareTierPriceModel(ProductModel.TierPriceModel model); Task PrepareProductAttributeValueModel(Product product, ProductModel.ProductAttributeValueModel model); Task PrepareProductListModel(); @@ -68,9 +67,6 @@ Task OutOfStockNotifications(Product product, ProductAttributeCombination combin Task DeleteBulkEdit(IEnumerable products); - //tierprices - Task> PrepareTierPriceModel(Product product); - Task PrepareProductAttributeMappingModel(Product product); Task PrepareProductAttributeMappingModel( diff --git a/src/Web/Grand.Web.AdminShared/Services/ProductPriceViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/ProductPriceViewModelService.cs new file mode 100644 index 000000000..9a8834bfc --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Services/ProductPriceViewModelService.cs @@ -0,0 +1,98 @@ +using Grand.Business.Core.Interfaces.Catalog.Tax; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Stores; +using Grand.Domain.Catalog; +using Grand.SharedKernel.Extensions; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.Common.Extensions; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc.Rendering; +using System.Net; + +namespace Grand.Web.AdminShared.Services; + +public class ProductPriceViewModelService( + ICurrencyService currencyService, + IDateTimeService dateTimeService, + IRequestTranslationService translationService, + IGroupService groupService, + IStoreService storeService, + IAdminDataScope scope) + : IProductPriceViewModelService +{ + public virtual async Task PrepareTierPriceModel(ProductModel.TierPriceModel model) + { + var storeId = scope.DefaultStoreId ?? ""; + + //stores - same capability gate as PrepareProductListModel: Vendor's original PrepareTierPriceModel + //never populated a stores dropdown at all (vendors don't pick stores), so this must not run for + //Vendor even though its storeId also resolves to "". + if (scope.ShowStoreSelector) + { + if (string.IsNullOrEmpty(storeId)) + model.AvailableStores.Add(new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = " " }); + + foreach (var store in (await storeService.GetAllStores()).Where(x => + x.Id == storeId || string.IsNullOrWhiteSpace(storeId))) + model.AvailableStores.Add(new SelectListItem { Text = store.Shortcut, Value = store.Id }); + } + + //customer groups + model.AvailableCustomerGroups.Add(new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = " " }); + foreach (var role in await groupService.GetAllCustomerGroups(showHidden: true)) + model.AvailableCustomerGroups.Add(new SelectListItem { Text = role.Name, Value = role.Id }); + + foreach (var currency in await currencyService.GetAllCurrencies()) + model.AvailableCurrencies.Add(new SelectListItem { Text = currency.Name, Value = currency.CurrencyCode }); + } + + public virtual async Task> PrepareTierPriceModel(Product product) + { + var storeId = scope.DefaultStoreId ?? ""; + var items = new List(); + foreach (var x in product.TierPrices + .Where(x => x.StoreId == storeId || string.IsNullOrWhiteSpace(storeId) || + string.IsNullOrWhiteSpace(x.StoreId)).OrderBy(x => x.StoreId).ThenBy(x => x.Quantity) + .ThenBy(x => x.CustomerGroupId)) + { + string storeName; + if (!string.IsNullOrEmpty(x.StoreId)) + { + var store = await storeService.GetStoreById(x.StoreId); + storeName = store != null ? store.Shortcut : "Deleted"; + } + else + { + storeName = translationService.GetResource("Admin.Catalog.Products.TierPrices.Fields.Store.All"); + } + + var groupName = string.Empty; + if (string.IsNullOrEmpty(x.CustomerGroupId)) + groupName = translationService.GetResource( + "Admin.Catalog.Products.TierPrices.Fields.CustomerGroup.All"); + else + groupName = (await groupService.GetCustomerGroupById(x.CustomerGroupId))?.Name ?? "NOT FOUND"; + + items.Add(new ProductModel.TierPriceModel { + Id = x.Id, + StoreId = x.StoreId, + Store = storeName, + CurrencyCode = x.CurrencyCode, + CustomerGroup = groupName, + ProductId = product.Id, + CustomerGroupId = !string.IsNullOrEmpty(x.CustomerGroupId) ? x.CustomerGroupId : "", + Quantity = x.Quantity, + Price = x.Price, + StartDateTime = x.StartDateTimeUtc.HasValue + ? dateTimeService.ConvertToUserTime(x.StartDateTimeUtc.Value, DateTimeKind.Utc) + : new DateTime?(), + EndDateTime = x.EndDateTimeUtc.HasValue + ? dateTimeService.ConvertToUserTime(x.EndDateTimeUtc.Value, DateTimeKind.Utc) + : new DateTime?() + }); + } + + return items; + } +} diff --git a/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs index 4774bc2cc..61f597867 100644 --- a/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs @@ -143,32 +143,6 @@ public virtual async Task PrepareAddProductAttributeCombinationModel(ProductAttr (await currencyService.GetCurrencyById(currencySettings.PrimaryStoreCurrencyId))?.CurrencyCode; } - public virtual async Task PrepareTierPriceModel(ProductModel.TierPriceModel model) - { - var storeId = scope.DefaultStoreId ?? ""; - - //stores - same capability gate as PrepareProductListModel: Vendor's original PrepareTierPriceModel - //never populated a stores dropdown at all (vendors don't pick stores), so this must not run for - //Vendor even though its storeId also resolves to "". - if (scope.ShowStoreSelector) - { - if (string.IsNullOrEmpty(storeId)) - model.AvailableStores.Add(new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = " " }); - - foreach (var store in (await storeService.GetAllStores()).Where(x => - x.Id == storeId || string.IsNullOrWhiteSpace(storeId))) - model.AvailableStores.Add(new SelectListItem { Text = store.Shortcut, Value = store.Id }); - } - - //customer groups - model.AvailableCustomerGroups.Add(new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = " " }); - foreach (var role in await groupService.GetAllCustomerGroups(showHidden: true)) - model.AvailableCustomerGroups.Add(new SelectListItem { Text = role.Name, Value = role.Id }); - - foreach (var currency in await currencyService.GetAllCurrencies()) - model.AvailableCurrencies.Add(new SelectListItem { Text = currency.Name, Value = currency.CurrencyCode }); - } - public virtual async Task PrepareProductAttributeValueModel(Product product, ProductModel.ProductAttributeValueModel model) { @@ -1304,55 +1278,6 @@ public virtual async Task DeleteBulkEdit(IEnumerable produ } } - public virtual async Task> PrepareTierPriceModel(Product product) - { - var storeId = scope.DefaultStoreId ?? ""; - var items = new List(); - foreach (var x in product.TierPrices - .Where(x => x.StoreId == storeId || string.IsNullOrWhiteSpace(storeId) || - string.IsNullOrWhiteSpace(x.StoreId)).OrderBy(x => x.StoreId).ThenBy(x => x.Quantity) - .ThenBy(x => x.CustomerGroupId)) - { - string storeName; - if (!string.IsNullOrEmpty(x.StoreId)) - { - var store = await storeService.GetStoreById(x.StoreId); - storeName = store != null ? store.Shortcut : "Deleted"; - } - else - { - storeName = translationService.GetResource("Admin.Catalog.Products.TierPrices.Fields.Store.All"); - } - - var groupName = string.Empty; - if (string.IsNullOrEmpty(x.CustomerGroupId)) - groupName = translationService.GetResource( - "Admin.Catalog.Products.TierPrices.Fields.CustomerGroup.All"); - else - groupName = (await groupService.GetCustomerGroupById(x.CustomerGroupId))?.Name ?? "NOT FOUND"; - - items.Add(new ProductModel.TierPriceModel { - Id = x.Id, - StoreId = x.StoreId, - Store = storeName, - CurrencyCode = x.CurrencyCode, - CustomerGroup = groupName, - ProductId = product.Id, - CustomerGroupId = !string.IsNullOrEmpty(x.CustomerGroupId) ? x.CustomerGroupId : "", - Quantity = x.Quantity, - Price = x.Price, - StartDateTime = x.StartDateTimeUtc.HasValue - ? dateTimeService.ConvertToUserTime(x.StartDateTimeUtc.Value, DateTimeKind.Utc) - : new DateTime?(), - EndDateTime = x.EndDateTimeUtc.HasValue - ? dateTimeService.ConvertToUserTime(x.EndDateTimeUtc.Value, DateTimeKind.Utc) - : new DateTime?() - }); - } - - return items; - } - public virtual async Task PrepareProductAttributeMappingModel( Product product) { diff --git a/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs index 38daf4566..c42782540 100644 --- a/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs +++ b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs @@ -72,6 +72,7 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Web/Grand.Web.Store/Controllers/ProductPriceController.cs b/src/Web/Grand.Web.Store/Controllers/ProductPriceController.cs index 175241a06..3f7077d63 100644 --- a/src/Web/Grand.Web.Store/Controllers/ProductPriceController.cs +++ b/src/Web/Grand.Web.Store/Controllers/ProductPriceController.cs @@ -15,9 +15,9 @@ namespace Grand.Web.Store.Controllers; [AuthorizeStore] [AuthorizeMenu] public class ProductPriceController( - IProductViewModelService productViewModelService, + IProductPriceViewModelService productPriceViewModelService, IProductService productService, IRequestTranslationService translationService, IDateTimeService dateTimeService, IAdminDataScope scope) - : BaseProductPriceController(productViewModelService, productService, translationService, dateTimeService, scope); + : BaseProductPriceController(productPriceViewModelService, productService, translationService, dateTimeService, scope); diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductPriceController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductPriceController.cs index 226c25ba1..bc458dded 100644 --- a/src/Web/Grand.Web.Vendor/Controllers/ProductPriceController.cs +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductPriceController.cs @@ -15,9 +15,9 @@ namespace Grand.Web.Vendor.Controllers; [AuthorizeVendor] [AuthorizeMenu] public class ProductPriceController( - IProductViewModelService productViewModelService, + IProductPriceViewModelService productPriceViewModelService, IProductService productService, IRequestTranslationService translationService, IDateTimeService dateTimeService, IAdminDataScope scope) - : BaseProductPriceController(productViewModelService, productService, translationService, dateTimeService, scope); + : BaseProductPriceController(productPriceViewModelService, productService, translationService, dateTimeService, scope); From 9561d122bf63090c7c5cc3ae18c2452d794e6a65 Mon Sep 17 00:00:00 2001 From: support Date: Thu, 22 Oct 2026 07:43:15 +0200 Subject: [PATCH 20/27] Move the related, bundle and associated products view-model logic into their own services --- .../BaseProductAssociatedControllerTests.cs | 18 +- .../BaseProductBundleControllerTests.cs | 18 +- .../BaseProductRelatedControllerTests.cs | 52 +++-- .../Controllers/ProductControllerTestsBase.cs | 6 + .../ProductBundleViewModelServiceTests.cs | 55 +++++ .../ProductRelatedViewModelServiceTests.cs | 117 ++++++++++ .../Services/ProductViewModelServiceTests.cs | 115 --------- .../ProductAssociatedController.cs | 3 +- .../Controllers/ProductBundleController.cs | 3 +- .../Controllers/ProductRelatedController.cs | 3 +- .../BaseProductAssociatedController.cs | 5 +- .../BaseProductBundleController.cs | 7 +- .../BaseProductRelatedController.cs | 21 +- .../IProductAssociatedViewModelService.cs | 11 + .../IProductBundleViewModelService.cs | 12 + .../IProductRelatedViewModelService.cs | 26 +++ .../Interfaces/IProductViewModelService.cs | 15 -- .../ProductAssociatedViewModelService.cs | 31 +++ .../Services/ProductBundleViewModelService.cs | 65 ++++++ .../ProductRelatedViewModelService.cs | 161 +++++++++++++ .../Services/ProductViewModelService.cs | 219 ------------------ .../Startup/StartupApplication.cs | 3 + .../ProductAssociatedController.cs | 3 +- .../Controllers/ProductBundleController.cs | 3 +- .../Controllers/ProductRelatedController.cs | 3 +- .../ProductAssociatedController.cs | 3 +- .../Controllers/ProductBundleController.cs | 3 +- .../Controllers/ProductRelatedController.cs | 3 +- 28 files changed, 570 insertions(+), 414 deletions(-) create mode 100644 src/Tests/Grand.Web.Admin.Tests/Services/ProductBundleViewModelServiceTests.cs create mode 100644 src/Tests/Grand.Web.Admin.Tests/Services/ProductRelatedViewModelServiceTests.cs create mode 100644 src/Web/Grand.Web.AdminShared/Interfaces/IProductAssociatedViewModelService.cs create mode 100644 src/Web/Grand.Web.AdminShared/Interfaces/IProductBundleViewModelService.cs create mode 100644 src/Web/Grand.Web.AdminShared/Interfaces/IProductRelatedViewModelService.cs create mode 100644 src/Web/Grand.Web.AdminShared/Services/ProductAssociatedViewModelService.cs create mode 100644 src/Web/Grand.Web.AdminShared/Services/ProductBundleViewModelService.cs create mode 100644 src/Web/Grand.Web.AdminShared/Services/ProductRelatedViewModelService.cs diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAssociatedControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAssociatedControllerTests.cs index 911787717..d8820ffb7 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAssociatedControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAssociatedControllerTests.cs @@ -24,13 +24,15 @@ public class BaseProductAssociatedControllerTests : ProductControllerTestsBase scope) - : BaseProductAssociatedController(productViewModelService, productService, translationService, scope); + : BaseProductAssociatedController(productViewModelService, productAssociatedViewModelService, productService, translationService, scope); protected override TestProductAssociatedController CreateController() => new( _productViewModelServiceMock.Object, + _productAssociatedViewModelServiceMock.Object, _productServiceMock.Object, _translationServiceMock.Object, _scopeMock.Object); @@ -134,7 +136,7 @@ public async Task AssociatedProductDelete_ScopeDeniesAccess_ReturnsErrorJson_Doe Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.DeleteAssociatedProduct(It.IsAny()), Times.Never); + _productAssociatedViewModelServiceMock.Verify(s => s.DeleteAssociatedProduct(It.IsAny()), Times.Never); } [TestMethod] @@ -148,7 +150,7 @@ public async Task AssociatedProductDelete_ScopeGrantsAccess_ValidModel_Deletes() var result = await _controller.AssociatedProductDelete(model); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.DeleteAssociatedProduct(product), Times.Once); + _productAssociatedViewModelServiceMock.Verify(s => s.DeleteAssociatedProduct(product), Times.Once); } // --- AssociatedProductAddPopup (GET) ------------------------------------------------------------- @@ -227,7 +229,7 @@ public async Task AssociatedProductAddPopupPost_ScopeDeniesAccessToParent_Return Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.InsertAssociatedProductModel(It.IsAny()), Times.Never); + _productAssociatedViewModelServiceMock.Verify(s => s.InsertAssociatedProductModel(It.IsAny()), Times.Never); } [TestMethod] @@ -247,7 +249,7 @@ public async Task AssociatedProductAddPopupPost_ScopeGrantsAccess_AllSelectedIds Assert.IsNotNull(content); Assert.AreEqual("", content.Content); CollectionAssert.AreEqual(new[] { "a1" }, model.SelectedProductIds); - _productViewModelServiceMock.Verify(s => s.InsertAssociatedProductModel(model), Times.Once); + _productAssociatedViewModelServiceMock.Verify(s => s.InsertAssociatedProductModel(model), Times.Once); } [TestMethod] @@ -268,7 +270,7 @@ public async Task AssociatedProductAddPopupPost_ScopeGrantsAccessToParent_Filter Assert.IsInstanceOfType(result); CollectionAssert.AreEqual(new[] { "a1" }, model.SelectedProductIds); - _productViewModelServiceMock.Verify(s => s.InsertAssociatedProductModel(model), Times.Once); + _productAssociatedViewModelServiceMock.Verify(s => s.InsertAssociatedProductModel(model), Times.Once); } [TestMethod] @@ -285,7 +287,7 @@ public async Task AssociatedProductAddPopupPost_AllSelectedIdsDenied_DoesNotInse var result = await _controller.AssociatedProductAddPopup(model); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.InsertAssociatedProductModel(It.IsAny()), Times.Never); + _productAssociatedViewModelServiceMock.Verify(s => s.InsertAssociatedProductModel(It.IsAny()), Times.Never); } [TestMethod] @@ -304,6 +306,6 @@ public async Task AssociatedProductAddPopupPost_ScopeGrantsAccess_InvalidModel_R var view = result as ViewResult; Assert.IsNotNull(view); Assert.AreSame(reprepared, view.Model); - _productViewModelServiceMock.Verify(s => s.InsertAssociatedProductModel(It.IsAny()), Times.Never); + _productAssociatedViewModelServiceMock.Verify(s => s.InsertAssociatedProductModel(It.IsAny()), Times.Never); } } diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductBundleControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductBundleControllerTests.cs index e9fd3fa5d..ef3c9a372 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductBundleControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductBundleControllerTests.cs @@ -23,13 +23,15 @@ public class BaseProductBundleControllerTests : ProductControllerTestsBase scope) - : BaseProductBundleController(productViewModelService, productService, translationService, scope); + : BaseProductBundleController(productViewModelService, productBundleViewModelService, productService, translationService, scope); protected override TestProductBundleController CreateController() => new( _productViewModelServiceMock.Object, + _productBundleViewModelServiceMock.Object, _productServiceMock.Object, _translationServiceMock.Object, _scopeMock.Object); @@ -82,7 +84,7 @@ public async Task BundleProductUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNot Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.UpdateBundleProductModel(It.IsAny()), Times.Never); + _productBundleViewModelServiceMock.Verify(s => s.UpdateBundleProductModel(It.IsAny()), Times.Never); } [TestMethod] @@ -96,7 +98,7 @@ public async Task BundleProductUpdate_ScopeGrantsAccess_ValidModel_Updates() var result = await _controller.BundleProductUpdate(model); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.UpdateBundleProductModel(model), Times.Once); + _productBundleViewModelServiceMock.Verify(s => s.UpdateBundleProductModel(model), Times.Once); } // --- BundleProductDelete ---------------------------------------------------------------------- @@ -113,7 +115,7 @@ public async Task BundleProductDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNot Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.DeleteBundleProductModel(It.IsAny()), Times.Never); + _productBundleViewModelServiceMock.Verify(s => s.DeleteBundleProductModel(It.IsAny()), Times.Never); } [TestMethod] @@ -127,7 +129,7 @@ public async Task BundleProductDelete_ScopeGrantsAccess_ValidModel_Deletes() var result = await _controller.BundleProductDelete(model); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.DeleteBundleProductModel(model), Times.Once); + _productBundleViewModelServiceMock.Verify(s => s.DeleteBundleProductModel(model), Times.Once); } // --- BundleProductAddPopup (GET) -------------------------------------------------------------- @@ -199,7 +201,7 @@ public async Task BundleProductAddPopupPost_ScopeDeniesAccess_ReturnsContentMess Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.InsertBundleProductModel(It.IsAny()), Times.Never); + _productBundleViewModelServiceMock.Verify(s => s.InsertBundleProductModel(It.IsAny()), Times.Never); } [TestMethod] @@ -215,7 +217,7 @@ public async Task BundleProductAddPopupPost_ScopeGrantsAccess_ValidModel_Inserts var content = result as ContentResult; Assert.IsNotNull(content); Assert.AreEqual("", content.Content); - _productViewModelServiceMock.Verify(s => s.InsertBundleProductModel(model), Times.Once); + _productBundleViewModelServiceMock.Verify(s => s.InsertBundleProductModel(model), Times.Once); } [TestMethod] @@ -234,6 +236,6 @@ public async Task BundleProductAddPopupPost_ScopeGrantsAccess_InvalidModel_Retur var view = result as ViewResult; Assert.IsNotNull(view); Assert.AreSame(reprepared, view.Model); - _productViewModelServiceMock.Verify(s => s.InsertBundleProductModel(It.IsAny()), Times.Never); + _productBundleViewModelServiceMock.Verify(s => s.InsertBundleProductModel(It.IsAny()), Times.Never); } } diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductRelatedControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductRelatedControllerTests.cs index 5d8269fd9..5b1d660db 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductRelatedControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductRelatedControllerTests.cs @@ -24,13 +24,15 @@ public class BaseProductRelatedControllerTests : ProductControllerTestsBase scope) - : BaseProductRelatedController(productViewModelService, productService, translationService, scope); + : BaseProductRelatedController(productViewModelService, productRelatedViewModelService, productService, translationService, scope); protected override TestProductRelatedController CreateController() => new( _productViewModelServiceMock.Object, + _productRelatedViewModelServiceMock.Object, _productServiceMock.Object, _translationServiceMock.Object, _scopeMock.Object); @@ -83,7 +85,7 @@ public async Task RelatedProductUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNo Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.UpdateRelatedProductModel(It.IsAny()), Times.Never); + _productRelatedViewModelServiceMock.Verify(s => s.UpdateRelatedProductModel(It.IsAny()), Times.Never); } [TestMethod] @@ -97,7 +99,7 @@ public async Task RelatedProductUpdate_ScopeGrantsAccess_ValidModel_Updates() var result = await _controller.RelatedProductUpdate(model); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.UpdateRelatedProductModel(model), Times.Once); + _productRelatedViewModelServiceMock.Verify(s => s.UpdateRelatedProductModel(model), Times.Once); } // --- RelatedProductDelete ---------------------------------------------------------------------- @@ -114,7 +116,7 @@ public async Task RelatedProductDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNo Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.DeleteRelatedProductModel(It.IsAny()), Times.Never); + _productRelatedViewModelServiceMock.Verify(s => s.DeleteRelatedProductModel(It.IsAny()), Times.Never); } [TestMethod] @@ -128,7 +130,7 @@ public async Task RelatedProductDelete_ScopeGrantsAccess_ValidModel_Deletes() var result = await _controller.RelatedProductDelete(model); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.DeleteRelatedProductModel(model), Times.Once); + _productRelatedViewModelServiceMock.Verify(s => s.DeleteRelatedProductModel(model), Times.Once); } // --- RelatedProductAddPopup (GET) -------------------------------------------------------------- @@ -200,7 +202,7 @@ public async Task RelatedProductAddPopupPost_ScopeDeniesAccess_ReturnsContentMes Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.InsertRelatedProductModel(It.IsAny()), Times.Never); + _productRelatedViewModelServiceMock.Verify(s => s.InsertRelatedProductModel(It.IsAny()), Times.Never); } [TestMethod] @@ -216,7 +218,7 @@ public async Task RelatedProductAddPopupPost_ScopeGrantsAccess_ValidModel_Insert var content = result as ContentResult; Assert.IsNotNull(content); Assert.AreEqual("", content.Content); - _productViewModelServiceMock.Verify(s => s.InsertRelatedProductModel(model), Times.Once); + _productRelatedViewModelServiceMock.Verify(s => s.InsertRelatedProductModel(model), Times.Once); } [TestMethod] @@ -235,7 +237,7 @@ public async Task RelatedProductAddPopupPost_ScopeGrantsAccess_InvalidModel_Retu var view = result as ViewResult; Assert.IsNotNull(view); Assert.AreSame(reprepared, view.Model); - _productViewModelServiceMock.Verify(s => s.InsertRelatedProductModel(It.IsAny()), Times.Never); + _productRelatedViewModelServiceMock.Verify(s => s.InsertRelatedProductModel(It.IsAny()), Times.Never); } // --- SimilarProductList ------------------------------------------------------------------------ @@ -286,7 +288,7 @@ public async Task SimilarProductUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNo Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.UpdateSimilarProductModel(It.IsAny()), Times.Never); + _productRelatedViewModelServiceMock.Verify(s => s.UpdateSimilarProductModel(It.IsAny()), Times.Never); } [TestMethod] @@ -300,7 +302,7 @@ public async Task SimilarProductUpdate_ScopeGrantsAccess_ValidModel_Updates() var result = await _controller.SimilarProductUpdate(model); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.UpdateSimilarProductModel(model), Times.Once); + _productRelatedViewModelServiceMock.Verify(s => s.UpdateSimilarProductModel(model), Times.Once); } // --- SimilarProductDelete ---------------------------------------------------------------------- @@ -317,7 +319,7 @@ public async Task SimilarProductDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNo Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.DeleteSimilarProductModel(It.IsAny()), Times.Never); + _productRelatedViewModelServiceMock.Verify(s => s.DeleteSimilarProductModel(It.IsAny()), Times.Never); } [TestMethod] @@ -331,7 +333,7 @@ public async Task SimilarProductDelete_ScopeGrantsAccess_ValidModel_Deletes() var result = await _controller.SimilarProductDelete(model); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.DeleteSimilarProductModel(model), Times.Once); + _productRelatedViewModelServiceMock.Verify(s => s.DeleteSimilarProductModel(model), Times.Once); } // --- SimilarProductAddPopup (GET) -------------------------------------------------------------- @@ -403,7 +405,7 @@ public async Task SimilarProductAddPopupPost_ScopeDeniesAccess_ReturnsContentMes Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.InsertSimilarProductModel(It.IsAny()), Times.Never); + _productRelatedViewModelServiceMock.Verify(s => s.InsertSimilarProductModel(It.IsAny()), Times.Never); } [TestMethod] @@ -419,7 +421,7 @@ public async Task SimilarProductAddPopupPost_ScopeGrantsAccess_ValidModel_Insert var content = result as ContentResult; Assert.IsNotNull(content); Assert.AreEqual("", content.Content); - _productViewModelServiceMock.Verify(s => s.InsertSimilarProductModel(model), Times.Once); + _productRelatedViewModelServiceMock.Verify(s => s.InsertSimilarProductModel(model), Times.Once); } [TestMethod] @@ -438,7 +440,7 @@ public async Task SimilarProductAddPopupPost_ScopeGrantsAccess_InvalidModel_Retu var view = result as ViewResult; Assert.IsNotNull(view); Assert.AreSame(reprepared, view.Model); - _productViewModelServiceMock.Verify(s => s.InsertSimilarProductModel(It.IsAny()), Times.Never); + _productRelatedViewModelServiceMock.Verify(s => s.InsertSimilarProductModel(It.IsAny()), Times.Never); } // --- CrossSellProductList --------------------------------------------------------------------- @@ -502,7 +504,7 @@ public async Task CrossSellProductDelete_ScopeDeniesAccess_ReturnsErrorJson_Does Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.DeleteCrossSellProduct(It.IsAny(), It.IsAny()), Times.Never); + _productRelatedViewModelServiceMock.Verify(s => s.DeleteCrossSellProduct(It.IsAny(), It.IsAny()), Times.Never); } [TestMethod] @@ -528,7 +530,7 @@ public async Task CrossSellProductDelete_ScopeGrantsAccess_ValidModel_Deletes() var result = await _controller.CrossSellProductDelete(model); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.DeleteCrossSellProduct("p1", "p2"), Times.Once); + _productRelatedViewModelServiceMock.Verify(s => s.DeleteCrossSellProduct("p1", "p2"), Times.Once); } // --- CrossSellProductAddPopup (GET) ------------------------------------------------------------- @@ -600,7 +602,7 @@ public async Task CrossSellProductAddPopupPost_ScopeDeniesAccess_ReturnsContentM Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.InsertCrossSellProductModel(It.IsAny()), Times.Never); + _productRelatedViewModelServiceMock.Verify(s => s.InsertCrossSellProductModel(It.IsAny()), Times.Never); } [TestMethod] @@ -616,7 +618,7 @@ public async Task CrossSellProductAddPopupPost_ScopeGrantsAccess_ValidModel_Inse var content = result as ContentResult; Assert.IsNotNull(content); Assert.AreEqual("", content.Content); - _productViewModelServiceMock.Verify(s => s.InsertCrossSellProductModel(model), Times.Once); + _productRelatedViewModelServiceMock.Verify(s => s.InsertCrossSellProductModel(model), Times.Once); } [TestMethod] @@ -635,7 +637,7 @@ public async Task CrossSellProductAddPopupPost_ScopeGrantsAccess_InvalidModel_Re var view = result as ViewResult; Assert.IsNotNull(view); Assert.AreSame(reprepared, view.Model); - _productViewModelServiceMock.Verify(s => s.InsertCrossSellProductModel(It.IsAny()), Times.Never); + _productRelatedViewModelServiceMock.Verify(s => s.InsertCrossSellProductModel(It.IsAny()), Times.Never); } // --- RecommendedProductList --------------------------------------------------------------------- @@ -700,7 +702,7 @@ public async Task RecommendedProductDelete_ScopeDeniesAccess_ReturnsErrorJson_Do Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.DeleteRecommendedProduct(It.IsAny(), It.IsAny()), Times.Never); + _productRelatedViewModelServiceMock.Verify(s => s.DeleteRecommendedProduct(It.IsAny(), It.IsAny()), Times.Never); } [TestMethod] @@ -726,7 +728,7 @@ public async Task RecommendedProductDelete_ScopeGrantsAccess_ValidModel_Deletes( var result = await _controller.RecommendedProductDelete(model); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.DeleteRecommendedProduct("p1", "p2"), Times.Once); + _productRelatedViewModelServiceMock.Verify(s => s.DeleteRecommendedProduct("p1", "p2"), Times.Once); } // --- RecommendedProductAddPopup (GET) ------------------------------------------------------------- @@ -798,7 +800,7 @@ public async Task RecommendedProductAddPopupPost_ScopeDeniesAccess_ReturnsConten Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.InsertRecommendedProductModel(It.IsAny()), Times.Never); + _productRelatedViewModelServiceMock.Verify(s => s.InsertRecommendedProductModel(It.IsAny()), Times.Never); } [TestMethod] @@ -814,7 +816,7 @@ public async Task RecommendedProductAddPopupPost_ScopeGrantsAccess_ValidModel_In var content = result as ContentResult; Assert.IsNotNull(content); Assert.AreEqual("", content.Content); - _productViewModelServiceMock.Verify(s => s.InsertRecommendedProductModel(model), Times.Once); + _productRelatedViewModelServiceMock.Verify(s => s.InsertRecommendedProductModel(model), Times.Once); } [TestMethod] @@ -833,6 +835,6 @@ public async Task RecommendedProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ var view = result as ViewResult; Assert.IsNotNull(view); Assert.AreSame(reprepared, view.Model); - _productViewModelServiceMock.Verify(s => s.InsertRecommendedProductModel(It.IsAny()), Times.Never); + _productRelatedViewModelServiceMock.Verify(s => s.InsertRecommendedProductModel(It.IsAny()), Times.Never); } } diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs index 2e9a76451..fed82023d 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs @@ -28,6 +28,9 @@ public abstract class ProductControllerTestsBase where TController protected TController _controller; protected Mock _productServiceMock; protected Mock _productViewModelServiceMock; + protected Mock _productAssociatedViewModelServiceMock; + protected Mock _productBundleViewModelServiceMock; + protected Mock _productRelatedViewModelServiceMock; protected Mock _productPriceViewModelServiceMock; protected Mock _productBidViewModelServiceMock; protected Mock _productCollectionViewModelServiceMock; @@ -58,6 +61,9 @@ public void Setup() _productServiceMock = new Mock(); _productViewModelServiceMock = new Mock(); + _productAssociatedViewModelServiceMock = new Mock(); + _productBundleViewModelServiceMock = new Mock(); + _productRelatedViewModelServiceMock = new Mock(); _productPriceViewModelServiceMock = new Mock(); _productBidViewModelServiceMock = new Mock(); _productCollectionViewModelServiceMock = new Mock(); diff --git a/src/Tests/Grand.Web.Admin.Tests/Services/ProductBundleViewModelServiceTests.cs b/src/Tests/Grand.Web.Admin.Tests/Services/ProductBundleViewModelServiceTests.cs new file mode 100644 index 000000000..81bad4034 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Services/ProductBundleViewModelServiceTests.cs @@ -0,0 +1,55 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Admin.Tests.Services; + +[TestClass] +public class ProductBundleViewModelServiceTests +{ + private Mock _productServiceMock; + private ProductBundleViewModelService _productBundleViewModelService; + private Mock> _scopeMock; + + [TestInitialize] + public void Setup() + { + _productServiceMock = new Mock(); + _scopeMock = new Mock>(); + _productBundleViewModelService = new ProductBundleViewModelService(_productServiceMock.Object, _scopeMock.Object); + } + + private void SetupOwnershipForInsertTests(Product ownedProduct, Product notOwnedProduct) + { + _productServiceMock.Setup(p => p.GetProductById(ownedProduct.Id, It.IsAny())).ReturnsAsync(ownedProduct); + _productServiceMock.Setup(p => p.GetProductById(notOwnedProduct.Id, It.IsAny())).ReturnsAsync(notOwnedProduct); + _scopeMock.Setup(s => s.HasAccess(ownedProduct)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(notOwnedProduct)).ReturnsAsync(false); + } + + [TestMethod] + public async Task InsertBundleProductModel_DropsNotOwnedSelectedId() + { + var mainProduct = new Product { Id = "main" }; + var owned = new Product { Id = "owned" }; + var notOwned = new Product { Id = "not-owned" }; + _productServiceMock.Setup(p => p.GetProductById("main", It.IsAny())).ReturnsAsync(mainProduct); + SetupOwnershipForInsertTests(owned, notOwned); + + var model = new ProductModel.AddBundleProductModel { + ProductId = "main", + SelectedProductIds = ["owned", "not-owned"] + }; + await _productBundleViewModelService.InsertBundleProductModel(model); + + Assert.IsTrue(mainProduct.BundleProducts.Any(x => x.ProductId == "owned"), + "The owned selected product should be inserted."); + Assert.IsFalse(mainProduct.BundleProducts.Any(x => x.ProductId == "not-owned"), + "A not-owned selected product must be silently dropped, not inserted."); + _productServiceMock.Verify(p => p.InsertBundleProduct(It.Is(r => r.ProductId == "not-owned"), It.IsAny()), Times.Never); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Services/ProductRelatedViewModelServiceTests.cs b/src/Tests/Grand.Web.Admin.Tests/Services/ProductRelatedViewModelServiceTests.cs new file mode 100644 index 000000000..afe3345a4 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Services/ProductRelatedViewModelServiceTests.cs @@ -0,0 +1,117 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Admin.Tests.Services; + +[TestClass] +public class ProductRelatedViewModelServiceTests +{ + private Mock _productServiceMock; + private ProductRelatedViewModelService _productRelatedViewModelService; + private Mock> _scopeMock; + + [TestInitialize] + public void Setup() + { + _productServiceMock = new Mock(); + _scopeMock = new Mock>(); + _productRelatedViewModelService = new ProductRelatedViewModelService(_productServiceMock.Object, _scopeMock.Object); + } + + private void SetupOwnershipForInsertTests(Product ownedProduct, Product notOwnedProduct) + { + _productServiceMock.Setup(p => p.GetProductById(ownedProduct.Id, It.IsAny())).ReturnsAsync(ownedProduct); + _productServiceMock.Setup(p => p.GetProductById(notOwnedProduct.Id, It.IsAny())).ReturnsAsync(notOwnedProduct); + _scopeMock.Setup(s => s.HasAccess(ownedProduct)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(notOwnedProduct)).ReturnsAsync(false); + } + + [TestMethod] + public async Task InsertRelatedProductModel_DropsNotOwnedSelectedId() + { + var mainProduct = new Product { Id = "main" }; + var owned = new Product { Id = "owned" }; + var notOwned = new Product { Id = "not-owned" }; + _productServiceMock.Setup(p => p.GetProductById("main", It.IsAny())).ReturnsAsync(mainProduct); + SetupOwnershipForInsertTests(owned, notOwned); + + var model = new ProductModel.AddRelatedProductModel { + ProductId = "main", + SelectedProductIds = ["owned", "not-owned"] + }; + await _productRelatedViewModelService.InsertRelatedProductModel(model); + + Assert.IsTrue(mainProduct.RelatedProducts.Any(x => x.ProductId2 == "owned"), + "The owned selected product should be inserted."); + Assert.IsFalse(mainProduct.RelatedProducts.Any(x => x.ProductId2 == "not-owned"), + "A not-owned selected product must be silently dropped, not inserted."); + _productServiceMock.Verify(p => p.InsertRelatedProduct(It.Is(r => r.ProductId2 == "not-owned"), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task InsertSimilarProductModel_DropsNotOwnedSelectedId() + { + var mainProduct = new Product { Id = "main" }; + var owned = new Product { Id = "owned" }; + var notOwned = new Product { Id = "not-owned" }; + _productServiceMock.Setup(p => p.GetProductById("main", It.IsAny())).ReturnsAsync(mainProduct); + SetupOwnershipForInsertTests(owned, notOwned); + + var model = new ProductModel.AddSimilarProductModel { + ProductId = "main", + SelectedProductIds = ["owned", "not-owned"] + }; + await _productRelatedViewModelService.InsertSimilarProductModel(model); + + Assert.IsTrue(mainProduct.SimilarProducts.Any(x => x.ProductId2 == "owned"), + "The owned selected product should be inserted."); + Assert.IsFalse(mainProduct.SimilarProducts.Any(x => x.ProductId2 == "not-owned"), + "A not-owned selected product must be silently dropped, not inserted."); + _productServiceMock.Verify(p => p.InsertSimilarProduct(It.Is(r => r.ProductId2 == "not-owned")), Times.Never); + } + + [TestMethod] + public async Task InsertCrossSellProductModel_DropsNotOwnedSelectedId() + { + var mainProduct = new Product { Id = "main" }; + var owned = new Product { Id = "owned" }; + var notOwned = new Product { Id = "not-owned" }; + _productServiceMock.Setup(p => p.GetProductById("main", It.IsAny())).ReturnsAsync(mainProduct); + SetupOwnershipForInsertTests(owned, notOwned); + + var model = new ProductModel.AddCrossSellProductModel { + ProductId = "main", + SelectedProductIds = ["owned", "not-owned"] + }; + await _productRelatedViewModelService.InsertCrossSellProductModel(model); + + _productServiceMock.Verify(p => p.InsertCrossSellProduct(It.Is(r => r.ProductId2 == "owned")), Times.Once); + _productServiceMock.Verify(p => p.InsertCrossSellProduct(It.Is(r => r.ProductId2 == "not-owned")), Times.Never, + "A not-owned selected product must be silently dropped, not inserted."); + } + + [TestMethod] + public async Task InsertRecommendedProductModel_DropsNotOwnedSelectedId() + { + var mainProduct = new Product { Id = "main" }; + var owned = new Product { Id = "owned" }; + var notOwned = new Product { Id = "not-owned" }; + _productServiceMock.Setup(p => p.GetProductById("main", It.IsAny())).ReturnsAsync(mainProduct); + SetupOwnershipForInsertTests(owned, notOwned); + + var model = new ProductModel.AddRecommendedProductModel { + ProductId = "main", + SelectedProductIds = ["owned", "not-owned"] + }; + await _productRelatedViewModelService.InsertRecommendedProductModel(model); + + _productServiceMock.Verify(p => p.InsertRecommendedProduct("main", "owned"), Times.Once); + _productServiceMock.Verify(p => p.InsertRecommendedProduct("main", "not-owned"), Times.Never, + "A not-owned selected product must be silently dropped, not inserted."); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs b/src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs index f2c987928..490e75568 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs @@ -19,7 +19,6 @@ using Grand.Domain.Customers; using Grand.Domain.Directory; using Grand.Domain.Discounts; -using Grand.Domain.Media; using Grand.Domain.Seo; using Grand.Domain.Shipping; using Grand.Domain.Stores; @@ -472,118 +471,4 @@ public async Task UpdateProductModel_GlobalScope_UsesModelVendorId() Assert.AreEqual("vendor2", result.VendorId, "Admin should keep being able to reassign a product's vendor ownership via the edit form."); } - - private void SetupOwnershipForInsertTests(Product ownedProduct, Product notOwnedProduct) - { - _productServiceMock.Setup(p => p.GetProductById(ownedProduct.Id, It.IsAny())).ReturnsAsync(ownedProduct); - _productServiceMock.Setup(p => p.GetProductById(notOwnedProduct.Id, It.IsAny())).ReturnsAsync(notOwnedProduct); - _scopeMock.Setup(s => s.HasAccess(ownedProduct)).ReturnsAsync(true); - _scopeMock.Setup(s => s.HasAccess(notOwnedProduct)).ReturnsAsync(false); - } - - [TestMethod] - public async Task InsertRelatedProductModel_DropsNotOwnedSelectedId() - { - var mainProduct = new Product { Id = "main" }; - var owned = new Product { Id = "owned" }; - var notOwned = new Product { Id = "not-owned" }; - _productServiceMock.Setup(p => p.GetProductById("main", It.IsAny())).ReturnsAsync(mainProduct); - SetupOwnershipForInsertTests(owned, notOwned); - - var model = new ProductModel.AddRelatedProductModel { - ProductId = "main", - SelectedProductIds = ["owned", "not-owned"] - }; - await _productViewModelService.InsertRelatedProductModel(model); - - Assert.IsTrue(mainProduct.RelatedProducts.Any(x => x.ProductId2 == "owned"), - "The owned selected product should be inserted."); - Assert.IsFalse(mainProduct.RelatedProducts.Any(x => x.ProductId2 == "not-owned"), - "A not-owned selected product must be silently dropped, not inserted."); - _productServiceMock.Verify(p => p.InsertRelatedProduct(It.Is(r => r.ProductId2 == "not-owned"), It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task InsertSimilarProductModel_DropsNotOwnedSelectedId() - { - var mainProduct = new Product { Id = "main" }; - var owned = new Product { Id = "owned" }; - var notOwned = new Product { Id = "not-owned" }; - _productServiceMock.Setup(p => p.GetProductById("main", It.IsAny())).ReturnsAsync(mainProduct); - SetupOwnershipForInsertTests(owned, notOwned); - - var model = new ProductModel.AddSimilarProductModel { - ProductId = "main", - SelectedProductIds = ["owned", "not-owned"] - }; - await _productViewModelService.InsertSimilarProductModel(model); - - Assert.IsTrue(mainProduct.SimilarProducts.Any(x => x.ProductId2 == "owned"), - "The owned selected product should be inserted."); - Assert.IsFalse(mainProduct.SimilarProducts.Any(x => x.ProductId2 == "not-owned"), - "A not-owned selected product must be silently dropped, not inserted."); - _productServiceMock.Verify(p => p.InsertSimilarProduct(It.Is(r => r.ProductId2 == "not-owned")), Times.Never); - } - - [TestMethod] - public async Task InsertBundleProductModel_DropsNotOwnedSelectedId() - { - var mainProduct = new Product { Id = "main" }; - var owned = new Product { Id = "owned" }; - var notOwned = new Product { Id = "not-owned" }; - _productServiceMock.Setup(p => p.GetProductById("main", It.IsAny())).ReturnsAsync(mainProduct); - SetupOwnershipForInsertTests(owned, notOwned); - - var model = new ProductModel.AddBundleProductModel { - ProductId = "main", - SelectedProductIds = ["owned", "not-owned"] - }; - await _productViewModelService.InsertBundleProductModel(model); - - Assert.IsTrue(mainProduct.BundleProducts.Any(x => x.ProductId == "owned"), - "The owned selected product should be inserted."); - Assert.IsFalse(mainProduct.BundleProducts.Any(x => x.ProductId == "not-owned"), - "A not-owned selected product must be silently dropped, not inserted."); - _productServiceMock.Verify(p => p.InsertBundleProduct(It.Is(r => r.ProductId == "not-owned"), It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task InsertCrossSellProductModel_DropsNotOwnedSelectedId() - { - var mainProduct = new Product { Id = "main" }; - var owned = new Product { Id = "owned" }; - var notOwned = new Product { Id = "not-owned" }; - _productServiceMock.Setup(p => p.GetProductById("main", It.IsAny())).ReturnsAsync(mainProduct); - SetupOwnershipForInsertTests(owned, notOwned); - - var model = new ProductModel.AddCrossSellProductModel { - ProductId = "main", - SelectedProductIds = ["owned", "not-owned"] - }; - await _productViewModelService.InsertCrossSellProductModel(model); - - _productServiceMock.Verify(p => p.InsertCrossSellProduct(It.Is(r => r.ProductId2 == "owned")), Times.Once); - _productServiceMock.Verify(p => p.InsertCrossSellProduct(It.Is(r => r.ProductId2 == "not-owned")), Times.Never, - "A not-owned selected product must be silently dropped, not inserted."); - } - - [TestMethod] - public async Task InsertRecommendedProductModel_DropsNotOwnedSelectedId() - { - var mainProduct = new Product { Id = "main" }; - var owned = new Product { Id = "owned" }; - var notOwned = new Product { Id = "not-owned" }; - _productServiceMock.Setup(p => p.GetProductById("main", It.IsAny())).ReturnsAsync(mainProduct); - SetupOwnershipForInsertTests(owned, notOwned); - - var model = new ProductModel.AddRecommendedProductModel { - ProductId = "main", - SelectedProductIds = ["owned", "not-owned"] - }; - await _productViewModelService.InsertRecommendedProductModel(model); - - _productServiceMock.Verify(p => p.InsertRecommendedProduct("main", "owned"), Times.Once); - _productServiceMock.Verify(p => p.InsertRecommendedProduct("main", "not-owned"), Times.Never, - "A not-owned selected product must be silently dropped, not inserted."); - } } diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductAssociatedController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductAssociatedController.cs index 74b0de7f3..805700a23 100644 --- a/src/Web/Grand.Web.Admin/Controllers/ProductAssociatedController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/ProductAssociatedController.cs @@ -15,7 +15,8 @@ namespace Grand.Web.Admin.Controllers; [AuthorizeMenu] public class ProductAssociatedController( IProductViewModelService productViewModelService, + IProductAssociatedViewModelService productAssociatedViewModelService, IProductService productService, IRequestTranslationService translationService, IAdminDataScope scope) - : BaseProductAssociatedController(productViewModelService, productService, translationService, scope); + : BaseProductAssociatedController(productViewModelService, productAssociatedViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductBundleController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductBundleController.cs index 147e0fbac..961f97591 100644 --- a/src/Web/Grand.Web.Admin/Controllers/ProductBundleController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/ProductBundleController.cs @@ -15,7 +15,8 @@ namespace Grand.Web.Admin.Controllers; [AuthorizeMenu] public class ProductBundleController( IProductViewModelService productViewModelService, + IProductBundleViewModelService productBundleViewModelService, IProductService productService, IRequestTranslationService translationService, IAdminDataScope scope) - : BaseProductBundleController(productViewModelService, productService, translationService, scope); + : BaseProductBundleController(productViewModelService, productBundleViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductRelatedController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductRelatedController.cs index 7aba3b247..e8c1cd508 100644 --- a/src/Web/Grand.Web.Admin/Controllers/ProductRelatedController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/ProductRelatedController.cs @@ -15,7 +15,8 @@ namespace Grand.Web.Admin.Controllers; [AuthorizeMenu] public class ProductRelatedController( IProductViewModelService productViewModelService, + IProductRelatedViewModelService productRelatedViewModelService, IProductService productService, IRequestTranslationService translationService, IAdminDataScope scope) - : BaseProductRelatedController(productViewModelService, productService, translationService, scope); + : BaseProductRelatedController(productViewModelService, productRelatedViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAssociatedController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAssociatedController.cs index 086d95892..a5c3b139a 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAssociatedController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAssociatedController.cs @@ -19,6 +19,7 @@ namespace Grand.Web.AdminShared.Controllers; [AutoValidateAntiforgeryToken] public abstract class BaseProductAssociatedController( IProductViewModelService productViewModelService, + IProductAssociatedViewModelService productAssociatedViewModelService, IProductService productService, IRequestTranslationService translationService, IAdminDataScope scope) @@ -104,7 +105,7 @@ public async Task AssociatedProductDelete(ProductModel.Associated if (ModelState.IsValid) { - await productViewModelService.DeleteAssociatedProduct(product); + await productAssociatedViewModelService.DeleteAssociatedProduct(product); return new JsonResult(""); } @@ -178,7 +179,7 @@ public async Task AssociatedProductAddPopup(ProductModel.AddAssoc } model.SelectedProductIds = validIds.ToArray(); - if (validIds.Count > 0) await productViewModelService.InsertAssociatedProductModel(model); + if (validIds.Count > 0) await productAssociatedViewModelService.InsertAssociatedProductModel(model); } return Content(""); diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductBundleController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductBundleController.cs index 858df800c..3a4a1bf93 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductBundleController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductBundleController.cs @@ -17,6 +17,7 @@ namespace Grand.Web.AdminShared.Controllers; [AutoValidateAntiforgeryToken] public abstract class BaseProductBundleController( IProductViewModelService productViewModelService, + IProductBundleViewModelService productBundleViewModelService, IProductService productService, IRequestTranslationService translationService, IAdminDataScope scope) @@ -69,7 +70,7 @@ public async Task BundleProductUpdate(ProductModel.BundleProductM if (ModelState.IsValid) { - await productViewModelService.UpdateBundleProductModel(model); + await productBundleViewModelService.UpdateBundleProductModel(model); return new JsonResult(""); } @@ -86,7 +87,7 @@ public async Task BundleProductDelete(ProductModel.BundleProductM if (ModelState.IsValid) { - await productViewModelService.DeleteBundleProductModel(model); + await productBundleViewModelService.DeleteBundleProductModel(model); return new JsonResult(""); } @@ -136,7 +137,7 @@ public async Task BundleProductAddPopup(ProductModel.AddBundlePro if (ModelState.IsValid) { - if (model.SelectedProductIds != null) await productViewModelService.InsertBundleProductModel(model); + if (model.SelectedProductIds != null) await productBundleViewModelService.InsertBundleProductModel(model); return Content(""); } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductRelatedController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductRelatedController.cs index f50e7024b..df3399ad0 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductRelatedController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductRelatedController.cs @@ -17,6 +17,7 @@ namespace Grand.Web.AdminShared.Controllers; [AutoValidateAntiforgeryToken] public abstract class BaseProductRelatedController( IProductViewModelService productViewModelService, + IProductRelatedViewModelService productRelatedViewModelService, IProductService productService, IRequestTranslationService translationService, IAdminDataScope scope) @@ -70,7 +71,7 @@ public async Task RelatedProductUpdate(ProductModel.RelatedProduc if (ModelState.IsValid) { - await productViewModelService.UpdateRelatedProductModel(model); + await productRelatedViewModelService.UpdateRelatedProductModel(model); return new JsonResult(""); } @@ -87,7 +88,7 @@ public async Task RelatedProductDelete(ProductModel.RelatedProduc if (ModelState.IsValid) { - await productViewModelService.DeleteRelatedProductModel(model); + await productRelatedViewModelService.DeleteRelatedProductModel(model); return new JsonResult(""); } @@ -137,7 +138,7 @@ public async Task RelatedProductAddPopup(ProductModel.AddRelatedP if (ModelState.IsValid) { - if (model.SelectedProductIds != null) await productViewModelService.InsertRelatedProductModel(model); + if (model.SelectedProductIds != null) await productRelatedViewModelService.InsertRelatedProductModel(model); return Content(""); } @@ -205,7 +206,7 @@ public async Task SimilarProductUpdate(ProductModel.SimilarProduc if (ModelState.IsValid) { - await productViewModelService.UpdateSimilarProductModel(model); + await productRelatedViewModelService.UpdateSimilarProductModel(model); return new JsonResult(""); } @@ -222,7 +223,7 @@ public async Task SimilarProductDelete(ProductModel.SimilarProduc if (ModelState.IsValid) { - await productViewModelService.DeleteSimilarProductModel(model); + await productRelatedViewModelService.DeleteSimilarProductModel(model); return new JsonResult(""); } @@ -272,7 +273,7 @@ public async Task SimilarProductAddPopup(ProductModel.AddSimilarP if (ModelState.IsValid) { - if (model.SelectedProductIds != null) await productViewModelService.InsertSimilarProductModel(model); + if (model.SelectedProductIds != null) await productRelatedViewModelService.InsertSimilarProductModel(model); return Content(""); } @@ -346,7 +347,7 @@ public async Task CrossSellProductDelete(ProductModel.CrossSellPr if (ModelState.IsValid) { - await productViewModelService.DeleteCrossSellProduct(product.Id, crossSellProduct); + await productRelatedViewModelService.DeleteCrossSellProduct(product.Id, crossSellProduct); return new JsonResult(""); } @@ -396,7 +397,7 @@ public async Task CrossSellProductAddPopup(ProductModel.AddCrossS if (ModelState.IsValid) { - if (model.SelectedProductIds != null) await productViewModelService.InsertCrossSellProductModel(model); + if (model.SelectedProductIds != null) await productRelatedViewModelService.InsertCrossSellProductModel(model); return Content(""); } @@ -470,7 +471,7 @@ public async Task RecommendedProductDelete(ProductModel.Recommend if (ModelState.IsValid) { - await productViewModelService.DeleteRecommendedProduct(product.Id, recommendedProduct); + await productRelatedViewModelService.DeleteRecommendedProduct(product.Id, recommendedProduct); return new JsonResult(""); } @@ -520,7 +521,7 @@ public async Task RecommendedProductAddPopup(ProductModel.AddReco if (ModelState.IsValid) { - if (model.SelectedProductIds != null) await productViewModelService.InsertRecommendedProductModel(model); + if (model.SelectedProductIds != null) await productRelatedViewModelService.InsertRecommendedProductModel(model); return Content(""); } diff --git a/src/Web/Grand.Web.AdminShared/Interfaces/IProductAssociatedViewModelService.cs b/src/Web/Grand.Web.AdminShared/Interfaces/IProductAssociatedViewModelService.cs new file mode 100644 index 000000000..698069c92 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Interfaces/IProductAssociatedViewModelService.cs @@ -0,0 +1,11 @@ +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Models.Catalog; + +namespace Grand.Web.AdminShared.Interfaces; + +public interface IProductAssociatedViewModelService +{ + Task InsertAssociatedProductModel(ProductModel.AddAssociatedProductModel model); + + Task DeleteAssociatedProduct(Product product); +} diff --git a/src/Web/Grand.Web.AdminShared/Interfaces/IProductBundleViewModelService.cs b/src/Web/Grand.Web.AdminShared/Interfaces/IProductBundleViewModelService.cs new file mode 100644 index 000000000..747374320 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Interfaces/IProductBundleViewModelService.cs @@ -0,0 +1,12 @@ +using Grand.Web.AdminShared.Models.Catalog; + +namespace Grand.Web.AdminShared.Interfaces; + +public interface IProductBundleViewModelService +{ + Task InsertBundleProductModel(ProductModel.AddBundleProductModel model); + + Task UpdateBundleProductModel(ProductModel.BundleProductModel model); + + Task DeleteBundleProductModel(ProductModel.BundleProductModel model); +} diff --git a/src/Web/Grand.Web.AdminShared/Interfaces/IProductRelatedViewModelService.cs b/src/Web/Grand.Web.AdminShared/Interfaces/IProductRelatedViewModelService.cs new file mode 100644 index 000000000..045b1b5c0 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Interfaces/IProductRelatedViewModelService.cs @@ -0,0 +1,26 @@ +using Grand.Web.AdminShared.Models.Catalog; + +namespace Grand.Web.AdminShared.Interfaces; + +public interface IProductRelatedViewModelService +{ + Task InsertRelatedProductModel(ProductModel.AddRelatedProductModel model); + + Task UpdateRelatedProductModel(ProductModel.RelatedProductModel model); + + Task DeleteRelatedProductModel(ProductModel.RelatedProductModel model); + + Task InsertSimilarProductModel(ProductModel.AddSimilarProductModel model); + + Task UpdateSimilarProductModel(ProductModel.SimilarProductModel model); + + Task DeleteSimilarProductModel(ProductModel.SimilarProductModel model); + + Task InsertCrossSellProductModel(ProductModel.AddCrossSellProductModel model); + + Task DeleteCrossSellProduct(string productId, string crossSellProductId); + + Task InsertRecommendedProductModel(ProductModel.AddRecommendedProductModel model); + + Task DeleteRecommendedProduct(string productId, string recommendedProductId); +} diff --git a/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs b/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs index a736e610e..0cf77c3ab 100644 --- a/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs @@ -35,21 +35,6 @@ Task OutOfStockNotifications(Product product, ProductAttributeCombination combin Task<(IList products, int totalCount)> PrepareProductModel(ProductModel.AddProductModel model, int pageIndex, int pageSize); - Task InsertRelatedProductModel(ProductModel.AddRelatedProductModel model); - Task UpdateRelatedProductModel(ProductModel.RelatedProductModel model); - Task DeleteRelatedProductModel(ProductModel.RelatedProductModel model); - Task InsertSimilarProductModel(ProductModel.AddSimilarProductModel model); - Task UpdateSimilarProductModel(ProductModel.SimilarProductModel model); - Task DeleteSimilarProductModel(ProductModel.SimilarProductModel model); - Task InsertBundleProductModel(ProductModel.AddBundleProductModel model); - Task UpdateBundleProductModel(ProductModel.BundleProductModel model); - Task DeleteBundleProductModel(ProductModel.BundleProductModel model); - Task InsertCrossSellProductModel(ProductModel.AddCrossSellProductModel model); - Task DeleteCrossSellProduct(string productId, string crossSellProductId); - Task InsertRecommendedProductModel(ProductModel.AddRecommendedProductModel model); - Task DeleteRecommendedProduct(string productId, string recommendedProductId); - Task InsertAssociatedProductModel(ProductModel.AddAssociatedProductModel model); - Task DeleteAssociatedProduct(Product product); Task PrepareRelatedProductModel(); Task PrepareSimilarProductModel(); Task PrepareBundleProductModel(); diff --git a/src/Web/Grand.Web.AdminShared/Services/ProductAssociatedViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/ProductAssociatedViewModelService.cs new file mode 100644 index 000000000..bed6cb0d0 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Services/ProductAssociatedViewModelService.cs @@ -0,0 +1,31 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using System.Net; + +namespace Grand.Web.AdminShared.Services; + +public class ProductAssociatedViewModelService( + IProductService productService) + : IProductAssociatedViewModelService +{ + public virtual async Task InsertAssociatedProductModel(ProductModel.AddAssociatedProductModel model) + { + foreach (var id in model.SelectedProductIds) + { + var product = await productService.GetProductById(id); + if (product != null) + { + product.ParentGroupedProductId = model.ProductId; + await productService.UpdateAssociatedProduct(product); + } + } + } + + public virtual async Task DeleteAssociatedProduct(Product product) + { + product.ParentGroupedProductId = ""; + await productService.UpdateAssociatedProduct(product); + } +} diff --git a/src/Web/Grand.Web.AdminShared/Services/ProductBundleViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/ProductBundleViewModelService.cs new file mode 100644 index 000000000..ac8a7a44e --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Services/ProductBundleViewModelService.cs @@ -0,0 +1,65 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using System.Net; + +namespace Grand.Web.AdminShared.Services; + +public class ProductBundleViewModelService( + IProductService productService, + IAdminDataScope scope) + : IProductBundleViewModelService +{ + public virtual async Task InsertBundleProductModel(ProductModel.AddBundleProductModel model) + { + var productId1 = await productService.GetProductById(model.ProductId, true); + + foreach (var id in model.SelectedProductIds) + { + var product = await productService.GetProductById(id); + // scope.HasAccess: see InsertRelatedProductModel above. + if (product == null || !await scope.HasAccess(product)) continue; + + var existingBundleProducts = productId1.BundleProducts; + if (model.ProductId != id) + if (!existingBundleProducts.Any(x => x.ProductId == id)) + { + var bundle = new BundleProduct { + ProductId = id, + DisplayOrder = 1, + Quantity = 1 + }; + productId1.BundleProducts.Add(bundle); + await productService.InsertBundleProduct(bundle, model.ProductId); + } + } + } + + public virtual async Task UpdateBundleProductModel(ProductModel.BundleProductModel model) + { + var product = await productService.GetProductById(model.ProductBundleId, true); + var bundleProduct = product.BundleProducts.FirstOrDefault(x => x.Id == model.Id); + if (bundleProduct == null) + throw new ArgumentException("No bundle product found with the specified id"); + + var product2 = await productService.GetProductById(bundleProduct.ProductId); + if (product2 == null) + throw new ArgumentException("No product found with the specified id"); + + bundleProduct.ProductId = model.ProductId; + bundleProduct.Quantity = model.Quantity > 0 ? model.Quantity : 1; + bundleProduct.DisplayOrder = model.DisplayOrder; + await productService.UpdateBundleProduct(bundleProduct, model.ProductBundleId); + } + + public virtual async Task DeleteBundleProductModel(ProductModel.BundleProductModel model) + { + var product = await productService.GetProductById(model.ProductBundleId, true); + var bundleProduct = product.BundleProducts.FirstOrDefault(x => x.Id == model.Id); + if (bundleProduct == null) + throw new ArgumentException("No bundle product found with the specified id"); + + await productService.DeleteBundleProduct(bundleProduct, model.ProductBundleId); + } +} diff --git a/src/Web/Grand.Web.AdminShared/Services/ProductRelatedViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/ProductRelatedViewModelService.cs new file mode 100644 index 000000000..8b7dfc609 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Services/ProductRelatedViewModelService.cs @@ -0,0 +1,161 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using System.Net; + +namespace Grand.Web.AdminShared.Services; + +public class ProductRelatedViewModelService( + IProductService productService, + IAdminDataScope scope) + : IProductRelatedViewModelService +{ + public virtual async Task InsertRelatedProductModel(ProductModel.AddRelatedProductModel model) + { + var productId1 = await productService.GetProductById(model.ProductId, true); + + foreach (var id in model.SelectedProductIds) + { + var product = await productService.GetProductById(id); + if (product == null || !await scope.HasAccess(product)) continue; + + var existingRelatedProducts = productId1.RelatedProducts; + if (model.ProductId != id) + if (!existingRelatedProducts.Any(x => x.ProductId2 == id)) + { + var related = new RelatedProduct { + ProductId2 = id, + DisplayOrder = 1 + }; + productId1.RelatedProducts.Add(related); + await productService.InsertRelatedProduct(related, model.ProductId); + } + } + } + + public virtual async Task UpdateRelatedProductModel(ProductModel.RelatedProductModel model) + { + var product1 = await productService.GetProductById(model.ProductId1, true); + var relatedProduct = product1.RelatedProducts.FirstOrDefault(x => x.Id == model.Id); + if (relatedProduct == null) + throw new ArgumentException("No related product found with the specified id"); + + var product2 = await productService.GetProductById(relatedProduct.ProductId2); + if (product2 == null) + throw new ArgumentException("No product found with the specified id"); + + relatedProduct.DisplayOrder = model.DisplayOrder; + await productService.UpdateRelatedProduct(relatedProduct, model.ProductId1); + } + + public virtual async Task DeleteRelatedProductModel(ProductModel.RelatedProductModel model) + { + var product = await productService.GetProductById(model.ProductId1, true); + var relatedProduct = product.RelatedProducts.FirstOrDefault(x => x.Id == model.Id); + if (relatedProduct == null) + throw new ArgumentException("No related product found with the specified id"); + + await productService.DeleteRelatedProduct(relatedProduct, model.ProductId1); + } + + public virtual async Task InsertSimilarProductModel(ProductModel.AddSimilarProductModel model) + { + var productId1 = await productService.GetProductById(model.ProductId, true); + + foreach (var id in model.SelectedProductIds) + { + var product = await productService.GetProductById(id); + // scope.HasAccess: see InsertRelatedProductModel above. + if (product == null || !await scope.HasAccess(product)) continue; + + var existingSimilarProducts = productId1.SimilarProducts; + if (model.ProductId != id) + if (!existingSimilarProducts.Any(x => x.ProductId2 == id)) + { + var similar = new SimilarProduct { + ProductId1 = model.ProductId, + ProductId2 = id, + DisplayOrder = 1 + }; + productId1.SimilarProducts.Add(similar); + await productService.InsertSimilarProduct(similar); + } + } + } + + public virtual async Task UpdateSimilarProductModel(ProductModel.SimilarProductModel model) + { + var product1 = await productService.GetProductById(model.ProductId1, true); + var similarProduct = product1.SimilarProducts.FirstOrDefault(x => x.Id == model.Id); + if (similarProduct == null) + throw new ArgumentException("No similar product found with the specified id"); + + var product2 = await productService.GetProductById(similarProduct.ProductId2); + if (product2 == null) + throw new ArgumentException("No product found with the specified id"); + + similarProduct.ProductId1 = model.ProductId1; + similarProduct.DisplayOrder = model.DisplayOrder; + await productService.UpdateSimilarProduct(similarProduct); + } + + public virtual async Task DeleteSimilarProductModel(ProductModel.SimilarProductModel model) + { + var product = await productService.GetProductById(model.ProductId1, true); + var similarProduct = product.SimilarProducts.FirstOrDefault(x => x.Id == model.Id); + if (similarProduct == null) + throw new ArgumentException("No similar product found with the specified id"); + + similarProduct.ProductId1 = model.ProductId1; + await productService.DeleteSimilarProduct(similarProduct); + } + + public virtual async Task InsertCrossSellProductModel(ProductModel.AddCrossSellProductModel model) + { + var crossSellProduct = await productService.GetProductById(model.ProductId, true); + foreach (var id in model.SelectedProductIds) + { + var product = await productService.GetProductById(id); + // scope.HasAccess: see InsertRelatedProductModel above. + if (product == null || !await scope.HasAccess(product)) continue; + + if (!crossSellProduct.CrossSellProduct.Any(x => x == id)) + if (model.ProductId != id) + await productService.InsertCrossSellProduct( + new CrossSellProduct { + ProductId1 = model.ProductId, + ProductId2 = id + }); + } + } + + public virtual async Task DeleteCrossSellProduct(string productId, string crossSellProductId) + { + var crosssell = new CrossSellProduct { + ProductId1 = productId, + ProductId2 = crossSellProductId + }; + await productService.DeleteCrossSellProduct(crosssell); + } + + public virtual async Task InsertRecommendedProductModel(ProductModel.AddRecommendedProductModel model) + { + var mainproduct = await productService.GetProductById(model.ProductId, true); + foreach (var id in model.SelectedProductIds) + { + var product = await productService.GetProductById(id); + // scope.HasAccess: see InsertRelatedProductModel above. + if (product == null || !await scope.HasAccess(product)) continue; + + if (!mainproduct.RecommendedProduct.Any(x => x == id)) + if (model.ProductId != id) + await productService.InsertRecommendedProduct(model.ProductId, id); + } + } + + public virtual async Task DeleteRecommendedProduct(string productId, string recommendedProductId) + { + await productService.DeleteRecommendedProduct(productId, recommendedProductId); + } +} diff --git a/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs index 61f597867..c8d3db558 100644 --- a/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs @@ -876,225 +876,6 @@ public virtual async Task DeleteSelected(IEnumerable selectedIds) return (products.Select(x => x.ToModel(dateTimeService)).ToList(), products.TotalCount); } - public virtual async Task InsertRelatedProductModel(ProductModel.AddRelatedProductModel model) - { - var productId1 = await productService.GetProductById(model.ProductId, true); - - foreach (var id in model.SelectedProductIds) - { - var product = await productService.GetProductById(id); - if (product == null || !await scope.HasAccess(product)) continue; - - var existingRelatedProducts = productId1.RelatedProducts; - if (model.ProductId != id) - if (!existingRelatedProducts.Any(x => x.ProductId2 == id)) - { - var related = new RelatedProduct { - ProductId2 = id, - DisplayOrder = 1 - }; - productId1.RelatedProducts.Add(related); - await productService.InsertRelatedProduct(related, model.ProductId); - } - } - } - - public virtual async Task UpdateRelatedProductModel(ProductModel.RelatedProductModel model) - { - var product1 = await productService.GetProductById(model.ProductId1, true); - var relatedProduct = product1.RelatedProducts.FirstOrDefault(x => x.Id == model.Id); - if (relatedProduct == null) - throw new ArgumentException("No related product found with the specified id"); - - var product2 = await productService.GetProductById(relatedProduct.ProductId2); - if (product2 == null) - throw new ArgumentException("No product found with the specified id"); - - relatedProduct.DisplayOrder = model.DisplayOrder; - await productService.UpdateRelatedProduct(relatedProduct, model.ProductId1); - } - - public virtual async Task DeleteRelatedProductModel(ProductModel.RelatedProductModel model) - { - var product = await productService.GetProductById(model.ProductId1, true); - var relatedProduct = product.RelatedProducts.FirstOrDefault(x => x.Id == model.Id); - if (relatedProduct == null) - throw new ArgumentException("No related product found with the specified id"); - - await productService.DeleteRelatedProduct(relatedProduct, model.ProductId1); - } - - public virtual async Task InsertSimilarProductModel(ProductModel.AddSimilarProductModel model) - { - var productId1 = await productService.GetProductById(model.ProductId, true); - - foreach (var id in model.SelectedProductIds) - { - var product = await productService.GetProductById(id); - // scope.HasAccess: see InsertRelatedProductModel above. - if (product == null || !await scope.HasAccess(product)) continue; - - var existingSimilarProducts = productId1.SimilarProducts; - if (model.ProductId != id) - if (!existingSimilarProducts.Any(x => x.ProductId2 == id)) - { - var similar = new SimilarProduct { - ProductId1 = model.ProductId, - ProductId2 = id, - DisplayOrder = 1 - }; - productId1.SimilarProducts.Add(similar); - await productService.InsertSimilarProduct(similar); - } - } - } - - public virtual async Task UpdateSimilarProductModel(ProductModel.SimilarProductModel model) - { - var product1 = await productService.GetProductById(model.ProductId1, true); - var similarProduct = product1.SimilarProducts.FirstOrDefault(x => x.Id == model.Id); - if (similarProduct == null) - throw new ArgumentException("No similar product found with the specified id"); - - var product2 = await productService.GetProductById(similarProduct.ProductId2); - if (product2 == null) - throw new ArgumentException("No product found with the specified id"); - - similarProduct.ProductId1 = model.ProductId1; - similarProduct.DisplayOrder = model.DisplayOrder; - await productService.UpdateSimilarProduct(similarProduct); - } - - public virtual async Task DeleteSimilarProductModel(ProductModel.SimilarProductModel model) - { - var product = await productService.GetProductById(model.ProductId1, true); - var similarProduct = product.SimilarProducts.FirstOrDefault(x => x.Id == model.Id); - if (similarProduct == null) - throw new ArgumentException("No similar product found with the specified id"); - - similarProduct.ProductId1 = model.ProductId1; - await productService.DeleteSimilarProduct(similarProduct); - } - - public virtual async Task InsertBundleProductModel(ProductModel.AddBundleProductModel model) - { - var productId1 = await productService.GetProductById(model.ProductId, true); - - foreach (var id in model.SelectedProductIds) - { - var product = await productService.GetProductById(id); - // scope.HasAccess: see InsertRelatedProductModel above. - if (product == null || !await scope.HasAccess(product)) continue; - - var existingBundleProducts = productId1.BundleProducts; - if (model.ProductId != id) - if (!existingBundleProducts.Any(x => x.ProductId == id)) - { - var bundle = new BundleProduct { - ProductId = id, - DisplayOrder = 1, - Quantity = 1 - }; - productId1.BundleProducts.Add(bundle); - await productService.InsertBundleProduct(bundle, model.ProductId); - } - } - } - - public virtual async Task UpdateBundleProductModel(ProductModel.BundleProductModel model) - { - var product = await productService.GetProductById(model.ProductBundleId, true); - var bundleProduct = product.BundleProducts.FirstOrDefault(x => x.Id == model.Id); - if (bundleProduct == null) - throw new ArgumentException("No bundle product found with the specified id"); - - var product2 = await productService.GetProductById(bundleProduct.ProductId); - if (product2 == null) - throw new ArgumentException("No product found with the specified id"); - - bundleProduct.ProductId = model.ProductId; - bundleProduct.Quantity = model.Quantity > 0 ? model.Quantity : 1; - bundleProduct.DisplayOrder = model.DisplayOrder; - await productService.UpdateBundleProduct(bundleProduct, model.ProductBundleId); - } - - public virtual async Task DeleteBundleProductModel(ProductModel.BundleProductModel model) - { - var product = await productService.GetProductById(model.ProductBundleId, true); - var bundleProduct = product.BundleProducts.FirstOrDefault(x => x.Id == model.Id); - if (bundleProduct == null) - throw new ArgumentException("No bundle product found with the specified id"); - - await productService.DeleteBundleProduct(bundleProduct, model.ProductBundleId); - } - - public virtual async Task InsertCrossSellProductModel(ProductModel.AddCrossSellProductModel model) - { - var crossSellProduct = await productService.GetProductById(model.ProductId, true); - foreach (var id in model.SelectedProductIds) - { - var product = await productService.GetProductById(id); - // scope.HasAccess: see InsertRelatedProductModel above. - if (product == null || !await scope.HasAccess(product)) continue; - - if (!crossSellProduct.CrossSellProduct.Any(x => x == id)) - if (model.ProductId != id) - await productService.InsertCrossSellProduct( - new CrossSellProduct { - ProductId1 = model.ProductId, - ProductId2 = id - }); - } - } - - public virtual async Task DeleteCrossSellProduct(string productId, string crossSellProductId) - { - var crosssell = new CrossSellProduct { - ProductId1 = productId, - ProductId2 = crossSellProductId - }; - await productService.DeleteCrossSellProduct(crosssell); - } - - public virtual async Task InsertRecommendedProductModel(ProductModel.AddRecommendedProductModel model) - { - var mainproduct = await productService.GetProductById(model.ProductId, true); - foreach (var id in model.SelectedProductIds) - { - var product = await productService.GetProductById(id); - // scope.HasAccess: see InsertRelatedProductModel above. - if (product == null || !await scope.HasAccess(product)) continue; - - if (!mainproduct.RecommendedProduct.Any(x => x == id)) - if (model.ProductId != id) - await productService.InsertRecommendedProduct(model.ProductId, id); - } - } - - public virtual async Task DeleteRecommendedProduct(string productId, string recommendedProductId) - { - await productService.DeleteRecommendedProduct(productId, recommendedProductId); - } - - public virtual async Task InsertAssociatedProductModel(ProductModel.AddAssociatedProductModel model) - { - foreach (var id in model.SelectedProductIds) - { - var product = await productService.GetProductById(id); - if (product != null) - { - product.ParentGroupedProductId = model.ProductId; - await productService.UpdateAssociatedProduct(product); - } - } - } - - public virtual async Task DeleteAssociatedProduct(Product product) - { - product.ParentGroupedProductId = ""; - await productService.UpdateAssociatedProduct(product); - } - public virtual async Task PrepareRelatedProductModel() { var model = await PrepareAddProductModel(); diff --git a/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs index c42782540..c3c00cc78 100644 --- a/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs +++ b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs @@ -73,6 +73,9 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Web/Grand.Web.Store/Controllers/ProductAssociatedController.cs b/src/Web/Grand.Web.Store/Controllers/ProductAssociatedController.cs index 94d04e926..370a5a6d2 100644 --- a/src/Web/Grand.Web.Store/Controllers/ProductAssociatedController.cs +++ b/src/Web/Grand.Web.Store/Controllers/ProductAssociatedController.cs @@ -15,7 +15,8 @@ namespace Grand.Web.Store.Controllers; [AuthorizeMenu] public class ProductAssociatedController( IProductViewModelService productViewModelService, + IProductAssociatedViewModelService productAssociatedViewModelService, IProductService productService, IRequestTranslationService translationService, IAdminDataScope scope) - : BaseProductAssociatedController(productViewModelService, productService, translationService, scope); + : BaseProductAssociatedController(productViewModelService, productAssociatedViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.Store/Controllers/ProductBundleController.cs b/src/Web/Grand.Web.Store/Controllers/ProductBundleController.cs index a2b37a6cb..19d76dc8f 100644 --- a/src/Web/Grand.Web.Store/Controllers/ProductBundleController.cs +++ b/src/Web/Grand.Web.Store/Controllers/ProductBundleController.cs @@ -15,7 +15,8 @@ namespace Grand.Web.Store.Controllers; [AuthorizeMenu] public class ProductBundleController( IProductViewModelService productViewModelService, + IProductBundleViewModelService productBundleViewModelService, IProductService productService, IRequestTranslationService translationService, IAdminDataScope scope) - : BaseProductBundleController(productViewModelService, productService, translationService, scope); + : BaseProductBundleController(productViewModelService, productBundleViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.Store/Controllers/ProductRelatedController.cs b/src/Web/Grand.Web.Store/Controllers/ProductRelatedController.cs index 08f465227..628e522a6 100644 --- a/src/Web/Grand.Web.Store/Controllers/ProductRelatedController.cs +++ b/src/Web/Grand.Web.Store/Controllers/ProductRelatedController.cs @@ -15,7 +15,8 @@ namespace Grand.Web.Store.Controllers; [AuthorizeMenu] public class ProductRelatedController( IProductViewModelService productViewModelService, + IProductRelatedViewModelService productRelatedViewModelService, IProductService productService, IRequestTranslationService translationService, IAdminDataScope scope) - : BaseProductRelatedController(productViewModelService, productService, translationService, scope); + : BaseProductRelatedController(productViewModelService, productRelatedViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductAssociatedController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductAssociatedController.cs index fae06caa5..acbd3f409 100644 --- a/src/Web/Grand.Web.Vendor/Controllers/ProductAssociatedController.cs +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductAssociatedController.cs @@ -16,11 +16,12 @@ namespace Grand.Web.Vendor.Controllers; [AuthorizeMenu] public class ProductAssociatedController( IProductViewModelService productViewModelService, + IProductAssociatedViewModelService productAssociatedViewModelService, IProductService productService, IRequestTranslationService translationService, IAdminDataScope scope, IContextAccessor contextAccessor) - : BaseProductAssociatedController(productViewModelService, productService, translationService, scope) + : BaseProductAssociatedController(productViewModelService, productAssociatedViewModelService, productService, translationService, scope) { // Vendor's original passed CurrentVendor.Id into GetAssociatedProducts(vendorId:) so a vendor only // sees the subset of a grouped product's associated products that they themselves own. Overriding diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductBundleController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductBundleController.cs index 7930fa945..db9e56745 100644 --- a/src/Web/Grand.Web.Vendor/Controllers/ProductBundleController.cs +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductBundleController.cs @@ -16,10 +16,11 @@ namespace Grand.Web.Vendor.Controllers; [AuthorizeMenu] public class ProductBundleController( IProductViewModelService productViewModelService, + IProductBundleViewModelService productBundleViewModelService, IProductService productService, IRequestTranslationService translationService, IAdminDataScope scope) - : BaseProductBundleController(productViewModelService, productService, translationService, scope) + : BaseProductBundleController(productViewModelService, productBundleViewModelService, productService, translationService, scope) { // Vendor's original AddPopup(POST) actions returned Content(ModelState.GetErrors()) on an invalid // model, instead of Admin/Store's re-prepare-and-View. Overriding the base's Admin/Store default, diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductRelatedController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductRelatedController.cs index a5e8cd422..0f6d5eefd 100644 --- a/src/Web/Grand.Web.Vendor/Controllers/ProductRelatedController.cs +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductRelatedController.cs @@ -16,10 +16,11 @@ namespace Grand.Web.Vendor.Controllers; [AuthorizeMenu] public class ProductRelatedController( IProductViewModelService productViewModelService, + IProductRelatedViewModelService productRelatedViewModelService, IProductService productService, IRequestTranslationService translationService, IAdminDataScope scope) - : BaseProductRelatedController(productViewModelService, productService, translationService, scope) + : BaseProductRelatedController(productViewModelService, productRelatedViewModelService, productService, translationService, scope) { // Vendor's original AddPopup(POST) actions returned Content(ModelState.GetErrors()) on an invalid // model, instead of Admin/Store's re-prepare-and-View. Overriding the base's Admin/Store default, From 8613c61dc136fff65405d4e42f75d5adde979237 Mon Sep 17 00:00:00 2001 From: support Date: Thu, 22 Oct 2026 07:45:53 +0200 Subject: [PATCH 21/27] Move the product attributes view-model logic into its own service --- ...eProductAttributeMappingControllerTests.cs | 88 +-- .../Controllers/ProductControllerTestsBase.cs | 2 + .../ProductAttributeMappingController.cs | 3 +- .../BaseProductAttributeMappingController.cs | 39 +- ...ProductAttributeMappingViewModelService.cs | 48 ++ .../Interfaces/IProductViewModelService.cs | 37 -- ...ProductAttributeMappingViewModelService.cs | 559 ++++++++++++++++++ .../Services/ProductViewModelService.cs | 524 ---------------- .../Startup/StartupApplication.cs | 1 + .../ProductAttributeMappingController.cs | 3 +- .../ProductAttributeMappingController.cs | 3 +- 11 files changed, 681 insertions(+), 626 deletions(-) create mode 100644 src/Web/Grand.Web.AdminShared/Interfaces/IProductAttributeMappingViewModelService.cs create mode 100644 src/Web/Grand.Web.AdminShared/Services/ProductAttributeMappingViewModelService.cs diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAttributeMappingControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAttributeMappingControllerTests.cs index 5f44fcffa..ef2443a89 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAttributeMappingControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAttributeMappingControllerTests.cs @@ -25,14 +25,16 @@ public class BaseProductAttributeMappingControllerTests : ProductControllerTests // can be invoked directly. public class TestProductAttributeMappingController( IProductViewModelService productViewModelService, + IProductAttributeMappingViewModelService productAttributeMappingViewModelService, IProductService productService, ILanguageService languageService, IRequestTranslationService translationService, IAdminDataScope scope) - : BaseProductAttributeMappingController(productViewModelService, productService, languageService, translationService, scope); + : BaseProductAttributeMappingController(productViewModelService, productAttributeMappingViewModelService, productService, languageService, translationService, scope); protected override TestProductAttributeMappingController CreateController() => new( _productViewModelServiceMock.Object, + _productAttributeMappingViewModelServiceMock.Object, _productServiceMock.Object, _languageServiceMock.Object, _translationServiceMock.Object, @@ -57,7 +59,7 @@ public async Task ProductAttributeMappingList_ScopeDeniesAccess_ReturnsErrorJson Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( + _productAttributeMappingViewModelServiceMock.Verify( s => s.PrepareProductAttributeMappingModels(It.IsAny()), Times.Never); } @@ -83,7 +85,7 @@ public async Task ProductAttributeMappingList_ScopeGrantsAccess_ReturnsGrid() _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); var attributes = new List { new() { Id = "pam1" } }; - _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModels(product)) + _productAttributeMappingViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModels(product)) .ReturnsAsync(attributes); var result = await _controller.ProductAttributeMappingList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); @@ -109,7 +111,7 @@ public async Task ProductAttributeMappingPopup_Get_ScopeDeniesAccess_ReturnsPerm var content = result as ContentResult; Assert.IsNotNull(content); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( + _productAttributeMappingViewModelServiceMock.Verify( s => s.PrepareProductAttributeMappingModel(It.IsAny()), Times.Never); } @@ -119,13 +121,13 @@ public async Task ProductAttributeMappingPopup_Get_ScopeGrantsAccess_NoMappingId var product = new Product { Id = "p1" }; _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModel(product)) + _productAttributeMappingViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModel(product)) .ReturnsAsync(new ProductModel.ProductAttributeMappingModel()); var result = await _controller.ProductAttributeMappingPopup("p1", null); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeMappingModel(product), Times.Once); + _productAttributeMappingViewModelServiceMock.Verify(s => s.PrepareProductAttributeMappingModel(product), Times.Once); } [TestMethod] @@ -136,13 +138,13 @@ public async Task ProductAttributeMappingPopup_Get_ScopeGrantsAccess_MappingId_P product.ProductAttributeMappings.Add(mapping); _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModel(product, mapping)) + _productAttributeMappingViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModel(product, mapping)) .ReturnsAsync(new ProductModel.ProductAttributeMappingModel()); var result = await _controller.ProductAttributeMappingPopup("p1", "pam1"); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeMappingModel(product, mapping), Times.Once); + _productAttributeMappingViewModelServiceMock.Verify(s => s.PrepareProductAttributeMappingModel(product, mapping), Times.Once); } // --- ProductAttributeMappingPopup (POST) ----------------------------------------------------- @@ -161,10 +163,10 @@ public async Task ProductAttributeMappingPopup_Post_ScopeDeniesAccess_DoesNotIns var content = result as ContentResult; Assert.IsNotNull(content); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( + _productAttributeMappingViewModelServiceMock.Verify( s => s.InsertProductAttributeMappingModel(It.IsAny()), Times.Never); - _productViewModelServiceMock.Verify( + _productAttributeMappingViewModelServiceMock.Verify( s => s.UpdateProductAttributeMappingModel(It.IsAny()), Times.Never); } @@ -180,8 +182,8 @@ public async Task ProductAttributeMappingPopup_Post_ScopeGrantsAccess_NoId_Inser var result = await _controller.ProductAttributeMappingPopup(model); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.InsertProductAttributeMappingModel(model), Times.Once); - _productViewModelServiceMock.Verify( + _productAttributeMappingViewModelServiceMock.Verify(s => s.InsertProductAttributeMappingModel(model), Times.Once); + _productAttributeMappingViewModelServiceMock.Verify( s => s.UpdateProductAttributeMappingModel(It.IsAny()), Times.Never); } @@ -197,7 +199,7 @@ public async Task ProductAttributeMappingPopup_Post_ScopeGrantsAccess_WithId_Upd var result = await _controller.ProductAttributeMappingPopup(model); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.UpdateProductAttributeMappingModel(model), Times.Once); + _productAttributeMappingViewModelServiceMock.Verify(s => s.UpdateProductAttributeMappingModel(model), Times.Once); } [TestMethod] @@ -214,7 +216,7 @@ public async Task ProductAttributeMappingPopup_Post_InvalidModelState_ReturnsVie { _controller.ModelState.AddModelError("x", "err"); var model = new ProductModel.ProductAttributeMappingModel { ProductId = "p1" }; - _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModel(model)).ReturnsAsync(model); + _productAttributeMappingViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModel(model)).ReturnsAsync(model); var result = await _controller.ProductAttributeMappingPopup(model); @@ -319,13 +321,13 @@ public async Task ProductAttributeValidationRulesPopup_Get_ScopeGrantsAccess_Pre product.ProductAttributeMappings.Add(mapping); _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModel(mapping)) + _productAttributeMappingViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModel(mapping)) .ReturnsAsync(new ProductModel.ProductAttributeMappingModel()); var result = await _controller.ProductAttributeValidationRulesPopup("pam1", "p1"); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeMappingModel(mapping), Times.Once); + _productAttributeMappingViewModelServiceMock.Verify(s => s.PrepareProductAttributeMappingModel(mapping), Times.Once); } // --- ProductAttributeValidationRulesPopup (POST) ---------------------------------------------- @@ -369,7 +371,7 @@ public async Task ProductAttributeValidationRulesPopup_Post_ScopeDeniesAccess_Re var content = result as ContentResult; Assert.IsNotNull(content); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( + _productAttributeMappingViewModelServiceMock.Verify( s => s.UpdateProductAttributeValidationRulesModel(It.IsAny(), It.IsAny()), Times.Never); } @@ -387,7 +389,7 @@ public async Task ProductAttributeValidationRulesPopup_Post_ScopeGrantsAccess_Va var result = await _controller.ProductAttributeValidationRulesPopup(model); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( + _productAttributeMappingViewModelServiceMock.Verify( s => s.UpdateProductAttributeValidationRulesModel(mapping, model), Times.Once); } @@ -401,12 +403,12 @@ public async Task ProductAttributeValidationRulesPopup_Post_ScopeGrantsAccess_In _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); _controller.ModelState.AddModelError("x", "err"); var model = new ProductModel.ProductAttributeMappingModel { Id = "pam1", ProductId = "p1" }; - _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModel(mapping)).ReturnsAsync(model); + _productAttributeMappingViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModel(mapping)).ReturnsAsync(model); var result = await _controller.ProductAttributeValidationRulesPopup(model); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( + _productAttributeMappingViewModelServiceMock.Verify( s => s.UpdateProductAttributeValidationRulesModel(It.IsAny(), It.IsAny()), Times.Never); } @@ -427,7 +429,7 @@ public async Task ProductAttributeConditionPopup_Get_ScopeDeniesAccess_ReturnsPe var content = result as ContentResult; Assert.IsNotNull(content); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( + _productAttributeMappingViewModelServiceMock.Verify( s => s.PrepareProductAttributeConditionModel(It.IsAny(), It.IsAny()), Times.Never); } @@ -459,7 +461,7 @@ public async Task ProductAttributeConditionPopup_Get_ScopeGrantsAccess_MappingMi var content = result as ContentResult; Assert.IsNotNull(content); Assert.AreEqual("No attribute value found with the specified id", content.Content); - _productViewModelServiceMock.Verify( + _productAttributeMappingViewModelServiceMock.Verify( s => s.PrepareProductAttributeConditionModel(It.IsAny(), It.IsAny()), Times.Never); } @@ -472,13 +474,13 @@ public async Task ProductAttributeConditionPopup_Get_ScopeGrantsAccess_PreparesM product.ProductAttributeMappings.Add(mapping); _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeConditionModel(product, mapping)) + _productAttributeMappingViewModelServiceMock.Setup(s => s.PrepareProductAttributeConditionModel(product, mapping)) .ReturnsAsync(new ProductAttributeConditionModel()); var result = await _controller.ProductAttributeConditionPopup("p1", "pam1"); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeConditionModel(product, mapping), + _productAttributeMappingViewModelServiceMock.Verify(s => s.PrepareProductAttributeConditionModel(product, mapping), Times.Once); } @@ -527,7 +529,7 @@ public async Task ProductAttributeConditionPopup_Post_ScopeDeniesAccess_ReturnsP var content = result as ContentResult; Assert.IsNotNull(content); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( + _productAttributeMappingViewModelServiceMock.Verify( s => s.UpdateProductAttributeConditionModel(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } @@ -545,7 +547,7 @@ public async Task ProductAttributeConditionPopup_Post_ScopeGrantsAccess_Updates( var result = await _controller.ProductAttributeConditionPopup(model); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.UpdateProductAttributeConditionModel(product, mapping, model), + _productAttributeMappingViewModelServiceMock.Verify(s => s.UpdateProductAttributeConditionModel(product, mapping, model), Times.Once); } @@ -626,7 +628,7 @@ public async Task ProductAttributeValueList_ScopeDeniesAccess_ReturnsErrorJson() Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( + _productAttributeMappingViewModelServiceMock.Verify( s => s.PrepareProductAttributeValueModels(It.IsAny(), It.IsAny()), Times.Never); } @@ -651,7 +653,7 @@ public async Task ProductAttributeValueList_ScopeGrantsAccess_ReturnsGrid() _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); var values = new List { new() { Id = "pav1" } }; - _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeValueModels(product, mapping)) + _productAttributeMappingViewModelServiceMock.Setup(s => s.PrepareProductAttributeValueModels(product, mapping)) .ReturnsAsync(values); var result = await _controller.ProductAttributeValueList("pam1", "p1", new Grand.Web.Common.DataSource.DataSourceRequest()); @@ -677,7 +679,7 @@ public async Task ProductAttributeValueCreatePopup_Get_ScopeDeniesAccess_Returns var content = result as ContentResult; Assert.IsNotNull(content); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( + _productAttributeMappingViewModelServiceMock.Verify( s => s.PrepareProductAttributeValueModel(It.IsAny(), It.IsAny()), Times.Never); } @@ -701,13 +703,13 @@ public async Task ProductAttributeValueCreatePopup_Get_ScopeGrantsAccess_Prepare product.ProductAttributeMappings.Add(mapping); _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeValueModel(product, mapping)) + _productAttributeMappingViewModelServiceMock.Setup(s => s.PrepareProductAttributeValueModel(product, mapping)) .ReturnsAsync(new ProductModel.ProductAttributeValueModel()); var result = await _controller.ProductAttributeValueCreatePopup("pam1", "p1"); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeValueModel(product, mapping), Times.Once); + _productAttributeMappingViewModelServiceMock.Verify(s => s.PrepareProductAttributeValueModel(product, mapping), Times.Once); } // --- ProductAttributeValueCreatePopup (POST) ---------------------------------------------------- @@ -741,7 +743,7 @@ public async Task ProductAttributeValueCreatePopup_Post_ScopeDeniesAccess_Return var content = result as ContentResult; Assert.IsNotNull(content); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( + _productAttributeMappingViewModelServiceMock.Verify( s => s.InsertProductAttributeValueModel(It.IsAny()), Times.Never); } @@ -764,7 +766,7 @@ public async Task ProductAttributeValueCreatePopup_Post_ScopeDeniesAccess_UsesVe Assert.IsNotNull(content); _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); - _productViewModelServiceMock.Verify( + _productAttributeMappingViewModelServiceMock.Verify( s => s.InsertProductAttributeValueModel(It.IsAny()), Times.Never); } @@ -798,7 +800,7 @@ public async Task ProductAttributeValueCreatePopup_Post_ScopeGrantsAccess_ValidM var result = await _controller.ProductAttributeValueCreatePopup(model); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.InsertProductAttributeValueModel(model), Times.Once); + _productAttributeMappingViewModelServiceMock.Verify(s => s.InsertProductAttributeValueModel(model), Times.Once); } [TestMethod] @@ -815,10 +817,10 @@ public async Task ProductAttributeValueCreatePopup_Post_ScopeGrantsAccess_Invali var result = await _controller.ProductAttributeValueCreatePopup(model); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( + _productAttributeMappingViewModelServiceMock.Verify( s => s.InsertProductAttributeValueModel(It.IsAny()), Times.Never); - _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeValueModel(product, model), Times.Once); + _productAttributeMappingViewModelServiceMock.Verify(s => s.PrepareProductAttributeValueModel(product, model), Times.Once); } // --- ProductAttributeValueEditPopup (GET) ------------------------------------------------------- @@ -878,14 +880,14 @@ public async Task ProductAttributeValueEditPopup_Get_ScopeGrantsAccess_PreparesM _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); var model = new ProductModel.ProductAttributeValueModel(); - _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeValueModel(mapping, pav)) + _productAttributeMappingViewModelServiceMock.Setup(s => s.PrepareProductAttributeValueModel(mapping, pav)) .ReturnsAsync(model); var result = await _controller.ProductAttributeValueEditPopup("pav1", "p1", "pam1"); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeValueModel(mapping, pav), Times.Once); - _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeValueModel(product, model), Times.Once); + _productAttributeMappingViewModelServiceMock.Verify(s => s.PrepareProductAttributeValueModel(mapping, pav), Times.Once); + _productAttributeMappingViewModelServiceMock.Verify(s => s.PrepareProductAttributeValueModel(product, model), Times.Once); } // --- ProductAttributeValueEditPopup (POST) ------------------------------------------------------ @@ -915,7 +917,7 @@ public async Task ProductAttributeValueEditPopup_Post_ScopeDeniesAccess_ReturnsP var content = result as ContentResult; Assert.IsNotNull(content); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( + _productAttributeMappingViewModelServiceMock.Verify( s => s.UpdateProductAttributeValueModel(It.IsAny(), It.IsAny()), Times.Never); } @@ -950,7 +952,7 @@ public async Task ProductAttributeValueEditPopup_Post_ScopeGrantsAccess_ValidMod var result = await _controller.ProductAttributeValueEditPopup("p1", model); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.UpdateProductAttributeValueModel(pav, model), Times.Once); + _productAttributeMappingViewModelServiceMock.Verify(s => s.UpdateProductAttributeValueModel(pav, model), Times.Once); } [TestMethod] @@ -969,10 +971,10 @@ public async Task ProductAttributeValueEditPopup_Post_ScopeGrantsAccess_InvalidM var result = await _controller.ProductAttributeValueEditPopup("p1", model); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( + _productAttributeMappingViewModelServiceMock.Verify( s => s.UpdateProductAttributeValueModel(It.IsAny(), It.IsAny()), Times.Never); - _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeValueModel(product, model), Times.Once); + _productAttributeMappingViewModelServiceMock.Verify(s => s.PrepareProductAttributeValueModel(product, model), Times.Once); } // --- ProductAttributeValueDelete ----------------------------------------------------------------- diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs index fed82023d..8c814be7f 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs @@ -28,6 +28,7 @@ public abstract class ProductControllerTestsBase where TController protected TController _controller; protected Mock _productServiceMock; protected Mock _productViewModelServiceMock; + protected Mock _productAttributeMappingViewModelServiceMock; protected Mock _productAssociatedViewModelServiceMock; protected Mock _productBundleViewModelServiceMock; protected Mock _productRelatedViewModelServiceMock; @@ -61,6 +62,7 @@ public void Setup() _productServiceMock = new Mock(); _productViewModelServiceMock = new Mock(); + _productAttributeMappingViewModelServiceMock = new Mock(); _productAssociatedViewModelServiceMock = new Mock(); _productBundleViewModelServiceMock = new Mock(); _productRelatedViewModelServiceMock = new Mock(); diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductAttributeMappingController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductAttributeMappingController.cs index 2515f4d78..8d21faab6 100644 --- a/src/Web/Grand.Web.Admin/Controllers/ProductAttributeMappingController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/ProductAttributeMappingController.cs @@ -16,8 +16,9 @@ namespace Grand.Web.Admin.Controllers; [AuthorizeMenu] public class ProductAttributeMappingController( IProductViewModelService productViewModelService, + IProductAttributeMappingViewModelService productAttributeMappingViewModelService, IProductService productService, ILanguageService languageService, IRequestTranslationService translationService, IAdminDataScope scope) - : BaseProductAttributeMappingController(productViewModelService, productService, languageService, translationService, scope); + : BaseProductAttributeMappingController(productViewModelService, productAttributeMappingViewModelService, productService, languageService, translationService, scope); diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAttributeMappingController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAttributeMappingController.cs index 852efd3ce..6901d959a 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAttributeMappingController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAttributeMappingController.cs @@ -20,6 +20,7 @@ namespace Grand.Web.AdminShared.Controllers; [AutoValidateAntiforgeryToken] public abstract class BaseProductAttributeMappingController( IProductViewModelService productViewModelService, + IProductAttributeMappingViewModelService productAttributeMappingViewModelService, IProductService productService, ILanguageService languageService, IRequestTranslationService translationService, @@ -45,7 +46,7 @@ public async Task ProductAttributeMappingList(DataSourceRequest c if (!await scope.CanView(product)) return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - var attributesModel = await productViewModelService.PrepareProductAttributeMappingModels(product); + var attributesModel = await productAttributeMappingViewModelService.PrepareProductAttributeMappingModels(product); var gridModel = new DataSourceResult { Data = attributesModel, Total = attributesModel.Count @@ -65,13 +66,13 @@ public async Task ProductAttributeMappingPopup(string productId, if (string.IsNullOrEmpty(productAttributeMappingId)) { - var model = await productViewModelService.PrepareProductAttributeMappingModel(product); + var model = await productAttributeMappingViewModelService.PrepareProductAttributeMappingModel(product); return View(model); } var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - var editModel = await productViewModelService.PrepareProductAttributeMappingModel(product, + var editModel = await productAttributeMappingViewModelService.PrepareProductAttributeMappingModel(product, productAttributeMapping); return View(editModel); } @@ -93,15 +94,15 @@ public async Task ProductAttributeMappingPopup(ProductModel.Produ return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); if (string.IsNullOrEmpty(model.Id)) - await productViewModelService.InsertProductAttributeMappingModel(model); + await productAttributeMappingViewModelService.InsertProductAttributeMappingModel(model); else - await productViewModelService.UpdateProductAttributeMappingModel(model); + await productAttributeMappingViewModelService.UpdateProductAttributeMappingModel(model); return Content(""); } Error(ModelState); - model = await productViewModelService.PrepareProductAttributeMappingModel(model); + model = await productAttributeMappingViewModelService.PrepareProductAttributeMappingModel(model); return View(model); } @@ -142,7 +143,7 @@ public async Task ProductAttributeValidationRulesPopup(string id, if (productAttributeMapping == null) return Content("No attribute value found with the specified id"); - var model = await productViewModelService.PrepareProductAttributeMappingModel(productAttributeMapping); + var model = await productAttributeMappingViewModelService.PrepareProductAttributeMappingModel(productAttributeMapping); return View(model); } @@ -167,12 +168,12 @@ public async Task ProductAttributeValidationRulesPopup( if (ModelState.IsValid) { - await productViewModelService.UpdateProductAttributeValidationRulesModel(productAttributeMapping, model); + await productAttributeMappingViewModelService.UpdateProductAttributeValidationRulesModel(productAttributeMapping, model); return Content(""); } Error(ModelState); - model = await productViewModelService.PrepareProductAttributeMappingModel(productAttributeMapping); + model = await productAttributeMappingViewModelService.PrepareProductAttributeMappingModel(productAttributeMapping); return View(model); } @@ -197,7 +198,7 @@ public async Task ProductAttributeConditionPopup(string productId //No attribute value found with the specified id return Content("No attribute value found with the specified id"); - var model = await productViewModelService.PrepareProductAttributeConditionModel(product, + var model = await productAttributeMappingViewModelService.PrepareProductAttributeConditionModel(product, productAttributeMapping); return View(model); } @@ -232,7 +233,7 @@ public async Task ProductAttributeConditionPopup(ProductAttribute if (!await scope.HasAccess(product)) return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - await productViewModelService.UpdateProductAttributeConditionModel(product, productAttributeMapping, model); + await productAttributeMappingViewModelService.UpdateProductAttributeConditionModel(product, productAttributeMapping, model); return Content(""); } @@ -289,7 +290,7 @@ public async Task ProductAttributeValueList(string productAttribu throw new ArgumentException("No product attribute mapping found with the specified id"); var values = - await productViewModelService.PrepareProductAttributeValueModels(product, productAttributeMapping); + await productAttributeMappingViewModelService.PrepareProductAttributeValueModels(product, productAttributeMapping); var gridModel = new DataSourceResult { Data = values, Total = values.Count @@ -317,7 +318,7 @@ public async Task ProductAttributeValueCreatePopup(string product throw new ArgumentException("No product attribute mapping found with the specified id"); var model = - await productViewModelService.PrepareProductAttributeValueModel(product, productAttributeMapping); + await productAttributeMappingViewModelService.PrepareProductAttributeValueModel(product, productAttributeMapping); //locales await AddLocales(languageService, model.Locales); @@ -358,12 +359,12 @@ public async Task ProductAttributeValueCreatePopup(ProductModel.P if (ModelState.IsValid) { - await productViewModelService.InsertProductAttributeValueModel(model); + await productAttributeMappingViewModelService.InsertProductAttributeValueModel(model); return Content(""); } //If we got this far, something failed, redisplay form - await productViewModelService.PrepareProductAttributeValueModel(product, model); + await productAttributeMappingViewModelService.PrepareProductAttributeValueModel(product, model); return View(model); } @@ -387,14 +388,14 @@ public async Task ProductAttributeValueEditPopup(string id, strin //No attribute value found with the specified id return RedirectToAction("List", "Product"); - var model = await productViewModelService.PrepareProductAttributeValueModel(pa, pav); + var model = await productAttributeMappingViewModelService.PrepareProductAttributeValueModel(pa, pav); //locales await AddLocales(languageService, model.Locales, (locale, languageId) => { locale.Name = pav.GetTranslation(x => x.Name, languageId, false); }); //pictures - await productViewModelService.PrepareProductAttributeValueModel(product, model); + await productAttributeMappingViewModelService.PrepareProductAttributeValueModel(product, model); return View(model); } @@ -424,12 +425,12 @@ public async Task ProductAttributeValueEditPopup(string productId if (ModelState.IsValid) { - await productViewModelService.UpdateProductAttributeValueModel(pav, model); + await productAttributeMappingViewModelService.UpdateProductAttributeValueModel(pav, model); return Content(""); } //If we got this far, something failed, redisplay form - await productViewModelService.PrepareProductAttributeValueModel(product, model); + await productAttributeMappingViewModelService.PrepareProductAttributeValueModel(product, model); return View(model); } diff --git a/src/Web/Grand.Web.AdminShared/Interfaces/IProductAttributeMappingViewModelService.cs b/src/Web/Grand.Web.AdminShared/Interfaces/IProductAttributeMappingViewModelService.cs new file mode 100644 index 000000000..c0d82d59d --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Interfaces/IProductAttributeMappingViewModelService.cs @@ -0,0 +1,48 @@ +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Models.Catalog; + +namespace Grand.Web.AdminShared.Interfaces; + +public interface IProductAttributeMappingViewModelService +{ + Task PrepareProductAttributeValueModel(Product product, ProductModel.ProductAttributeValueModel model); + + Task PrepareProductAttributeMappingModel(Product product); + + Task PrepareProductAttributeMappingModel( + ProductModel.ProductAttributeMappingModel model); + + Task PrepareProductAttributeMappingModel(Product product, + ProductAttributeMapping productAttributeMapping); + + Task> PrepareProductAttributeMappingModels(Product product); + + Task InsertProductAttributeMappingModel(ProductModel.ProductAttributeMappingModel model); + + Task UpdateProductAttributeMappingModel(ProductModel.ProductAttributeMappingModel model); + + Task PrepareProductAttributeMappingModel( + ProductAttributeMapping productAttributeMapping); + + Task UpdateProductAttributeValidationRulesModel(ProductAttributeMapping productAttributeMapping, + ProductModel.ProductAttributeMappingModel model); + + Task PrepareProductAttributeConditionModel(Product product, + ProductAttributeMapping productAttributeMapping); + + Task UpdateProductAttributeConditionModel(Product product, ProductAttributeMapping productAttributeMapping, + ProductAttributeConditionModel model); + + Task PrepareProductAttributeValueModel(Product product, + ProductAttributeMapping productAttributeMapping); + + Task> PrepareProductAttributeValueModels(Product product, + ProductAttributeMapping productAttributeMapping); + + Task PrepareProductAttributeValueModel(ProductAttributeMapping pa, + ProductAttributeValue pav); + + Task InsertProductAttributeValueModel(ProductModel.ProductAttributeValueModel model); + + Task UpdateProductAttributeValueModel(ProductAttributeValue pav, ProductModel.ProductAttributeValueModel model); +} diff --git a/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs b/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs index 0cf77c3ab..2c34ebca1 100644 --- a/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs @@ -19,7 +19,6 @@ Task OutOfStockNotifications(Product product, ProductAttributeCombination combin Task PrepareAddProductAttributeCombinationModel(ProductAttributeCombinationModel model, Product product); Task SaveProductWarehouseInventory(Product product, IList model); - Task PrepareProductAttributeValueModel(Product product, ProductModel.ProductAttributeValueModel model); Task PrepareProductListModel(); Task<(IEnumerable productModels, int totalCount)> PrepareProductsModel(ProductListModel model, @@ -52,42 +51,6 @@ Task OutOfStockNotifications(Product product, ProductAttributeCombination combin Task DeleteBulkEdit(IEnumerable products); - Task PrepareProductAttributeMappingModel(Product product); - - Task PrepareProductAttributeMappingModel( - ProductModel.ProductAttributeMappingModel model); - - Task PrepareProductAttributeMappingModel(Product product, - ProductAttributeMapping productAttributeMapping); - - Task> PrepareProductAttributeMappingModels(Product product); - Task InsertProductAttributeMappingModel(ProductModel.ProductAttributeMappingModel model); - Task UpdateProductAttributeMappingModel(ProductModel.ProductAttributeMappingModel model); - - Task PrepareProductAttributeMappingModel( - ProductAttributeMapping productAttributeMapping); - - Task UpdateProductAttributeValidationRulesModel(ProductAttributeMapping productAttributeMapping, - ProductModel.ProductAttributeMappingModel model); - - Task PrepareProductAttributeConditionModel(Product product, - ProductAttributeMapping productAttributeMapping); - - Task UpdateProductAttributeConditionModel(Product product, ProductAttributeMapping productAttributeMapping, - ProductAttributeConditionModel model); - - Task PrepareProductAttributeValueModel(Product product, - ProductAttributeMapping productAttributeMapping); - - Task> PrepareProductAttributeValueModels(Product product, - ProductAttributeMapping productAttributeMapping); - - Task PrepareProductAttributeValueModel(ProductAttributeMapping pa, - ProductAttributeValue pav); - - Task InsertProductAttributeValueModel(ProductModel.ProductAttributeValueModel model); - Task UpdateProductAttributeValueModel(ProductAttributeValue pav, ProductModel.ProductAttributeValueModel model); - Task PrepareAssociateProductToAttributeValueModel(); diff --git a/src/Web/Grand.Web.AdminShared/Services/ProductAttributeMappingViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/ProductAttributeMappingViewModelService.cs new file mode 100644 index 000000000..d37fa9e7c --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Services/ProductAttributeMappingViewModelService.cs @@ -0,0 +1,559 @@ +using Grand.Business.Core.Extensions; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Catalog.Tax; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Storage; +using Grand.Domain.Catalog; +using Grand.Domain.Common; +using Grand.Domain.Directory; +using Grand.Domain.Localization; +using Grand.Infrastructure; +using Grand.SharedKernel.Extensions; +using Grand.Web.AdminShared.Extensions.Mapping; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.Common.Extensions; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc.Rendering; +using System.Net; +using ProductExtensions = Grand.Domain.Catalog.ProductExtensions; + +namespace Grand.Web.AdminShared.Services; + +public class ProductAttributeMappingViewModelService( + IProductService productService, + IPictureService pictureService, + IProductAttributeService productAttributeService, + ICurrencyService currencyService, + IRequestTranslationService translationService, + IContextAccessor contextAccessor, + ILanguageService languageService, + CurrencySettings currencySettings, + IEnumTranslationService enumTranslationService) + : IProductAttributeMappingViewModelService +{ + public virtual async Task PrepareProductAttributeValueModel(Product product, + ProductModel.ProductAttributeValueModel model) + { + //pictures + foreach (var x in product.ProductPictures.OrderBy(x => x.DisplayOrder)) + model.ProductPictureModels.Add(new ProductModel.ProductPictureModel { + Id = x.Id, + ProductId = product.Id, + PictureId = x.PictureId, + PictureUrl = await pictureService.GetPictureUrl(x.PictureId), + DisplayOrder = x.DisplayOrder, + IsDefault = x.IsDefault + }); + + var associatedProduct = await productService.GetProductById(model.AssociatedProductId); + model.AssociatedProductName = associatedProduct != null ? associatedProduct.Name : ""; + } + + public virtual async Task PrepareProductAttributeMappingModel( + Product product) + { + var model = new ProductModel.ProductAttributeMappingModel { + ProductId = product.Id + }; + foreach (var attribute in await productAttributeService.GetAllProductAttributes(contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + model.AvailableProductAttribute.Add(new SelectListItem { + Value = attribute.Id, + Text = attribute.Name + }); + return model; + } + + public virtual async Task PrepareProductAttributeMappingModel( + Product product, ProductAttributeMapping productAttributeMapping) + { + var model = productAttributeMapping.ToModel(); + foreach (var attribute in await productAttributeService.GetAllProductAttributes(contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + model.AvailableProductAttribute.Add(new SelectListItem { + Value = attribute.Id, + Text = attribute.Name, + Selected = attribute.Id == model.ProductAttributeId + }); + return model; + } + + public virtual async Task PrepareProductAttributeMappingModel( + ProductModel.ProductAttributeMappingModel model) + { + foreach (var attribute in await productAttributeService.GetAllProductAttributes(contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + model.AvailableProductAttribute.Add(new SelectListItem { + Value = attribute.Id, + Text = attribute.Name + }); + return model; + } + + public virtual async Task> PrepareProductAttributeMappingModels( + Product product) + { + var items = new List(); + foreach (var x in product.ProductAttributeMappings.OrderBy(x => x.DisplayOrder)) + { + var attributeModel = new ProductModel.ProductAttributeMappingModel { + Id = x.Id, + ProductId = product.Id, + ProductAttribute = (await productAttributeService.GetProductAttributeById(x.ProductAttributeId))?.Name, + ProductAttributeId = x.ProductAttributeId, + TextPrompt = x.TextPrompt, + IsRequired = x.IsRequired, + ShowOnCatalogPage = x.ShowOnCatalogPage, + AttributeControlType = enumTranslationService.GetTranslationEnum(x.AttributeControlTypeId), + AttributeControlTypeId = x.AttributeControlTypeId, + DisplayOrder = x.DisplayOrder, + Combination = x.Combination + }; + + + if (x.ShouldHaveValues()) + { + attributeModel.ShouldHaveValues = true; + attributeModel.TotalValues = x.ProductAttributeValues.Count; + } + + if (x.ValidationRulesAllowed()) + { + var validationRules = new StringBuilder(string.Empty); + attributeModel.ValidationRulesAllowed = true; + if (x.ValidationMinLength != null) + validationRules.AppendFormat("{0}: {1}
", + translationService.GetResource( + "Admin.Catalog.Products.ProductAttributes.Attributes.ValidationRules.MinLength"), + x.ValidationMinLength); + if (x.ValidationMaxLength != null) + validationRules.AppendFormat("{0}: {1}
", + translationService.GetResource( + "Admin.Catalog.Products.ProductAttributes.Attributes.ValidationRules.MaxLength"), + x.ValidationMaxLength); + if (!string.IsNullOrEmpty(x.ValidationFileAllowedExtensions)) + validationRules.AppendFormat("{0}: {1}
", + translationService.GetResource( + "Admin.Catalog.Products.ProductAttributes.Attributes.ValidationRules.FileAllowedExtensions"), + WebUtility.HtmlEncode(x.ValidationFileAllowedExtensions)); + if (x.ValidationFileMaximumSize != null) + validationRules.AppendFormat("{0}: {1}
", + translationService.GetResource( + "Admin.Catalog.Products.ProductAttributes.Attributes.ValidationRules.FileMaximumSize"), + x.ValidationFileMaximumSize); + if (!string.IsNullOrEmpty(x.DefaultValue)) + validationRules.AppendFormat("{0}: {1}
", + translationService.GetResource( + "Admin.Catalog.Products.ProductAttributes.Attributes.ValidationRules.DefaultValue"), + WebUtility.HtmlEncode(x.DefaultValue)); + attributeModel.ValidationRulesString = validationRules.ToString(); + } + + //currenty any attribute can have condition. why not? + attributeModel.ConditionAllowed = true; + var conditionAttribute = product.ParseProductAttributeMappings(x.ConditionAttribute).FirstOrDefault(); + var conditionValue = product.ParseProductAttributeValues(x.ConditionAttribute).FirstOrDefault(); + if (conditionAttribute != null && conditionValue != null) + { + var productAttribute = + await productAttributeService.GetProductAttributeById(conditionAttribute.ProductAttributeId); + var _paname = productAttribute != null ? productAttribute.Name : ""; + attributeModel.ConditionString = + $"{WebUtility.HtmlEncode(_paname)}: {WebUtility.HtmlEncode(conditionValue.Name)}"; + } + else + { + attributeModel.ConditionString = string.Empty; + } + + items.Add(attributeModel); + } + + return items; + } + + public virtual async Task InsertProductAttributeMappingModel(ProductModel.ProductAttributeMappingModel model) + { + //insert mapping + var productAttributeMapping = model.ToEntity(); + //predefined values + var predefinedValues = (await productAttributeService.GetProductAttributeById(model.ProductAttributeId)) + .PredefinedProductAttributeValues; + foreach (var predefinedValue in predefinedValues) + { + var pav = predefinedValue.ToEntity(); + //locales + pav.Locales.Clear(); + var languages = await languageService.GetAllLanguages(true); + //localization + foreach (var lang in languages) + { + var name = predefinedValue.GetTranslation(x => x.Name, lang.Id, false); + if (!string.IsNullOrEmpty(name)) + pav.Locales.Add(new TranslationEntity { LanguageId = lang.Id, LocaleKey = "Name", LocaleValue = name }); + } + + productAttributeMapping.ProductAttributeValues.Add(pav); + } + + await productAttributeService.InsertProductAttributeMapping(productAttributeMapping, model.ProductId); + } + + public virtual async Task UpdateProductAttributeMappingModel(ProductModel.ProductAttributeMappingModel model) + { + var product = await productService.GetProductById(model.ProductId, true); + if (product != null) + { + var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.Id); + if (productAttributeMapping != null) + { + productAttributeMapping = model.ToEntity(productAttributeMapping); + await productAttributeService.UpdateProductAttributeMapping(productAttributeMapping, model.ProductId); + } + } + } + + public virtual async Task PrepareProductAttributeMappingModel( + ProductAttributeMapping productAttributeMapping) + { + var model = new ProductModel.ProductAttributeMappingModel { + //prepare only used properties + Id = productAttributeMapping.Id, + ValidationRulesAllowed = productAttributeMapping.ValidationRulesAllowed(), + AttributeControlTypeId = productAttributeMapping.AttributeControlTypeId, + ValidationMinLength = productAttributeMapping.ValidationMinLength, + ValidationMaxLength = productAttributeMapping.ValidationMaxLength, + ValidationFileAllowedExtensions = productAttributeMapping.ValidationFileAllowedExtensions, + ValidationFileMaximumSize = productAttributeMapping.ValidationFileMaximumSize, + DefaultValue = productAttributeMapping.DefaultValue + }; + return await Task.FromResult(model); + } + + public virtual async Task UpdateProductAttributeValidationRulesModel( + ProductAttributeMapping productAttributeMapping, ProductModel.ProductAttributeMappingModel model) + { + productAttributeMapping.ValidationMinLength = model.ValidationMinLength; + productAttributeMapping.ValidationMaxLength = model.ValidationMaxLength; + productAttributeMapping.ValidationFileAllowedExtensions = model.ValidationFileAllowedExtensions; + productAttributeMapping.ValidationFileMaximumSize = model.ValidationFileMaximumSize; + productAttributeMapping.DefaultValue = model.DefaultValue; + await productAttributeService.UpdateProductAttributeMapping(productAttributeMapping, model.ProductId); + } + + public virtual async Task PrepareProductAttributeConditionModel(Product product, + ProductAttributeMapping productAttributeMapping) + { + var model = new ProductAttributeConditionModel { + ProductAttributeMappingId = productAttributeMapping.Id, + EnableCondition = productAttributeMapping.ConditionAttribute.Any(), + ProductId = product.Id + }; + //pre-select attribute and values + var selectedPva = product.ParseProductAttributeMappings(productAttributeMapping.ConditionAttribute) + .FirstOrDefault(); + + var attributes = product.ProductAttributeMappings + //ignore non-combinable attributes (should have selectable values) + .Where(x => x.CanBeUsedAsCondition()) + //ignore this attribute (it cannot depend on itself) + .Where(x => x.Id != productAttributeMapping.Id) + .ToList(); + foreach (var attribute in attributes) + { + var pam = await productAttributeService.GetProductAttributeById(attribute.ProductAttributeId); + var attributeModel = new ProductAttributeConditionModel.ProductAttributeModel { + Id = attribute.Id, + ProductAttributeId = attribute.ProductAttributeId, + Name = pam.Name, + TextPrompt = attribute.TextPrompt, + IsRequired = attribute.IsRequired, + AttributeControlType = attribute.AttributeControlTypeId + }; + + if (attribute.ShouldHaveValues()) + { + //values + var attributeValues = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == attribute.Id) + ?.ProductAttributeValues; + if (attributeValues != null) + foreach (var attributeValue in attributeValues) + { + var attributeValueModel = new ProductAttributeConditionModel.ProductAttributeValueModel { + Id = attributeValue.Id, + Name = attributeValue.Name, + IsPreSelected = attributeValue.IsPreSelected + }; + attributeModel.Values.Add(attributeValueModel); + } + + //pre-select attribute and value + if (selectedPva != null && attribute.Id == selectedPva.Id) + { + //attribute + model.SelectedProductAttributeId = selectedPva.Id; + + //values + switch (attribute.AttributeControlTypeId) + { + case AttributeControlType.DropdownList: + case AttributeControlType.RadioList: + case AttributeControlType.Checkboxes: + case AttributeControlType.ColorSquares: + case AttributeControlType.ImageSquares: + { + if (productAttributeMapping.ConditionAttribute.Any()) + { + //clear default selection + foreach (var item in attributeModel.Values) + item.IsPreSelected = false; + + //select new values + var selectedValues = + product.ParseProductAttributeValues(productAttributeMapping.ConditionAttribute); + foreach (var attributeValue in selectedValues) + foreach (var item in attributeModel.Values) + if (attributeValue.Id == item.Id) + item.IsPreSelected = true; + } + } + break; + case AttributeControlType.ReadonlyCheckboxes: + case AttributeControlType.TextBox: + case AttributeControlType.MultilineTextbox: + case AttributeControlType.Datepicker: + case AttributeControlType.FileUpload: + default: + //these attribute types are supported as conditions + break; + } + } + } + + model.ProductAttributes.Add(attributeModel); + } + + return model; + } + + public virtual async Task UpdateProductAttributeConditionModel(Product product, + ProductAttributeMapping productAttributeMapping, ProductAttributeConditionModel model) + { + var customAttributes = new List(); + if (model.EnableCondition) + { + var attribute = + product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.SelectedProductAttributeId); + if (attribute != null) + switch (attribute.AttributeControlTypeId) + { + case AttributeControlType.DropdownList: + case AttributeControlType.RadioList: + case AttributeControlType.ColorSquares: + case AttributeControlType.ImageSquares: + { + var ctrlAttributes = model.SelectedAttributes.FirstOrDefault(x => x.Key == attribute.Id)?.Value; + if (!string.IsNullOrEmpty(ctrlAttributes)) + customAttributes = ProductExtensions.AddProductAttribute(customAttributes, + attribute, ctrlAttributes).ToList(); + else + customAttributes = ProductExtensions.AddProductAttribute(customAttributes, + attribute, "").ToList(); + } + break; + case AttributeControlType.Checkboxes: + { + var cblAttributes = model.SelectedAttributes.FirstOrDefault(x => x.Key == attribute.Id)?.Value; + if (!string.IsNullOrEmpty(cblAttributes)) + { + var anyValueSelected = false; + foreach (var item in cblAttributes.Split([','], + StringSplitOptions.RemoveEmptyEntries)) + if (!string.IsNullOrEmpty(item)) + { + customAttributes = ProductExtensions.AddProductAttribute(customAttributes, + attribute, item).ToList(); + anyValueSelected = true; + } + + if (!anyValueSelected) + customAttributes = ProductExtensions.AddProductAttribute(customAttributes, + attribute, "").ToList(); + } + else + { + customAttributes = ProductExtensions.AddProductAttribute(customAttributes, + attribute, "").ToList(); + } + } + break; + case AttributeControlType.ReadonlyCheckboxes: + case AttributeControlType.TextBox: + case AttributeControlType.MultilineTextbox: + case AttributeControlType.Datepicker: + case AttributeControlType.FileUpload: + default: + //these attribute types are supported as conditions + break; + } + } + + productAttributeMapping.ConditionAttribute = customAttributes; + await productAttributeService.UpdateProductAttributeMapping(productAttributeMapping, model.ProductId); + } + + public virtual async Task PrepareProductAttributeValueModel( + Product product, ProductAttributeMapping productAttributeMapping) + { + var model = new ProductModel.ProductAttributeValueModel { + ProductAttributeMappingId = productAttributeMapping.Id, + ProductId = product.Id, + + //color squares + DisplayColorSquaresRgb = + productAttributeMapping.AttributeControlTypeId == AttributeControlType.ColorSquares, + ColorSquaresRgb = "#000000", + //image squares + DisplayImageSquaresPicture = + productAttributeMapping.AttributeControlTypeId == AttributeControlType.ImageSquares, + PrimaryStoreCurrencyCode = (await currencyService.GetCurrencyById(currencySettings.PrimaryStoreCurrencyId)) + ?.CurrencyCode, + //default qantity for associated product + Quantity = 1 + }; + + //pictures + foreach (var x in product.ProductPictures) + model.ProductPictureModels.Add(new ProductModel.ProductPictureModel { + Id = x.Id, + ProductId = product.Id, + PictureId = x.PictureId, + PictureUrl = await pictureService.GetPictureUrl(x.PictureId), + DisplayOrder = x.DisplayOrder, + IsDefault = x.IsDefault + }); + return model; + } + + public virtual async Task> PrepareProductAttributeValueModels( + Product product, ProductAttributeMapping productAttributeMapping) + { + var items = new List(); + foreach (var x in productAttributeMapping.ProductAttributeValues.OrderBy(x => x.DisplayOrder)) + { + Product associatedProduct = null; + if (x.AttributeValueTypeId == AttributeValueType.AssociatedToProduct) + associatedProduct = await productService.GetProductById(x.AssociatedProductId); + + var pictureThumbnailUrl = + await pictureService.GetPictureUrl( + string.IsNullOrEmpty(x.PictureId) ? x.ImageSquaresPictureId : x.PictureId, 100, false); + + if (string.IsNullOrEmpty(pictureThumbnailUrl)) + pictureThumbnailUrl = await pictureService.GetPictureUrl("", 1); + + items.Add(new ProductModel.ProductAttributeValueModel { + Id = x.Id, + ProductAttributeMappingId = productAttributeMapping.Id, //TODO - check x.ProductAttributeMappingId, + AttributeValueTypeId = x.AttributeValueTypeId, + AttributeValueTypeName = enumTranslationService.GetTranslationEnum(x.AttributeValueTypeId), + AssociatedProductId = x.AssociatedProductId, + AssociatedProductName = associatedProduct != null ? associatedProduct.Name : "", + Name = productAttributeMapping.AttributeControlTypeId != AttributeControlType.ColorSquares + ? x.Name + : $"{x.Name} - {x.ColorSquaresRgb}", + ColorSquaresRgb = x.ColorSquaresRgb, + ImageSquaresPictureId = x.ImageSquaresPictureId, + PriceAdjustment = x.PriceAdjustment, + PriceAdjustmentStr = x.PriceAdjustment.ToString("G29"), + WeightAdjustment = x.WeightAdjustment, + WeightAdjustmentStr = x.AttributeValueTypeId == AttributeValueType.Simple + ? x.WeightAdjustment.ToString("G29") + : "", + Cost = x.Cost, + PrimaryStoreCurrencyCode = + (await currencyService.GetCurrencyById(currencySettings.PrimaryStoreCurrencyId))?.CurrencyCode, + Quantity = x.Quantity, + IsPreSelected = x.IsPreSelected, + DisplayOrder = x.DisplayOrder, + PictureId = x.PictureId, + PictureThumbnailUrl = pictureThumbnailUrl, + ProductId = product.Id + }); + } + + return items; + } + + public virtual async Task PrepareProductAttributeValueModel( + ProductAttributeMapping pa, ProductAttributeValue pav) + { + var associatedProduct = await productService.GetProductById(pav.AssociatedProductId); + + var model = new ProductModel.ProductAttributeValueModel { + ProductAttributeMappingId = pa.Id, //TODO - check pav.ProductAttributeMappingId, + AttributeValueTypeId = pav.AttributeValueTypeId, + AttributeValueTypeName = enumTranslationService.GetTranslationEnum(pav.AttributeValueTypeId), + AssociatedProductId = pav.AssociatedProductId, + AssociatedProductName = associatedProduct != null ? associatedProduct.Name : "", + Name = pav.Name, + ColorSquaresRgb = pav.ColorSquaresRgb, + DisplayColorSquaresRgb = pa.AttributeControlTypeId == AttributeControlType.ColorSquares, + ImageSquaresPictureId = pav.ImageSquaresPictureId, + DisplayImageSquaresPicture = pa.AttributeControlTypeId == AttributeControlType.ImageSquares, + PriceAdjustment = pav.PriceAdjustment, + WeightAdjustment = pav.WeightAdjustment, + Cost = pav.Cost, + PrimaryStoreCurrencyCode = (await currencyService.GetCurrencyById(currencySettings.PrimaryStoreCurrencyId)) + ?.CurrencyCode, + Quantity = pav.Quantity, + IsPreSelected = pav.IsPreSelected, + DisplayOrder = pav.DisplayOrder, + PictureId = pav.PictureId + }; + if (model.DisplayColorSquaresRgb && string.IsNullOrEmpty(model.ColorSquaresRgb)) + model.ColorSquaresRgb = "#000000"; + return model; + } + + public virtual async Task InsertProductAttributeValueModel(ProductModel.ProductAttributeValueModel model) + { + var pav = new ProductAttributeValue { + AttributeValueTypeId = model.AttributeValueTypeId, + AssociatedProductId = model.AssociatedProductId, + Name = model.Name, + ColorSquaresRgb = model.ColorSquaresRgb, + ImageSquaresPictureId = model.ImageSquaresPictureId, + PriceAdjustment = model.PriceAdjustment, + WeightAdjustment = model.WeightAdjustment, + Cost = model.Cost, + Quantity = model.Quantity, + IsPreSelected = model.IsPreSelected, + DisplayOrder = model.DisplayOrder, + PictureId = model.PictureId, + Locales = model.Locales.ToTranslationProperty() + }; + await productAttributeService.InsertProductAttributeValue(pav, model.ProductId, + model.ProductAttributeMappingId); + } + + public virtual async Task UpdateProductAttributeValueModel(ProductAttributeValue pav, + ProductModel.ProductAttributeValueModel model) + { + pav.AttributeValueTypeId = model.AttributeValueTypeId; + pav.AssociatedProductId = model.AssociatedProductId; + pav.Name = model.Name; + pav.ColorSquaresRgb = model.ColorSquaresRgb; + pav.ImageSquaresPictureId = model.ImageSquaresPictureId; + pav.PriceAdjustment = model.PriceAdjustment; + pav.WeightAdjustment = model.WeightAdjustment; + pav.Cost = model.Cost; + pav.Quantity = model.Quantity; + pav.IsPreSelected = model.IsPreSelected; + pav.DisplayOrder = model.DisplayOrder; + pav.PictureId = model.PictureId; + pav.Locales = model.Locales.ToTranslationProperty(); + + await productAttributeService.UpdateProductAttributeValue(pav, model.ProductId, + model.ProductAttributeMappingId); + } +} diff --git a/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs index c8d3db558..1f5d7f683 100644 --- a/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs @@ -143,24 +143,6 @@ public virtual async Task PrepareAddProductAttributeCombinationModel(ProductAttr (await currencyService.GetCurrencyById(currencySettings.PrimaryStoreCurrencyId))?.CurrencyCode; } - public virtual async Task PrepareProductAttributeValueModel(Product product, - ProductModel.ProductAttributeValueModel model) - { - //pictures - foreach (var x in product.ProductPictures.OrderBy(x => x.DisplayOrder)) - model.ProductPictureModels.Add(new ProductModel.ProductPictureModel { - Id = x.Id, - ProductId = product.Id, - PictureId = x.PictureId, - PictureUrl = await pictureService.GetPictureUrl(x.PictureId), - DisplayOrder = x.DisplayOrder, - IsDefault = x.IsDefault - }); - - var associatedProduct = await productService.GetProductById(model.AssociatedProductId); - model.AssociatedProductName = associatedProduct != null ? associatedProduct.Name : ""; - } - public virtual async Task OutOfStockNotifications(Product product, int prevStockQuantity, List prevMultiWarehouseStock ) @@ -1059,512 +1041,6 @@ public virtual async Task DeleteBulkEdit(IEnumerable produ } } - public virtual async Task PrepareProductAttributeMappingModel( - Product product) - { - var model = new ProductModel.ProductAttributeMappingModel { - ProductId = product.Id - }; - foreach (var attribute in await productAttributeService.GetAllProductAttributes(contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) - model.AvailableProductAttribute.Add(new SelectListItem { - Value = attribute.Id, - Text = attribute.Name - }); - return model; - } - - public virtual async Task PrepareProductAttributeMappingModel( - Product product, ProductAttributeMapping productAttributeMapping) - { - var model = productAttributeMapping.ToModel(); - foreach (var attribute in await productAttributeService.GetAllProductAttributes(contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) - model.AvailableProductAttribute.Add(new SelectListItem { - Value = attribute.Id, - Text = attribute.Name, - Selected = attribute.Id == model.ProductAttributeId - }); - return model; - } - - public virtual async Task PrepareProductAttributeMappingModel( - ProductModel.ProductAttributeMappingModel model) - { - foreach (var attribute in await productAttributeService.GetAllProductAttributes(contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) - model.AvailableProductAttribute.Add(new SelectListItem { - Value = attribute.Id, - Text = attribute.Name - }); - return model; - } - - public virtual async Task> PrepareProductAttributeMappingModels( - Product product) - { - var items = new List(); - foreach (var x in product.ProductAttributeMappings.OrderBy(x => x.DisplayOrder)) - { - var attributeModel = new ProductModel.ProductAttributeMappingModel { - Id = x.Id, - ProductId = product.Id, - ProductAttribute = (await productAttributeService.GetProductAttributeById(x.ProductAttributeId))?.Name, - ProductAttributeId = x.ProductAttributeId, - TextPrompt = x.TextPrompt, - IsRequired = x.IsRequired, - ShowOnCatalogPage = x.ShowOnCatalogPage, - AttributeControlType = enumTranslationService.GetTranslationEnum(x.AttributeControlTypeId), - AttributeControlTypeId = x.AttributeControlTypeId, - DisplayOrder = x.DisplayOrder, - Combination = x.Combination - }; - - - if (x.ShouldHaveValues()) - { - attributeModel.ShouldHaveValues = true; - attributeModel.TotalValues = x.ProductAttributeValues.Count; - } - - if (x.ValidationRulesAllowed()) - { - var validationRules = new StringBuilder(string.Empty); - attributeModel.ValidationRulesAllowed = true; - if (x.ValidationMinLength != null) - validationRules.AppendFormat("{0}: {1}
", - translationService.GetResource( - "Admin.Catalog.Products.ProductAttributes.Attributes.ValidationRules.MinLength"), - x.ValidationMinLength); - if (x.ValidationMaxLength != null) - validationRules.AppendFormat("{0}: {1}
", - translationService.GetResource( - "Admin.Catalog.Products.ProductAttributes.Attributes.ValidationRules.MaxLength"), - x.ValidationMaxLength); - if (!string.IsNullOrEmpty(x.ValidationFileAllowedExtensions)) - validationRules.AppendFormat("{0}: {1}
", - translationService.GetResource( - "Admin.Catalog.Products.ProductAttributes.Attributes.ValidationRules.FileAllowedExtensions"), - WebUtility.HtmlEncode(x.ValidationFileAllowedExtensions)); - if (x.ValidationFileMaximumSize != null) - validationRules.AppendFormat("{0}: {1}
", - translationService.GetResource( - "Admin.Catalog.Products.ProductAttributes.Attributes.ValidationRules.FileMaximumSize"), - x.ValidationFileMaximumSize); - if (!string.IsNullOrEmpty(x.DefaultValue)) - validationRules.AppendFormat("{0}: {1}
", - translationService.GetResource( - "Admin.Catalog.Products.ProductAttributes.Attributes.ValidationRules.DefaultValue"), - WebUtility.HtmlEncode(x.DefaultValue)); - attributeModel.ValidationRulesString = validationRules.ToString(); - } - - //currenty any attribute can have condition. why not? - attributeModel.ConditionAllowed = true; - var conditionAttribute = product.ParseProductAttributeMappings(x.ConditionAttribute).FirstOrDefault(); - var conditionValue = product.ParseProductAttributeValues(x.ConditionAttribute).FirstOrDefault(); - if (conditionAttribute != null && conditionValue != null) - { - var productAttribute = - await productAttributeService.GetProductAttributeById(conditionAttribute.ProductAttributeId); - var _paname = productAttribute != null ? productAttribute.Name : ""; - attributeModel.ConditionString = - $"{WebUtility.HtmlEncode(_paname)}: {WebUtility.HtmlEncode(conditionValue.Name)}"; - } - else - { - attributeModel.ConditionString = string.Empty; - } - - items.Add(attributeModel); - } - - return items; - } - - public virtual async Task InsertProductAttributeMappingModel(ProductModel.ProductAttributeMappingModel model) - { - //insert mapping - var productAttributeMapping = model.ToEntity(); - //predefined values - var predefinedValues = (await productAttributeService.GetProductAttributeById(model.ProductAttributeId)) - .PredefinedProductAttributeValues; - foreach (var predefinedValue in predefinedValues) - { - var pav = predefinedValue.ToEntity(); - //locales - pav.Locales.Clear(); - var languages = await languageService.GetAllLanguages(true); - //localization - foreach (var lang in languages) - { - var name = predefinedValue.GetTranslation(x => x.Name, lang.Id, false); - if (!string.IsNullOrEmpty(name)) - pav.Locales.Add(new TranslationEntity { LanguageId = lang.Id, LocaleKey = "Name", LocaleValue = name }); - } - - productAttributeMapping.ProductAttributeValues.Add(pav); - } - - await productAttributeService.InsertProductAttributeMapping(productAttributeMapping, model.ProductId); - } - - public virtual async Task UpdateProductAttributeMappingModel(ProductModel.ProductAttributeMappingModel model) - { - var product = await productService.GetProductById(model.ProductId, true); - if (product != null) - { - var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.Id); - if (productAttributeMapping != null) - { - productAttributeMapping = model.ToEntity(productAttributeMapping); - await productAttributeService.UpdateProductAttributeMapping(productAttributeMapping, model.ProductId); - } - } - } - - public virtual async Task PrepareProductAttributeMappingModel( - ProductAttributeMapping productAttributeMapping) - { - var model = new ProductModel.ProductAttributeMappingModel { - //prepare only used properties - Id = productAttributeMapping.Id, - ValidationRulesAllowed = productAttributeMapping.ValidationRulesAllowed(), - AttributeControlTypeId = productAttributeMapping.AttributeControlTypeId, - ValidationMinLength = productAttributeMapping.ValidationMinLength, - ValidationMaxLength = productAttributeMapping.ValidationMaxLength, - ValidationFileAllowedExtensions = productAttributeMapping.ValidationFileAllowedExtensions, - ValidationFileMaximumSize = productAttributeMapping.ValidationFileMaximumSize, - DefaultValue = productAttributeMapping.DefaultValue - }; - return await Task.FromResult(model); - } - - public virtual async Task UpdateProductAttributeValidationRulesModel( - ProductAttributeMapping productAttributeMapping, ProductModel.ProductAttributeMappingModel model) - { - productAttributeMapping.ValidationMinLength = model.ValidationMinLength; - productAttributeMapping.ValidationMaxLength = model.ValidationMaxLength; - productAttributeMapping.ValidationFileAllowedExtensions = model.ValidationFileAllowedExtensions; - productAttributeMapping.ValidationFileMaximumSize = model.ValidationFileMaximumSize; - productAttributeMapping.DefaultValue = model.DefaultValue; - await productAttributeService.UpdateProductAttributeMapping(productAttributeMapping, model.ProductId); - } - - public virtual async Task PrepareProductAttributeConditionModel(Product product, - ProductAttributeMapping productAttributeMapping) - { - var model = new ProductAttributeConditionModel { - ProductAttributeMappingId = productAttributeMapping.Id, - EnableCondition = productAttributeMapping.ConditionAttribute.Any(), - ProductId = product.Id - }; - //pre-select attribute and values - var selectedPva = product.ParseProductAttributeMappings(productAttributeMapping.ConditionAttribute) - .FirstOrDefault(); - - var attributes = product.ProductAttributeMappings - //ignore non-combinable attributes (should have selectable values) - .Where(x => x.CanBeUsedAsCondition()) - //ignore this attribute (it cannot depend on itself) - .Where(x => x.Id != productAttributeMapping.Id) - .ToList(); - foreach (var attribute in attributes) - { - var pam = await productAttributeService.GetProductAttributeById(attribute.ProductAttributeId); - var attributeModel = new ProductAttributeConditionModel.ProductAttributeModel { - Id = attribute.Id, - ProductAttributeId = attribute.ProductAttributeId, - Name = pam.Name, - TextPrompt = attribute.TextPrompt, - IsRequired = attribute.IsRequired, - AttributeControlType = attribute.AttributeControlTypeId - }; - - if (attribute.ShouldHaveValues()) - { - //values - var attributeValues = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == attribute.Id) - ?.ProductAttributeValues; - if (attributeValues != null) - foreach (var attributeValue in attributeValues) - { - var attributeValueModel = new ProductAttributeConditionModel.ProductAttributeValueModel { - Id = attributeValue.Id, - Name = attributeValue.Name, - IsPreSelected = attributeValue.IsPreSelected - }; - attributeModel.Values.Add(attributeValueModel); - } - - //pre-select attribute and value - if (selectedPva != null && attribute.Id == selectedPva.Id) - { - //attribute - model.SelectedProductAttributeId = selectedPva.Id; - - //values - switch (attribute.AttributeControlTypeId) - { - case AttributeControlType.DropdownList: - case AttributeControlType.RadioList: - case AttributeControlType.Checkboxes: - case AttributeControlType.ColorSquares: - case AttributeControlType.ImageSquares: - { - if (productAttributeMapping.ConditionAttribute.Any()) - { - //clear default selection - foreach (var item in attributeModel.Values) - item.IsPreSelected = false; - - //select new values - var selectedValues = - product.ParseProductAttributeValues(productAttributeMapping.ConditionAttribute); - foreach (var attributeValue in selectedValues) - foreach (var item in attributeModel.Values) - if (attributeValue.Id == item.Id) - item.IsPreSelected = true; - } - } - break; - case AttributeControlType.ReadonlyCheckboxes: - case AttributeControlType.TextBox: - case AttributeControlType.MultilineTextbox: - case AttributeControlType.Datepicker: - case AttributeControlType.FileUpload: - default: - //these attribute types are supported as conditions - break; - } - } - } - - model.ProductAttributes.Add(attributeModel); - } - - return model; - } - - public virtual async Task UpdateProductAttributeConditionModel(Product product, - ProductAttributeMapping productAttributeMapping, ProductAttributeConditionModel model) - { - var customAttributes = new List(); - if (model.EnableCondition) - { - var attribute = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.SelectedProductAttributeId); - if (attribute != null) - switch (attribute.AttributeControlTypeId) - { - case AttributeControlType.DropdownList: - case AttributeControlType.RadioList: - case AttributeControlType.ColorSquares: - case AttributeControlType.ImageSquares: - { - var ctrlAttributes = model.SelectedAttributes.FirstOrDefault(x => x.Key == attribute.Id)?.Value; - if (!string.IsNullOrEmpty(ctrlAttributes)) - customAttributes = ProductExtensions.AddProductAttribute(customAttributes, - attribute, ctrlAttributes).ToList(); - else - customAttributes = ProductExtensions.AddProductAttribute(customAttributes, - attribute, "").ToList(); - } - break; - case AttributeControlType.Checkboxes: - { - var cblAttributes = model.SelectedAttributes.FirstOrDefault(x => x.Key == attribute.Id)?.Value; - if (!string.IsNullOrEmpty(cblAttributes)) - { - var anyValueSelected = false; - foreach (var item in cblAttributes.Split([','], - StringSplitOptions.RemoveEmptyEntries)) - if (!string.IsNullOrEmpty(item)) - { - customAttributes = ProductExtensions.AddProductAttribute(customAttributes, - attribute, item).ToList(); - anyValueSelected = true; - } - - if (!anyValueSelected) - customAttributes = ProductExtensions.AddProductAttribute(customAttributes, - attribute, "").ToList(); - } - else - { - customAttributes = ProductExtensions.AddProductAttribute(customAttributes, - attribute, "").ToList(); - } - } - break; - case AttributeControlType.ReadonlyCheckboxes: - case AttributeControlType.TextBox: - case AttributeControlType.MultilineTextbox: - case AttributeControlType.Datepicker: - case AttributeControlType.FileUpload: - default: - //these attribute types are supported as conditions - break; - } - } - - productAttributeMapping.ConditionAttribute = customAttributes; - await productAttributeService.UpdateProductAttributeMapping(productAttributeMapping, model.ProductId); - } - - public virtual async Task PrepareProductAttributeValueModel( - Product product, ProductAttributeMapping productAttributeMapping) - { - var model = new ProductModel.ProductAttributeValueModel { - ProductAttributeMappingId = productAttributeMapping.Id, - ProductId = product.Id, - - //color squares - DisplayColorSquaresRgb = - productAttributeMapping.AttributeControlTypeId == AttributeControlType.ColorSquares, - ColorSquaresRgb = "#000000", - //image squares - DisplayImageSquaresPicture = - productAttributeMapping.AttributeControlTypeId == AttributeControlType.ImageSquares, - PrimaryStoreCurrencyCode = (await currencyService.GetCurrencyById(currencySettings.PrimaryStoreCurrencyId)) - ?.CurrencyCode, - //default qantity for associated product - Quantity = 1 - }; - - //pictures - foreach (var x in product.ProductPictures) - model.ProductPictureModels.Add(new ProductModel.ProductPictureModel { - Id = x.Id, - ProductId = product.Id, - PictureId = x.PictureId, - PictureUrl = await pictureService.GetPictureUrl(x.PictureId), - DisplayOrder = x.DisplayOrder, - IsDefault = x.IsDefault - }); - return model; - } - - public virtual async Task> PrepareProductAttributeValueModels( - Product product, ProductAttributeMapping productAttributeMapping) - { - var items = new List(); - foreach (var x in productAttributeMapping.ProductAttributeValues.OrderBy(x => x.DisplayOrder)) - { - Product associatedProduct = null; - if (x.AttributeValueTypeId == AttributeValueType.AssociatedToProduct) - associatedProduct = await productService.GetProductById(x.AssociatedProductId); - - var pictureThumbnailUrl = - await pictureService.GetPictureUrl( - string.IsNullOrEmpty(x.PictureId) ? x.ImageSquaresPictureId : x.PictureId, 100, false); - - if (string.IsNullOrEmpty(pictureThumbnailUrl)) - pictureThumbnailUrl = await pictureService.GetPictureUrl("", 1); - - items.Add(new ProductModel.ProductAttributeValueModel { - Id = x.Id, - ProductAttributeMappingId = productAttributeMapping.Id, //TODO - check x.ProductAttributeMappingId, - AttributeValueTypeId = x.AttributeValueTypeId, - AttributeValueTypeName = enumTranslationService.GetTranslationEnum(x.AttributeValueTypeId), - AssociatedProductId = x.AssociatedProductId, - AssociatedProductName = associatedProduct != null ? associatedProduct.Name : "", - Name = productAttributeMapping.AttributeControlTypeId != AttributeControlType.ColorSquares - ? x.Name - : $"{x.Name} - {x.ColorSquaresRgb}", - ColorSquaresRgb = x.ColorSquaresRgb, - ImageSquaresPictureId = x.ImageSquaresPictureId, - PriceAdjustment = x.PriceAdjustment, - PriceAdjustmentStr = x.PriceAdjustment.ToString("G29"), - WeightAdjustment = x.WeightAdjustment, - WeightAdjustmentStr = x.AttributeValueTypeId == AttributeValueType.Simple - ? x.WeightAdjustment.ToString("G29") - : "", - Cost = x.Cost, - PrimaryStoreCurrencyCode = - (await currencyService.GetCurrencyById(currencySettings.PrimaryStoreCurrencyId))?.CurrencyCode, - Quantity = x.Quantity, - IsPreSelected = x.IsPreSelected, - DisplayOrder = x.DisplayOrder, - PictureId = x.PictureId, - PictureThumbnailUrl = pictureThumbnailUrl, - ProductId = product.Id - }); - } - - return items; - } - - public virtual async Task PrepareProductAttributeValueModel( - ProductAttributeMapping pa, ProductAttributeValue pav) - { - var associatedProduct = await productService.GetProductById(pav.AssociatedProductId); - - var model = new ProductModel.ProductAttributeValueModel { - ProductAttributeMappingId = pa.Id, //TODO - check pav.ProductAttributeMappingId, - AttributeValueTypeId = pav.AttributeValueTypeId, - AttributeValueTypeName = enumTranslationService.GetTranslationEnum(pav.AttributeValueTypeId), - AssociatedProductId = pav.AssociatedProductId, - AssociatedProductName = associatedProduct != null ? associatedProduct.Name : "", - Name = pav.Name, - ColorSquaresRgb = pav.ColorSquaresRgb, - DisplayColorSquaresRgb = pa.AttributeControlTypeId == AttributeControlType.ColorSquares, - ImageSquaresPictureId = pav.ImageSquaresPictureId, - DisplayImageSquaresPicture = pa.AttributeControlTypeId == AttributeControlType.ImageSquares, - PriceAdjustment = pav.PriceAdjustment, - WeightAdjustment = pav.WeightAdjustment, - Cost = pav.Cost, - PrimaryStoreCurrencyCode = (await currencyService.GetCurrencyById(currencySettings.PrimaryStoreCurrencyId)) - ?.CurrencyCode, - Quantity = pav.Quantity, - IsPreSelected = pav.IsPreSelected, - DisplayOrder = pav.DisplayOrder, - PictureId = pav.PictureId - }; - if (model.DisplayColorSquaresRgb && string.IsNullOrEmpty(model.ColorSquaresRgb)) - model.ColorSquaresRgb = "#000000"; - return model; - } - - public virtual async Task InsertProductAttributeValueModel(ProductModel.ProductAttributeValueModel model) - { - var pav = new ProductAttributeValue { - AttributeValueTypeId = model.AttributeValueTypeId, - AssociatedProductId = model.AssociatedProductId, - Name = model.Name, - ColorSquaresRgb = model.ColorSquaresRgb, - ImageSquaresPictureId = model.ImageSquaresPictureId, - PriceAdjustment = model.PriceAdjustment, - WeightAdjustment = model.WeightAdjustment, - Cost = model.Cost, - Quantity = model.Quantity, - IsPreSelected = model.IsPreSelected, - DisplayOrder = model.DisplayOrder, - PictureId = model.PictureId, - Locales = model.Locales.ToTranslationProperty() - }; - await productAttributeService.InsertProductAttributeValue(pav, model.ProductId, - model.ProductAttributeMappingId); - } - - public virtual async Task UpdateProductAttributeValueModel(ProductAttributeValue pav, - ProductModel.ProductAttributeValueModel model) - { - pav.AttributeValueTypeId = model.AttributeValueTypeId; - pav.AssociatedProductId = model.AssociatedProductId; - pav.Name = model.Name; - pav.ColorSquaresRgb = model.ColorSquaresRgb; - pav.ImageSquaresPictureId = model.ImageSquaresPictureId; - pav.PriceAdjustment = model.PriceAdjustment; - pav.WeightAdjustment = model.WeightAdjustment; - pav.Cost = model.Cost; - pav.Quantity = model.Quantity; - pav.IsPreSelected = model.IsPreSelected; - pav.DisplayOrder = model.DisplayOrder; - pav.PictureId = model.PictureId; - pav.Locales = model.Locales.ToTranslationProperty(); - - await productAttributeService.UpdateProductAttributeValue(pav, model.ProductId, - model.ProductAttributeMappingId); - } - public virtual async Task PrepareAssociateProductToAttributeValueModel() { var model = await PrepareAddProductModel(); diff --git a/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs index c3c00cc78..394cefd0e 100644 --- a/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs +++ b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs @@ -76,6 +76,7 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Web/Grand.Web.Store/Controllers/ProductAttributeMappingController.cs b/src/Web/Grand.Web.Store/Controllers/ProductAttributeMappingController.cs index 7d0ff3863..3201eeb57 100644 --- a/src/Web/Grand.Web.Store/Controllers/ProductAttributeMappingController.cs +++ b/src/Web/Grand.Web.Store/Controllers/ProductAttributeMappingController.cs @@ -16,8 +16,9 @@ namespace Grand.Web.Store.Controllers; [AuthorizeMenu] public class ProductAttributeMappingController( IProductViewModelService productViewModelService, + IProductAttributeMappingViewModelService productAttributeMappingViewModelService, IProductService productService, ILanguageService languageService, IRequestTranslationService translationService, IAdminDataScope scope) - : BaseProductAttributeMappingController(productViewModelService, productService, languageService, translationService, scope); + : BaseProductAttributeMappingController(productViewModelService, productAttributeMappingViewModelService, productService, languageService, translationService, scope); diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductAttributeMappingController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductAttributeMappingController.cs index 65ee93019..bcf00bcae 100644 --- a/src/Web/Grand.Web.Vendor/Controllers/ProductAttributeMappingController.cs +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductAttributeMappingController.cs @@ -16,8 +16,9 @@ namespace Grand.Web.Vendor.Controllers; [AuthorizeMenu] public class ProductAttributeMappingController( IProductViewModelService productViewModelService, + IProductAttributeMappingViewModelService productAttributeMappingViewModelService, IProductService productService, ILanguageService languageService, IRequestTranslationService translationService, IAdminDataScope scope) - : BaseProductAttributeMappingController(productViewModelService, productService, languageService, translationService, scope); + : BaseProductAttributeMappingController(productViewModelService, productAttributeMappingViewModelService, productService, languageService, translationService, scope); From 26fec1a8f973d93479dfc24da70090c96536239d Mon Sep 17 00:00:00 2001 From: support Date: Thu, 22 Oct 2026 07:48:27 +0200 Subject: [PATCH 22/27] Move the attribute combinations view-model logic into its own service --- ...ductAttributeCombinationControllerTests.cs | 54 +- .../Controllers/ProductControllerTestsBase.cs | 2 + .../ProductAttributeCombinationController.cs | 4 +- ...seProductAttributeCombinationController.cs | 24 +- ...uctAttributeCombinationViewModelService.cs | 38 ++ .../Interfaces/IProductViewModelService.cs | 30 - ...uctAttributeCombinationViewModelService.cs | 541 ++++++++++++++++++ .../Services/ProductViewModelService.cs | 504 ---------------- .../Startup/StartupApplication.cs | 1 + .../ProductAttributeCombinationController.cs | 4 +- .../ProductAttributeCombinationController.cs | 4 +- 11 files changed, 627 insertions(+), 579 deletions(-) create mode 100644 src/Web/Grand.Web.AdminShared/Interfaces/IProductAttributeCombinationViewModelService.cs create mode 100644 src/Web/Grand.Web.AdminShared/Services/ProductAttributeCombinationViewModelService.cs diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAttributeCombinationControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAttributeCombinationControllerTests.cs index 1addc05ff..11e0b6b53 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAttributeCombinationControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAttributeCombinationControllerTests.cs @@ -22,15 +22,15 @@ public class BaseProductAttributeCombinationControllerTests : ProductControllerT // BaseProductAttributeCombinationController is abstract. This minimal subclass exists only so the actions under test // can be invoked directly. public class TestProductAttributeCombinationController( - IProductViewModelService productViewModelService, + IProductAttributeCombinationViewModelService productAttributeCombinationViewModelService, IProductService productService, IInventoryManageService inventoryManageService, IRequestTranslationService translationService, IAdminDataScope scope) - : BaseProductAttributeCombinationController(productViewModelService, productService, inventoryManageService, translationService, scope); + : BaseProductAttributeCombinationController(productAttributeCombinationViewModelService, productService, inventoryManageService, translationService, scope); protected override TestProductAttributeCombinationController CreateController() => new( - _productViewModelServiceMock.Object, + _productAttributeCombinationViewModelServiceMock.Object, _productServiceMock.Object, _inventoryManageServiceMock.Object, _translationServiceMock.Object, @@ -49,7 +49,7 @@ public async Task ProductAttributeCombinationList_ScopeDeniesAccess_ReturnsError Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( + _productAttributeCombinationViewModelServiceMock.Verify( s => s.PrepareProductAttributeCombinationModel(It.IsAny()), Times.Never); } @@ -59,7 +59,7 @@ public async Task ProductAttributeCombinationList_ScopeGrantsAccess_ReturnsGrid( var product = new Product { Id = "p1" }; _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeCombinationModel(product)) + _productAttributeCombinationViewModelServiceMock.Setup(s => s.PrepareProductAttributeCombinationModel(product)) .ReturnsAsync(new List { new() }); var result = await _controller.ProductAttributeCombinationList(new DataSourceRequest(), "p1"); @@ -142,7 +142,7 @@ public async Task AttributeCombinationPopupGet_ScopeDeniesAccess_ReturnsPermissi var content = result as ContentResult; Assert.IsNotNull(content); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( + _productAttributeCombinationViewModelServiceMock.Verify( s => s.PrepareProductAttributeCombinationModel(It.IsAny(), It.IsAny()), Times.Never); } @@ -153,7 +153,7 @@ public async Task AttributeCombinationPopupGet_ScopeGrantsAccess_ReturnsView() _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); var model = new ProductAttributeCombinationModel { Id = "c1" }; - _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeCombinationModel(product, "c1")) + _productAttributeCombinationViewModelServiceMock.Setup(s => s.PrepareProductAttributeCombinationModel(product, "c1")) .ReturnsAsync(model); var result = await _controller.AttributeCombinationPopup("p1", "c1"); @@ -161,7 +161,7 @@ public async Task AttributeCombinationPopupGet_ScopeGrantsAccess_ReturnsView() var view = result as ViewResult; Assert.IsNotNull(view); Assert.AreSame(model, view.Model); - _productViewModelServiceMock.Verify(s => s.PrepareAddProductAttributeCombinationModel(model, product), + _productAttributeCombinationViewModelServiceMock.Verify(s => s.PrepareAddProductAttributeCombinationModel(model, product), Times.Once); } @@ -194,7 +194,7 @@ public async Task AttributeCombinationPopupPost_ScopeDeniesAccess_ReturnsPermiss var content = result as ContentResult; Assert.IsNotNull(content); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( + _productAttributeCombinationViewModelServiceMock.Verify( s => s.InsertOrUpdateProductAttributeCombinationPopup(It.IsAny(), It.IsAny()), Times.Never); } @@ -206,7 +206,7 @@ public async Task AttributeCombinationPopupPost_ScopeGrantsAccess_NoWarnings_Ret _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); var model = new ProductAttributeCombinationModel(); - _productViewModelServiceMock.Setup(s => s.InsertOrUpdateProductAttributeCombinationPopup(product, model)) + _productAttributeCombinationViewModelServiceMock.Setup(s => s.InsertOrUpdateProductAttributeCombinationPopup(product, model)) .ReturnsAsync(new List()); var result = await _controller.AttributeCombinationPopup("p1", model); @@ -223,7 +223,7 @@ public async Task AttributeCombinationPopupPost_ScopeGrantsAccess_WithWarnings_R _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); var model = new ProductAttributeCombinationModel(); - _productViewModelServiceMock.Setup(s => s.InsertOrUpdateProductAttributeCombinationPopup(product, model)) + _productAttributeCombinationViewModelServiceMock.Setup(s => s.InsertOrUpdateProductAttributeCombinationPopup(product, model)) .ReturnsAsync(new List { "warning" }); var result = await _controller.AttributeCombinationPopup("p1", model); @@ -232,7 +232,7 @@ public async Task AttributeCombinationPopupPost_ScopeGrantsAccess_WithWarnings_R Assert.IsNotNull(view); Assert.AreSame(model, view.Model); CollectionAssert.Contains(model.Warnings.ToList(), "warning"); - _productViewModelServiceMock.Verify(s => s.PrepareAddProductAttributeCombinationModel(model, product), + _productAttributeCombinationViewModelServiceMock.Verify(s => s.PrepareAddProductAttributeCombinationModel(model, product), Times.Once); } @@ -258,7 +258,7 @@ public async Task GenerateAllAttributeCombinations_ScopeDeniesAccess_ReturnsErro Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.GenerateAllAttributeCombinations(It.IsAny()), + _productAttributeCombinationViewModelServiceMock.Verify(s => s.GenerateAllAttributeCombinations(It.IsAny()), Times.Never); } @@ -272,7 +272,7 @@ public async Task GenerateAllAttributeCombinations_ScopeGrantsAccess_Generates() var result = await _controller.GenerateAllAttributeCombinations("p1"); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.GenerateAllAttributeCombinations(product), Times.Once); + _productAttributeCombinationViewModelServiceMock.Verify(s => s.GenerateAllAttributeCombinations(product), Times.Once); } // --- ClearAllAttributeCombinations ----------------------------------------------------------------- @@ -297,7 +297,7 @@ public async Task ClearAllAttributeCombinations_ScopeDeniesAccess_ReturnsErrorJs Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.ClearAllAttributeCombinations(It.IsAny()), Times.Never); + _productAttributeCombinationViewModelServiceMock.Verify(s => s.ClearAllAttributeCombinations(It.IsAny()), Times.Never); } [TestMethod] @@ -311,7 +311,7 @@ public async Task ClearAllAttributeCombinations_ScopeGrantsAccess_ValidModel_Cle var result = await _controller.ClearAllAttributeCombinations("p1"); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.ClearAllAttributeCombinations(product), Times.Once); + _productAttributeCombinationViewModelServiceMock.Verify(s => s.ClearAllAttributeCombinations(product), Times.Once); } [TestMethod] @@ -325,7 +325,7 @@ public async Task ClearAllAttributeCombinations_ScopeGrantsAccess_InvalidModelSt var result = await _controller.ClearAllAttributeCombinations("p1"); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.ClearAllAttributeCombinations(It.IsAny()), Times.Never); + _productAttributeCombinationViewModelServiceMock.Verify(s => s.ClearAllAttributeCombinations(It.IsAny()), Times.Never); } // --- ProductAttributeCombinationTierPriceList (POST) ----------------------------------------------- @@ -341,7 +341,7 @@ public async Task ProductAttributeCombinationTierPriceList_ScopeDeniesAccess_Ret Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( + _productAttributeCombinationViewModelServiceMock.Verify( s => s.PrepareProductAttributeCombinationTierPricesModel(It.IsAny(), It.IsAny()), Times.Never); } @@ -352,7 +352,7 @@ public async Task ProductAttributeCombinationTierPriceList_ScopeGrantsAccess_Ret var product = new Product { Id = "p1" }; _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeCombinationTierPricesModel(product, "c1")) + _productAttributeCombinationViewModelServiceMock.Setup(s => s.PrepareProductAttributeCombinationTierPricesModel(product, "c1")) .ReturnsAsync(new List { new() }); var result = await _controller.ProductAttributeCombinationTierPriceList(new DataSourceRequest(), "p1", "c1"); @@ -388,7 +388,7 @@ public async Task ProductAttributeCombinationTierPriceInsert_ScopeDeniesAccess_R Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( + _productAttributeCombinationViewModelServiceMock.Verify( s => s.InsertProductAttributeCombinationTierPricesModel(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); @@ -405,7 +405,7 @@ public async Task ProductAttributeCombinationTierPriceInsert_ScopeGrantsAccess_C new ProductModel.ProductAttributeCombinationTierPricesModel()); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( + _productAttributeCombinationViewModelServiceMock.Verify( s => s.InsertProductAttributeCombinationTierPricesModel(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); @@ -424,7 +424,7 @@ public async Task ProductAttributeCombinationTierPriceInsert_ScopeGrantsAccess_I var result = await _controller.ProductAttributeCombinationTierPriceInsert("p1", "c1", model); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( + _productAttributeCombinationViewModelServiceMock.Verify( s => s.InsertProductAttributeCombinationTierPricesModel(product, combination, model), Times.Once); } @@ -453,7 +453,7 @@ public async Task ProductAttributeCombinationTierPriceUpdate_ScopeDeniesAccess_R Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( + _productAttributeCombinationViewModelServiceMock.Verify( s => s.UpdateProductAttributeCombinationTierPricesModel(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); @@ -472,7 +472,7 @@ public async Task ProductAttributeCombinationTierPriceUpdate_ScopeGrantsAccess_U var result = await _controller.ProductAttributeCombinationTierPriceUpdate("p1", "c1", model); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( + _productAttributeCombinationViewModelServiceMock.Verify( s => s.UpdateProductAttributeCombinationTierPricesModel(product, combination, model), Times.Once); } @@ -499,7 +499,7 @@ public async Task ProductAttributeCombinationTierPriceDelete_ScopeDeniesAccess_R Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( + _productAttributeCombinationViewModelServiceMock.Verify( s => s.DeleteProductAttributeCombinationTierPrices(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); @@ -516,7 +516,7 @@ public async Task ProductAttributeCombinationTierPriceDelete_ScopeGrantsAccess_T var result = await _controller.ProductAttributeCombinationTierPriceDelete("p1", "c1", "missing-tp"); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( + _productAttributeCombinationViewModelServiceMock.Verify( s => s.DeleteProductAttributeCombinationTierPrices(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); @@ -536,7 +536,7 @@ public async Task ProductAttributeCombinationTierPriceDelete_ScopeGrantsAccess_D var result = await _controller.ProductAttributeCombinationTierPriceDelete("p1", "c1", "tp1"); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( + _productAttributeCombinationViewModelServiceMock.Verify( s => s.DeleteProductAttributeCombinationTierPrices(product, combination, tierPrice), Times.Once); } } diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs index 8c814be7f..a16ce80a6 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs @@ -28,6 +28,7 @@ public abstract class ProductControllerTestsBase where TController protected TController _controller; protected Mock _productServiceMock; protected Mock _productViewModelServiceMock; + protected Mock _productAttributeCombinationViewModelServiceMock; protected Mock _productAttributeMappingViewModelServiceMock; protected Mock _productAssociatedViewModelServiceMock; protected Mock _productBundleViewModelServiceMock; @@ -62,6 +63,7 @@ public void Setup() _productServiceMock = new Mock(); _productViewModelServiceMock = new Mock(); + _productAttributeCombinationViewModelServiceMock = new Mock(); _productAttributeMappingViewModelServiceMock = new Mock(); _productAssociatedViewModelServiceMock = new Mock(); _productBundleViewModelServiceMock = new Mock(); diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductAttributeCombinationController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductAttributeCombinationController.cs index 8f8082f58..38f3215e1 100644 --- a/src/Web/Grand.Web.Admin/Controllers/ProductAttributeCombinationController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/ProductAttributeCombinationController.cs @@ -14,9 +14,9 @@ namespace Grand.Web.Admin.Controllers; [Area(Constants.AreaAdmin)] [AuthorizeMenu] public class ProductAttributeCombinationController( - IProductViewModelService productViewModelService, + IProductAttributeCombinationViewModelService productAttributeCombinationViewModelService, IProductService productService, IInventoryManageService inventoryManageService, IRequestTranslationService translationService, IAdminDataScope scope) - : BaseProductAttributeCombinationController(productViewModelService, productService, inventoryManageService, translationService, scope); + : BaseProductAttributeCombinationController(productAttributeCombinationViewModelService, productService, inventoryManageService, translationService, scope); diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAttributeCombinationController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAttributeCombinationController.cs index 2e72b4438..3a1bb2924 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAttributeCombinationController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAttributeCombinationController.cs @@ -16,7 +16,7 @@ namespace Grand.Web.AdminShared.Controllers; [PermissionAuthorize(PermissionSystemName.Products)] [AutoValidateAntiforgeryToken] public abstract class BaseProductAttributeCombinationController( - IProductViewModelService productViewModelService, + IProductAttributeCombinationViewModelService productAttributeCombinationViewModelService, IProductService productService, IInventoryManageService inventoryManageService, IRequestTranslationService translationService, @@ -43,7 +43,7 @@ public async Task ProductAttributeCombinationList(DataSourceReque return ErrorForKendoGridJson( translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - var combinationsModel = await productViewModelService.PrepareProductAttributeCombinationModel(product); + var combinationsModel = await productAttributeCombinationViewModelService.PrepareProductAttributeCombinationModel(product); var gridModel = new DataSourceResult { Data = combinationsModel, Total = combinationsModel.Count @@ -99,8 +99,8 @@ public async Task AttributeCombinationPopup(string productId, str if (!await scope.HasAccess(product)) return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - var model = await productViewModelService.PrepareProductAttributeCombinationModel(product, id); - await productViewModelService.PrepareAddProductAttributeCombinationModel(model, product); + var model = await productAttributeCombinationViewModelService.PrepareProductAttributeCombinationModel(product, id); + await productAttributeCombinationViewModelService.PrepareAddProductAttributeCombinationModel(model, product); return View(model); } @@ -123,10 +123,10 @@ public async Task AttributeCombinationPopup(string productId, if (!await scope.HasAccess(product)) return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - var warnings = await productViewModelService.InsertOrUpdateProductAttributeCombinationPopup(product, model); + var warnings = await productAttributeCombinationViewModelService.InsertOrUpdateProductAttributeCombinationPopup(product, model); if (!warnings.Any()) return Content(""); //If we got this far, something failed, redisplay form - await productViewModelService.PrepareAddProductAttributeCombinationModel(model, product); + await productAttributeCombinationViewModelService.PrepareAddProductAttributeCombinationModel(model, product); model.Warnings = warnings; return View(model); } @@ -155,7 +155,7 @@ public async Task GenerateAllAttributeCombinations(string product return ErrorForKendoGridJson( translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - await productViewModelService.GenerateAllAttributeCombinations(product); + await productAttributeCombinationViewModelService.GenerateAllAttributeCombinations(product); return Json(new { Success = true }); } @@ -175,7 +175,7 @@ public async Task ClearAllAttributeCombinations(string productId) if (ModelState.IsValid) { - await productViewModelService.ClearAllAttributeCombinations(product); + await productAttributeCombinationViewModelService.ClearAllAttributeCombinations(product); if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes) { @@ -216,7 +216,7 @@ public async Task ProductAttributeCombinationTierPriceList(DataSo translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); var tierPriceModel = - await productViewModelService.PrepareProductAttributeCombinationTierPricesModel(product, + await productAttributeCombinationViewModelService.PrepareProductAttributeCombinationTierPricesModel(product, productAttributeCombinationId); var gridModel = new DataSourceResult { Data = tierPriceModel, @@ -243,7 +243,7 @@ public async Task ProductAttributeCombinationTierPriceInsert(stri var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == productAttributeCombinationId); if (combination != null) - await productViewModelService.InsertProductAttributeCombinationTierPricesModel(product, combination, + await productAttributeCombinationViewModelService.InsertProductAttributeCombinationTierPricesModel(product, combination, model); return new JsonResult(""); @@ -264,7 +264,7 @@ public async Task ProductAttributeCombinationTierPriceUpdate(stri var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == productAttributeCombinationId); if (combination != null) - await productViewModelService.UpdateProductAttributeCombinationTierPricesModel(product, combination, + await productAttributeCombinationViewModelService.UpdateProductAttributeCombinationTierPricesModel(product, combination, model); return new JsonResult(""); @@ -289,7 +289,7 @@ public async Task ProductAttributeCombinationTierPriceDelete(stri { var tierPrice = combination.TierPrices.FirstOrDefault(x => x.Id == id); if (tierPrice != null) - await productViewModelService.DeleteProductAttributeCombinationTierPrices(product, combination, + await productAttributeCombinationViewModelService.DeleteProductAttributeCombinationTierPrices(product, combination, tierPrice); } diff --git a/src/Web/Grand.Web.AdminShared/Interfaces/IProductAttributeCombinationViewModelService.cs b/src/Web/Grand.Web.AdminShared/Interfaces/IProductAttributeCombinationViewModelService.cs new file mode 100644 index 000000000..be0bf5194 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Interfaces/IProductAttributeCombinationViewModelService.cs @@ -0,0 +1,38 @@ +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Models.Catalog; + +namespace Grand.Web.AdminShared.Interfaces; + +public interface IProductAttributeCombinationViewModelService +{ + Task OutOfStockNotifications(Product product, ProductAttributeCombination combination, + ProductAttributeCombination prevcombination); + + Task PrepareAddProductAttributeCombinationModel(ProductAttributeCombinationModel model, Product product); + + Task> PrepareProductAttributeCombinationModel(Product product); + + Task PrepareProductAttributeCombinationModel(Product product, + string combinationId); + + Task> InsertOrUpdateProductAttributeCombinationPopup(Product product, + ProductAttributeCombinationModel model); + + Task GenerateAllAttributeCombinations(Product product); + + Task ClearAllAttributeCombinations(Product product); + + Task> + PrepareProductAttributeCombinationTierPricesModel(Product product, string productAttributeCombinationId); + + Task InsertProductAttributeCombinationTierPricesModel(Product product, + ProductAttributeCombination productAttributeCombination, + ProductModel.ProductAttributeCombinationTierPricesModel model); + + Task UpdateProductAttributeCombinationTierPricesModel(Product product, + ProductAttributeCombination productAttributeCombination, + ProductModel.ProductAttributeCombinationTierPricesModel model); + + Task DeleteProductAttributeCombinationTierPrices(Product product, + ProductAttributeCombination productAttributeCombination, ProductCombinationTierPrices tierPrice); +} diff --git a/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs b/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs index 2c34ebca1..a58a95897 100644 --- a/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs @@ -14,10 +14,6 @@ Task PrepareProductReviewModel(ProductReviewModel model, ProductReview productRe Task OutOfStockNotifications(Product product, int prevStockQuantity, List prevMultiWarehouseStock); - Task OutOfStockNotifications(Product product, ProductAttributeCombination combination, - ProductAttributeCombination prevcombination); - - Task PrepareAddProductAttributeCombinationModel(ProductAttributeCombinationModel model, Product product); Task SaveProductWarehouseInventory(Product product, IList model); Task PrepareProductListModel(); @@ -54,30 +50,4 @@ Task OutOfStockNotifications(Product product, ProductAttributeCombination combin Task PrepareAssociateProductToAttributeValueModel(); - Task> PrepareProductAttributeCombinationModel(Product product); - - Task PrepareProductAttributeCombinationModel(Product product, - string combinationId); - - Task> InsertOrUpdateProductAttributeCombinationPopup(Product product, - ProductAttributeCombinationModel model); - - Task GenerateAllAttributeCombinations(Product product); - - Task ClearAllAttributeCombinations(Product product); - - Task> - PrepareProductAttributeCombinationTierPricesModel(Product product, string productAttributeCombinationId); - - Task InsertProductAttributeCombinationTierPricesModel(Product product, - ProductAttributeCombination productAttributeCombination, - ProductModel.ProductAttributeCombinationTierPricesModel model); - - Task UpdateProductAttributeCombinationTierPricesModel(Product product, - ProductAttributeCombination productAttributeCombination, - ProductModel.ProductAttributeCombinationTierPricesModel model); - - Task DeleteProductAttributeCombinationTierPrices(Product product, - ProductAttributeCombination productAttributeCombination, ProductCombinationTierPrices tierPrice); - } \ No newline at end of file diff --git a/src/Web/Grand.Web.AdminShared/Services/ProductAttributeCombinationViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/ProductAttributeCombinationViewModelService.cs new file mode 100644 index 000000000..9e892b11a --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Services/ProductAttributeCombinationViewModelService.cs @@ -0,0 +1,541 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Catalog.Tax; +using Grand.Business.Core.Interfaces.Checkout.Shipping; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Stores; +using Grand.Business.Core.Interfaces.Storage; +using Grand.Domain.Catalog; +using Grand.Domain.Common; +using Grand.Domain.Directory; +using Grand.Infrastructure; +using Grand.SharedKernel.Extensions; +using Grand.Web.AdminShared.Extensions.Mapping; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.Common.Extensions; +using Grand.Web.Common.Localization; +using System.Net; +using ProductExtensions = Grand.Domain.Catalog.ProductExtensions; + +namespace Grand.Web.AdminShared.Services; + +public class ProductAttributeCombinationViewModelService( + IProductService productService, + IInventoryManageService inventoryManageService, + IPictureService pictureService, + IProductAttributeService productAttributeService, + ICurrencyService currencyService, + IRequestTranslationService translationService, + IContextAccessor contextAccessor, + IGroupService groupService, + IWarehouseService warehouseService, + IStoreService storeService, + IOutOfStockSubscriptionService outOfStockSubscriptionService, + IProductAttributeFormatter productAttributeFormatter, + CurrencySettings currencySettings) + : IProductAttributeCombinationViewModelService +{ + public virtual async Task PrepareAddProductAttributeCombinationModel(ProductAttributeCombinationModel model, + Product product) + { + ArgumentNullException.ThrowIfNull(model); + ArgumentNullException.ThrowIfNull(product); + + if (product.UseMultipleWarehouses) model.UseMultipleWarehouses = product.UseMultipleWarehouses; + if (string.IsNullOrEmpty(model.Id)) + { + model.ProductId = product.Id; + var attributes = product.ProductAttributeMappings + .Where(x => !x.IsNonCombinable()) + .ToList(); + foreach (var attribute in attributes) + { + var productAttribute = + await productAttributeService.GetProductAttributeById(attribute.ProductAttributeId); + var attributeModel = new ProductAttributeCombinationModel.ProductAttributeModel { + Id = attribute.Id, + ProductAttributeId = attribute.ProductAttributeId, + Name = productAttribute.Name, + TextPrompt = attribute.TextPrompt, + IsRequired = attribute.IsRequired, + AttributeControlType = attribute.AttributeControlTypeId + }; + + if (attribute.ShouldHaveValues()) + { + //values + var attributeValues = attribute.ProductAttributeValues; + foreach (var attributeValue in attributeValues) + { + var attributeValueModel = new ProductAttributeCombinationModel.ProductAttributeValueModel { + Id = attributeValue.Id, + Name = attributeValue.Name, + IsPreSelected = attributeValue.IsPreSelected + }; + attributeModel.Values.Add(attributeValueModel); + } + } + + model.ProductAttributes.Add(attributeModel); + } + } + + + if (!string.IsNullOrEmpty(model.PictureId)) + { + var pictureThumbnailUrl = await pictureService.GetPictureUrl(model.PictureId, 100, false); + model.PictureThumbnailUrl = pictureThumbnailUrl; + } + + foreach (var picture in product.ProductPictures) + model.ProductPictureModels.Add(new ProductModel.ProductPictureModel { + Id = picture.Id, + ProductId = product.Id, + PictureId = picture.PictureId, + PictureUrl = await pictureService.GetPictureUrl(picture.PictureId), + DisplayOrder = picture.DisplayOrder, + IsDefault = picture.IsDefault + }); + model.PrimaryStoreCurrencyCode = + (await currencyService.GetCurrencyById(currencySettings.PrimaryStoreCurrencyId))?.CurrencyCode; + } + + public virtual async Task OutOfStockNotifications(Product product, ProductAttributeCombination combination, + ProductAttributeCombination prevcombination) + { + if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes && + product.BackorderModeId == BackorderMode.NoBackorders && + product.AllowOutOfStockSubscriptions && + combination.StockQuantity > 0 && + prevcombination.StockQuantity <= 0 && !product.UseMultipleWarehouses && + product.Published) + await outOfStockSubscriptionService.SendNotificationsToSubscribers(product, combination.Attributes, ""); + if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes && + product.BackorderModeId == BackorderMode.NoBackorders && + product.AllowOutOfStockSubscriptions && + product.UseMultipleWarehouses && + product.Published) + { + foreach (var prevstock in prevcombination.WarehouseInventory) + if (prevstock.StockQuantity - prevstock.ReservedQuantity <= 0) + { + var actualStock = + combination.WarehouseInventory.FirstOrDefault(x => x.WarehouseId == prevstock.WarehouseId); + if (actualStock != null) + if (actualStock.StockQuantity - actualStock.ReservedQuantity > 0) + await outOfStockSubscriptionService.SendNotificationsToSubscribers(product, + combination.Attributes, prevstock.WarehouseId); + } + + if (combination.WarehouseInventory.Sum(x => x.StockQuantity - x.ReservedQuantity) > 0) + if (prevcombination.WarehouseInventory.Sum(x => x.StockQuantity - x.ReservedQuantity) <= 0) + await outOfStockSubscriptionService.SendNotificationsToSubscribers(product, combination.Attributes, + ""); + } + } + + public virtual async Task> + PrepareProductAttributeCombinationModel(Product product) + { + var items = new List(); + foreach (var x in product.ProductAttributeCombinations) + { + var attributes = await productAttributeFormatter.FormatAttributes(product, x.Attributes, + contextAccessor.CalculationContext(), contextAccessor.WorkContext.WorkingLanguage, + contextAccessor.StoreContext.CurrentHost, "
", true, true, true, false, true, true); + var pacModel = new ProductModel.ProductAttributeCombinationModel { + Id = x.Id, + ProductId = product.Id, + Attributes = string.IsNullOrEmpty(attributes) ? "(null)" : attributes, + StockQuantity = product.UseMultipleWarehouses + ? x.WarehouseInventory.Sum(y => y.StockQuantity - y.ReservedQuantity) + : x.StockQuantity, + AllowOutOfStockOrders = x.AllowOutOfStockOrders, + Sku = x.Sku, + Mpn = x.Mpn, + Gtin = x.Gtin, + OverriddenPrice = x.OverriddenPrice, + NotifyAdminForQuantityBelow = x.NotifyAdminForQuantityBelow + }; + items.Add(pacModel); + } + + return items; + } + + public virtual async Task PrepareProductAttributeCombinationModel(Product product, + string combinationId) + { + var model = new ProductAttributeCombinationModel(); + var wim = new List(); + foreach (var warehouse in await warehouseService.GetAllWarehouses()) + { + var pwiModel = new ProductAttributeCombinationModel.WarehouseInventoryModel { + WarehouseId = warehouse.Id, + WarehouseName = warehouse.Name + }; + wim.Add(pwiModel); + } + + if (product.UseMultipleWarehouses) + { + model.UseMultipleWarehouses = product.UseMultipleWarehouses; + model.WarehouseInventoryModels = wim; + } + + if (!string.IsNullOrEmpty(combinationId)) + { + var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == combinationId); + if (combination != null) + { + model = combination.ToModel(); + model.UseMultipleWarehouses = product.UseMultipleWarehouses; + model.WarehouseInventoryModels = wim; + model.ProductId = product.Id; + model.Attributes = await productAttributeFormatter.FormatAttributes(product, combination.Attributes, + contextAccessor.CalculationContext(), contextAccessor.WorkContext.WorkingLanguage, + contextAccessor.StoreContext.CurrentHost, "
", true, true, true, false); + if (model.UseMultipleWarehouses) + foreach (var _winv in combination.WarehouseInventory) + { + var warehouseInventoryModel = + model.WarehouseInventoryModels.FirstOrDefault(x => x.WarehouseId == _winv.WarehouseId); + if (warehouseInventoryModel != null) + { + warehouseInventoryModel.WarehouseUsed = true; + warehouseInventoryModel.Id = _winv.Id; + warehouseInventoryModel.StockQuantity = _winv.StockQuantity; + warehouseInventoryModel.ReservedQuantity = _winv.ReservedQuantity; + } + } + } + } + + return model; + } + + public virtual async Task> InsertOrUpdateProductAttributeCombinationPopup(Product product, + ProductAttributeCombinationModel model) + { + var customAttributes = new List(); + var warnings = new List(); + + async Task PrepareCombinationWarehouseInventory(ProductAttributeCombination combination) + { + var warehouses = await warehouseService.GetAllWarehouses(); + + foreach (var warehouse in warehouses) + { + var whim = model.WarehouseInventoryModels.FirstOrDefault(x => x.WarehouseId == warehouse.Id); + var existingPwI = combination.WarehouseInventory.FirstOrDefault(x => x.WarehouseId == warehouse.Id); + if (existingPwI != null) + { + if (whim is { WarehouseUsed: true }) + { + //update + existingPwI.StockQuantity = whim.StockQuantity; + existingPwI.ReservedQuantity = whim.ReservedQuantity; + } + else + { + //delete + combination.WarehouseInventory.Remove(existingPwI); + } + } + else + { + if (whim is { WarehouseUsed: true }) + { + //no need to insert a record for qty 0 + existingPwI = new ProductCombinationWarehouseInventory { + WarehouseId = whim.WarehouseId, + StockQuantity = whim.StockQuantity, + ReservedQuantity = whim.ReservedQuantity + }; + combination.WarehouseInventory.Add(existingPwI); + } + } + } + } + + if (string.IsNullOrEmpty(model.Id)) + { + #region Product attributes + + var attributes = product.ProductAttributeMappings + .Where(x => !x.IsNonCombinable()) + .ToList(); + if (attributes.Count == 0) + { + warnings.Add("This combination attributes is empty!"); + return warnings; + } + + foreach (var attribute in attributes) + switch (attribute.AttributeControlTypeId) + { + case AttributeControlType.DropdownList: + case AttributeControlType.RadioList: + case AttributeControlType.ColorSquares: + case AttributeControlType.ImageSquares: + { + var ctrlAttributes = model.SelectedAttributes.FirstOrDefault(x => x.Key == attribute.Id)?.Value; + if (!string.IsNullOrEmpty(ctrlAttributes)) + customAttributes = ProductExtensions.AddProductAttribute(customAttributes, + attribute, ctrlAttributes).ToList(); + } + break; + case AttributeControlType.Checkboxes: + { + var cblAttributes = model.SelectedAttributes.FirstOrDefault(x => x.Key == attribute.Id)?.Value; + if (!string.IsNullOrEmpty(cblAttributes)) + foreach (var item in cblAttributes.Split([','], + StringSplitOptions.RemoveEmptyEntries)) + if (!string.IsNullOrEmpty(item)) + customAttributes = ProductExtensions.AddProductAttribute(customAttributes, + attribute, item).ToList(); + } + break; + case AttributeControlType.ReadonlyCheckboxes: + { + //load read-only (already server-side selected) values + var attributeValues = attribute.ProductAttributeValues; + foreach (var selectedAttributeId in attributeValues + .Where(v => v.IsPreSelected) + .Select(v => v.Id) + .ToList()) + customAttributes = ProductExtensions.AddProductAttribute(customAttributes, + attribute, selectedAttributeId).ToList(); + } + break; + } + + //validate conditional attributes (if specified) + foreach (var attribute in attributes) + { + var conditionMet = product.IsConditionMet(attribute, customAttributes); + if (conditionMet.HasValue && !conditionMet.Value) + customAttributes = ProductExtensions.RemoveProductAttribute(customAttributes, attribute).ToList(); + } + + if (customAttributes.Count == 0) + { + warnings.Add("This combination custom attributes is empty!"); + return warnings; + } + + foreach (var customAttribute in customAttributes) + if (string.IsNullOrEmpty(customAttribute.Value)) + { + warnings.Add("Combination custom attributes need to be selected value!"); + return warnings; + } + + #endregion + + if (product.FindProductAttributeCombination(customAttributes) != null) + warnings.Add("This combination attributes exists!"); + if (warnings.Count == 0) + { + var combination = new ProductAttributeCombination { + Attributes = customAttributes, + StockQuantity = model.StockQuantity, + ReservedQuantity = model.ReservedQuantity, + AllowOutOfStockOrders = model.AllowOutOfStockOrders, + Sku = model.Sku, + Text = model.Text, + Mpn = model.Mpn, + Gtin = model.Gtin, + OverriddenPrice = model.OverriddenPrice, + NotifyAdminForQuantityBelow = model.NotifyAdminForQuantityBelow, + PictureId = model.PictureId + }; + + if (product.UseMultipleWarehouses) + { + await PrepareCombinationWarehouseInventory(combination); + combination.StockQuantity = combination.WarehouseInventory.Sum(x => x.StockQuantity); + combination.ReservedQuantity = combination.WarehouseInventory.Sum(x => x.ReservedQuantity); + } + + await productAttributeService.InsertProductAttributeCombination(combination, product.Id); + + if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes) + { + product.StockQuantity = product.ProductAttributeCombinations.Sum(x => x.StockQuantity); + product.ReservedQuantity = product.ProductAttributeCombinations.Sum(x => x.ReservedQuantity); + await inventoryManageService.UpdateStockProduct(product, false); + } + } + } + else + { + var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == model.Id); + var prevCombination = (ProductAttributeCombination)combination!.Clone(); + + combination.StockQuantity = model.StockQuantity; + combination.ReservedQuantity = model.ReservedQuantity; + combination.AllowOutOfStockOrders = model.AllowOutOfStockOrders; + combination.Sku = model.Sku; + combination.Text = model.Text; + combination.Mpn = model.Mpn; + combination.Gtin = model.Gtin; + combination.OverriddenPrice = model.OverriddenPrice; + combination.NotifyAdminForQuantityBelow = model.NotifyAdminForQuantityBelow; + combination.PictureId = model.PictureId; + + if (product.UseMultipleWarehouses) + { + await PrepareCombinationWarehouseInventory(combination); + combination.StockQuantity = combination.WarehouseInventory.Sum(x => x.StockQuantity); + } + + //notification - out of stock + await OutOfStockNotifications(product, combination, prevCombination); + + //update combination + await productAttributeService.UpdateProductAttributeCombination(combination, product.Id); + + if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes) + { + var pr = await productService.GetProductById(model.ProductId); + pr.StockQuantity = pr.ProductAttributeCombinations.Sum(x => x.StockQuantity); + pr.ReservedQuantity = pr.ProductAttributeCombinations.Sum(x => x.ReservedQuantity); + await inventoryManageService.UpdateStockProduct(pr, false); + } + } + + return warnings; + } + + public virtual async Task GenerateAllAttributeCombinations(Product product) + { + var allAttributesComb = product.GenerateAllCombinations(); + if (allAttributesComb == null || allAttributesComb.Count == 0) + return; + + foreach (var attributes in allAttributesComb) + { + var customAttributes = attributes.ToList(); + if (!customAttributes.Any()) + continue; + + var existingCombination = product.FindProductAttributeCombination(customAttributes); + + //already exists? + if (existingCombination != null) + continue; + + //save combination + var combination = new ProductAttributeCombination { + Attributes = customAttributes.ToList(), + StockQuantity = 0, + AllowOutOfStockOrders = false, + Sku = null, + Mpn = null, + Gtin = null, + OverriddenPrice = null, + NotifyAdminForQuantityBelow = 1 + }; + await productAttributeService.InsertProductAttributeCombination(combination, product.Id); + } + + if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes) + { + product.StockQuantity = product.ProductAttributeCombinations.Sum(x => x.StockQuantity); + product.ReservedQuantity = product.ProductAttributeCombinations.Sum(x => x.ReservedQuantity); + await inventoryManageService.UpdateStockProduct(product, false); + } + } + + public virtual async Task ClearAllAttributeCombinations(Product product) + { + foreach (var combination in product.ProductAttributeCombinations) + await productAttributeService.DeleteProductAttributeCombination(combination, product.Id); + } + + public virtual async Task> + PrepareProductAttributeCombinationTierPricesModel(Product product, string productAttributeCombinationId) + { + var items = new List(); + foreach (var x in product.ProductAttributeCombinations.Where(x => x.Id == productAttributeCombinationId) + .SelectMany(x => x.TierPrices)) + { + string storeName; + if (!string.IsNullOrEmpty(x.StoreId)) + { + var store = await storeService.GetStoreById(x.StoreId); + storeName = store != null ? store.Shortcut : "Deleted"; + } + else + { + storeName = translationService.GetResource("Admin.Catalog.Products.TierPrices.Fields.Store.All"); + } + + var priceModel = new ProductModel.ProductAttributeCombinationTierPricesModel { + Id = x.Id, + CustomerGroupId = x.CustomerGroupId, + CustomerGroup = !string.IsNullOrEmpty(x.CustomerGroupId) + ? (await groupService.GetCustomerGroupById(x.CustomerGroupId)).Name + : translationService.GetResource("Admin.Catalog.Products.TierPrices.Fields.CustomerGroup.All"), + StoreId = x.StoreId, + Store = storeName, + Price = x.Price, + Quantity = x.Quantity + }; + items.Add(priceModel); + } + + return items; + } + + public virtual async Task InsertProductAttributeCombinationTierPricesModel(Product product, + ProductAttributeCombination productAttributeCombination, + ProductModel.ProductAttributeCombinationTierPricesModel model) + { + model.CustomerGroupId = !string.IsNullOrEmpty(model.CustomerGroupId) ? model.CustomerGroupId.Trim() : ""; + + model.StoreId = !string.IsNullOrEmpty(model.StoreId) ? model.StoreId.Trim() : ""; + + if (productAttributeCombination != null) + { + var pctp = new ProductCombinationTierPrices { + Price = model.Price, + Quantity = model.Quantity, + StoreId = model.StoreId, + CustomerGroupId = model.CustomerGroupId + }; + productAttributeCombination.TierPrices.Add(pctp); + await productAttributeService.UpdateProductAttributeCombination(productAttributeCombination, product.Id); + } + } + + public virtual async Task UpdateProductAttributeCombinationTierPricesModel(Product product, + ProductAttributeCombination productAttributeCombination, + ProductModel.ProductAttributeCombinationTierPricesModel model) + { + model.CustomerGroupId = !string.IsNullOrEmpty(model.CustomerGroupId) ? model.CustomerGroupId.Trim() : ""; + model.StoreId = !string.IsNullOrEmpty(model.StoreId) ? model.StoreId.Trim() : ""; + + if (productAttributeCombination != null) + { + var tierPrice = productAttributeCombination.TierPrices.FirstOrDefault(x => x.Id == model.Id); + if (tierPrice != null) + { + tierPrice.Price = model.Price; + tierPrice.Quantity = model.Quantity; + tierPrice.StoreId = model.StoreId; + tierPrice.CustomerGroupId = model.CustomerGroupId; + await productAttributeService.UpdateProductAttributeCombination(productAttributeCombination, + product.Id); + } + } + } + + public virtual async Task DeleteProductAttributeCombinationTierPrices(Product product, + ProductAttributeCombination productAttributeCombination, ProductCombinationTierPrices tierPrice) + { + productAttributeCombination.TierPrices.Remove(tierPrice); + await productAttributeService.UpdateProductAttributeCombination(productAttributeCombination, product.Id); + } +} diff --git a/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs index 1f5d7f683..43ef0918b 100644 --- a/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs @@ -78,71 +78,6 @@ public class ProductViewModelService( IAdminDataScope scope) : IProductViewModelService { - public virtual async Task PrepareAddProductAttributeCombinationModel(ProductAttributeCombinationModel model, - Product product) - { - ArgumentNullException.ThrowIfNull(model); - ArgumentNullException.ThrowIfNull(product); - - if (product.UseMultipleWarehouses) model.UseMultipleWarehouses = product.UseMultipleWarehouses; - if (string.IsNullOrEmpty(model.Id)) - { - model.ProductId = product.Id; - var attributes = product.ProductAttributeMappings - .Where(x => !x.IsNonCombinable()) - .ToList(); - foreach (var attribute in attributes) - { - var productAttribute = - await productAttributeService.GetProductAttributeById(attribute.ProductAttributeId); - var attributeModel = new ProductAttributeCombinationModel.ProductAttributeModel { - Id = attribute.Id, - ProductAttributeId = attribute.ProductAttributeId, - Name = productAttribute.Name, - TextPrompt = attribute.TextPrompt, - IsRequired = attribute.IsRequired, - AttributeControlType = attribute.AttributeControlTypeId - }; - - if (attribute.ShouldHaveValues()) - { - //values - var attributeValues = attribute.ProductAttributeValues; - foreach (var attributeValue in attributeValues) - { - var attributeValueModel = new ProductAttributeCombinationModel.ProductAttributeValueModel { - Id = attributeValue.Id, - Name = attributeValue.Name, - IsPreSelected = attributeValue.IsPreSelected - }; - attributeModel.Values.Add(attributeValueModel); - } - } - - model.ProductAttributes.Add(attributeModel); - } - } - - - if (!string.IsNullOrEmpty(model.PictureId)) - { - var pictureThumbnailUrl = await pictureService.GetPictureUrl(model.PictureId, 100, false); - model.PictureThumbnailUrl = pictureThumbnailUrl; - } - - foreach (var picture in product.ProductPictures) - model.ProductPictureModels.Add(new ProductModel.ProductPictureModel { - Id = picture.Id, - ProductId = product.Id, - PictureId = picture.PictureId, - PictureUrl = await pictureService.GetPictureUrl(picture.PictureId), - DisplayOrder = picture.DisplayOrder, - IsDefault = picture.IsDefault - }); - model.PrimaryStoreCurrencyCode = - (await currencyService.GetCurrencyById(currencySettings.PrimaryStoreCurrencyId))?.CurrencyCode; - } - public virtual async Task OutOfStockNotifications(Product product, int prevStockQuantity, List prevMultiWarehouseStock ) @@ -177,40 +112,6 @@ await outOfStockSubscriptionService.SendNotificationsToSubscribers(product, } } - public virtual async Task OutOfStockNotifications(Product product, ProductAttributeCombination combination, - ProductAttributeCombination prevcombination) - { - if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes && - product.BackorderModeId == BackorderMode.NoBackorders && - product.AllowOutOfStockSubscriptions && - combination.StockQuantity > 0 && - prevcombination.StockQuantity <= 0 && !product.UseMultipleWarehouses && - product.Published) - await outOfStockSubscriptionService.SendNotificationsToSubscribers(product, combination.Attributes, ""); - if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes && - product.BackorderModeId == BackorderMode.NoBackorders && - product.AllowOutOfStockSubscriptions && - product.UseMultipleWarehouses && - product.Published) - { - foreach (var prevstock in prevcombination.WarehouseInventory) - if (prevstock.StockQuantity - prevstock.ReservedQuantity <= 0) - { - var actualStock = - combination.WarehouseInventory.FirstOrDefault(x => x.WarehouseId == prevstock.WarehouseId); - if (actualStock != null) - if (actualStock.StockQuantity - actualStock.ReservedQuantity > 0) - await outOfStockSubscriptionService.SendNotificationsToSubscribers(product, - combination.Attributes, prevstock.WarehouseId); - } - - if (combination.WarehouseInventory.Sum(x => x.StockQuantity - x.ReservedQuantity) > 0) - if (prevcombination.WarehouseInventory.Sum(x => x.StockQuantity - x.ReservedQuantity) <= 0) - await outOfStockSubscriptionService.SendNotificationsToSubscribers(product, combination.Attributes, - ""); - } - } - public virtual async Task PrepareProductModel(ProductModel model, Product product, bool setPredefinedValues, bool excludeProperties) { @@ -1047,411 +948,6 @@ public virtual async Task DeleteBulkEdit(IEnumerable produ return model; } - public virtual async Task> - PrepareProductAttributeCombinationModel(Product product) - { - var items = new List(); - foreach (var x in product.ProductAttributeCombinations) - { - var attributes = await productAttributeFormatter.FormatAttributes(product, x.Attributes, - contextAccessor.CalculationContext(), contextAccessor.WorkContext.WorkingLanguage, - contextAccessor.StoreContext.CurrentHost, "
", true, true, true, false, true, true); - var pacModel = new ProductModel.ProductAttributeCombinationModel { - Id = x.Id, - ProductId = product.Id, - Attributes = string.IsNullOrEmpty(attributes) ? "(null)" : attributes, - StockQuantity = product.UseMultipleWarehouses - ? x.WarehouseInventory.Sum(y => y.StockQuantity - y.ReservedQuantity) - : x.StockQuantity, - AllowOutOfStockOrders = x.AllowOutOfStockOrders, - Sku = x.Sku, - Mpn = x.Mpn, - Gtin = x.Gtin, - OverriddenPrice = x.OverriddenPrice, - NotifyAdminForQuantityBelow = x.NotifyAdminForQuantityBelow - }; - items.Add(pacModel); - } - - return items; - } - - public virtual async Task PrepareProductAttributeCombinationModel(Product product, - string combinationId) - { - var model = new ProductAttributeCombinationModel(); - var wim = new List(); - foreach (var warehouse in await warehouseService.GetAllWarehouses()) - { - var pwiModel = new ProductAttributeCombinationModel.WarehouseInventoryModel { - WarehouseId = warehouse.Id, - WarehouseName = warehouse.Name - }; - wim.Add(pwiModel); - } - - if (product.UseMultipleWarehouses) - { - model.UseMultipleWarehouses = product.UseMultipleWarehouses; - model.WarehouseInventoryModels = wim; - } - - if (!string.IsNullOrEmpty(combinationId)) - { - var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == combinationId); - if (combination != null) - { - model = combination.ToModel(); - model.UseMultipleWarehouses = product.UseMultipleWarehouses; - model.WarehouseInventoryModels = wim; - model.ProductId = product.Id; - model.Attributes = await productAttributeFormatter.FormatAttributes(product, combination.Attributes, - contextAccessor.CalculationContext(), contextAccessor.WorkContext.WorkingLanguage, - contextAccessor.StoreContext.CurrentHost, "
", true, true, true, false); - if (model.UseMultipleWarehouses) - foreach (var _winv in combination.WarehouseInventory) - { - var warehouseInventoryModel = - model.WarehouseInventoryModels.FirstOrDefault(x => x.WarehouseId == _winv.WarehouseId); - if (warehouseInventoryModel != null) - { - warehouseInventoryModel.WarehouseUsed = true; - warehouseInventoryModel.Id = _winv.Id; - warehouseInventoryModel.StockQuantity = _winv.StockQuantity; - warehouseInventoryModel.ReservedQuantity = _winv.ReservedQuantity; - } - } - } - } - - return model; - } - - public virtual async Task> InsertOrUpdateProductAttributeCombinationPopup(Product product, - ProductAttributeCombinationModel model) - { - var customAttributes = new List(); - var warnings = new List(); - - async Task PrepareCombinationWarehouseInventory(ProductAttributeCombination combination) - { - var warehouses = await warehouseService.GetAllWarehouses(); - - foreach (var warehouse in warehouses) - { - var whim = model.WarehouseInventoryModels.FirstOrDefault(x => x.WarehouseId == warehouse.Id); - var existingPwI = combination.WarehouseInventory.FirstOrDefault(x => x.WarehouseId == warehouse.Id); - if (existingPwI != null) - { - if (whim is { WarehouseUsed: true }) - { - //update - existingPwI.StockQuantity = whim.StockQuantity; - existingPwI.ReservedQuantity = whim.ReservedQuantity; - } - else - { - //delete - combination.WarehouseInventory.Remove(existingPwI); - } - } - else - { - if (whim is { WarehouseUsed: true }) - { - //no need to insert a record for qty 0 - existingPwI = new ProductCombinationWarehouseInventory { - WarehouseId = whim.WarehouseId, - StockQuantity = whim.StockQuantity, - ReservedQuantity = whim.ReservedQuantity - }; - combination.WarehouseInventory.Add(existingPwI); - } - } - } - } - - if (string.IsNullOrEmpty(model.Id)) - { - #region Product attributes - - var attributes = product.ProductAttributeMappings - .Where(x => !x.IsNonCombinable()) - .ToList(); - if (attributes.Count == 0) - { - warnings.Add("This combination attributes is empty!"); - return warnings; - } - - foreach (var attribute in attributes) - switch (attribute.AttributeControlTypeId) - { - case AttributeControlType.DropdownList: - case AttributeControlType.RadioList: - case AttributeControlType.ColorSquares: - case AttributeControlType.ImageSquares: - { - var ctrlAttributes = model.SelectedAttributes.FirstOrDefault(x => x.Key == attribute.Id)?.Value; - if (!string.IsNullOrEmpty(ctrlAttributes)) - customAttributes = ProductExtensions.AddProductAttribute(customAttributes, - attribute, ctrlAttributes).ToList(); - } - break; - case AttributeControlType.Checkboxes: - { - var cblAttributes = model.SelectedAttributes.FirstOrDefault(x => x.Key == attribute.Id)?.Value; - if (!string.IsNullOrEmpty(cblAttributes)) - foreach (var item in cblAttributes.Split([','], - StringSplitOptions.RemoveEmptyEntries)) - if (!string.IsNullOrEmpty(item)) - customAttributes = ProductExtensions.AddProductAttribute(customAttributes, - attribute, item).ToList(); - } - break; - case AttributeControlType.ReadonlyCheckboxes: - { - //load read-only (already server-side selected) values - var attributeValues = attribute.ProductAttributeValues; - foreach (var selectedAttributeId in attributeValues - .Where(v => v.IsPreSelected) - .Select(v => v.Id) - .ToList()) - customAttributes = ProductExtensions.AddProductAttribute(customAttributes, - attribute, selectedAttributeId).ToList(); - } - break; - } - - //validate conditional attributes (if specified) - foreach (var attribute in attributes) - { - var conditionMet = product.IsConditionMet(attribute, customAttributes); - if (conditionMet.HasValue && !conditionMet.Value) - customAttributes = ProductExtensions.RemoveProductAttribute(customAttributes, attribute).ToList(); - } - - if (customAttributes.Count == 0) - { - warnings.Add("This combination custom attributes is empty!"); - return warnings; - } - - foreach (var customAttribute in customAttributes) - if (string.IsNullOrEmpty(customAttribute.Value)) - { - warnings.Add("Combination custom attributes need to be selected value!"); - return warnings; - } - - #endregion - - if (product.FindProductAttributeCombination(customAttributes) != null) - warnings.Add("This combination attributes exists!"); - if (warnings.Count == 0) - { - var combination = new ProductAttributeCombination { - Attributes = customAttributes, - StockQuantity = model.StockQuantity, - ReservedQuantity = model.ReservedQuantity, - AllowOutOfStockOrders = model.AllowOutOfStockOrders, - Sku = model.Sku, - Text = model.Text, - Mpn = model.Mpn, - Gtin = model.Gtin, - OverriddenPrice = model.OverriddenPrice, - NotifyAdminForQuantityBelow = model.NotifyAdminForQuantityBelow, - PictureId = model.PictureId - }; - - if (product.UseMultipleWarehouses) - { - await PrepareCombinationWarehouseInventory(combination); - combination.StockQuantity = combination.WarehouseInventory.Sum(x => x.StockQuantity); - combination.ReservedQuantity = combination.WarehouseInventory.Sum(x => x.ReservedQuantity); - } - - await productAttributeService.InsertProductAttributeCombination(combination, product.Id); - - if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes) - { - product.StockQuantity = product.ProductAttributeCombinations.Sum(x => x.StockQuantity); - product.ReservedQuantity = product.ProductAttributeCombinations.Sum(x => x.ReservedQuantity); - await inventoryManageService.UpdateStockProduct(product, false); - } - } - } - else - { - var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == model.Id); - var prevCombination = (ProductAttributeCombination)combination!.Clone(); - - combination.StockQuantity = model.StockQuantity; - combination.ReservedQuantity = model.ReservedQuantity; - combination.AllowOutOfStockOrders = model.AllowOutOfStockOrders; - combination.Sku = model.Sku; - combination.Text = model.Text; - combination.Mpn = model.Mpn; - combination.Gtin = model.Gtin; - combination.OverriddenPrice = model.OverriddenPrice; - combination.NotifyAdminForQuantityBelow = model.NotifyAdminForQuantityBelow; - combination.PictureId = model.PictureId; - - if (product.UseMultipleWarehouses) - { - await PrepareCombinationWarehouseInventory(combination); - combination.StockQuantity = combination.WarehouseInventory.Sum(x => x.StockQuantity); - } - - //notification - out of stock - await OutOfStockNotifications(product, combination, prevCombination); - - //update combination - await productAttributeService.UpdateProductAttributeCombination(combination, product.Id); - - if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes) - { - var pr = await productService.GetProductById(model.ProductId); - pr.StockQuantity = pr.ProductAttributeCombinations.Sum(x => x.StockQuantity); - pr.ReservedQuantity = pr.ProductAttributeCombinations.Sum(x => x.ReservedQuantity); - await inventoryManageService.UpdateStockProduct(pr, false); - } - } - - return warnings; - } - - public virtual async Task GenerateAllAttributeCombinations(Product product) - { - var allAttributesComb = product.GenerateAllCombinations(); - if (allAttributesComb == null || allAttributesComb.Count == 0) - return; - - foreach (var attributes in allAttributesComb) - { - var customAttributes = attributes.ToList(); - if (!customAttributes.Any()) - continue; - - var existingCombination = product.FindProductAttributeCombination(customAttributes); - - //already exists? - if (existingCombination != null) - continue; - - //save combination - var combination = new ProductAttributeCombination { - Attributes = customAttributes.ToList(), - StockQuantity = 0, - AllowOutOfStockOrders = false, - Sku = null, - Mpn = null, - Gtin = null, - OverriddenPrice = null, - NotifyAdminForQuantityBelow = 1 - }; - await productAttributeService.InsertProductAttributeCombination(combination, product.Id); - } - - if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes) - { - product.StockQuantity = product.ProductAttributeCombinations.Sum(x => x.StockQuantity); - product.ReservedQuantity = product.ProductAttributeCombinations.Sum(x => x.ReservedQuantity); - await inventoryManageService.UpdateStockProduct(product, false); - } - } - - public virtual async Task ClearAllAttributeCombinations(Product product) - { - foreach (var combination in product.ProductAttributeCombinations) - await productAttributeService.DeleteProductAttributeCombination(combination, product.Id); - } - - public virtual async Task> - PrepareProductAttributeCombinationTierPricesModel(Product product, string productAttributeCombinationId) - { - var items = new List(); - foreach (var x in product.ProductAttributeCombinations.Where(x => x.Id == productAttributeCombinationId) - .SelectMany(x => x.TierPrices)) - { - string storeName; - if (!string.IsNullOrEmpty(x.StoreId)) - { - var store = await storeService.GetStoreById(x.StoreId); - storeName = store != null ? store.Shortcut : "Deleted"; - } - else - { - storeName = translationService.GetResource("Admin.Catalog.Products.TierPrices.Fields.Store.All"); - } - - var priceModel = new ProductModel.ProductAttributeCombinationTierPricesModel { - Id = x.Id, - CustomerGroupId = x.CustomerGroupId, - CustomerGroup = !string.IsNullOrEmpty(x.CustomerGroupId) - ? (await groupService.GetCustomerGroupById(x.CustomerGroupId)).Name - : translationService.GetResource("Admin.Catalog.Products.TierPrices.Fields.CustomerGroup.All"), - StoreId = x.StoreId, - Store = storeName, - Price = x.Price, - Quantity = x.Quantity - }; - items.Add(priceModel); - } - - return items; - } - - public virtual async Task InsertProductAttributeCombinationTierPricesModel(Product product, - ProductAttributeCombination productAttributeCombination, - ProductModel.ProductAttributeCombinationTierPricesModel model) - { - model.CustomerGroupId = !string.IsNullOrEmpty(model.CustomerGroupId) ? model.CustomerGroupId.Trim() : ""; - - model.StoreId = !string.IsNullOrEmpty(model.StoreId) ? model.StoreId.Trim() : ""; - - if (productAttributeCombination != null) - { - var pctp = new ProductCombinationTierPrices { - Price = model.Price, - Quantity = model.Quantity, - StoreId = model.StoreId, - CustomerGroupId = model.CustomerGroupId - }; - productAttributeCombination.TierPrices.Add(pctp); - await productAttributeService.UpdateProductAttributeCombination(productAttributeCombination, product.Id); - } - } - - public virtual async Task UpdateProductAttributeCombinationTierPricesModel(Product product, - ProductAttributeCombination productAttributeCombination, - ProductModel.ProductAttributeCombinationTierPricesModel model) - { - model.CustomerGroupId = !string.IsNullOrEmpty(model.CustomerGroupId) ? model.CustomerGroupId.Trim() : ""; - model.StoreId = !string.IsNullOrEmpty(model.StoreId) ? model.StoreId.Trim() : ""; - - if (productAttributeCombination != null) - { - var tierPrice = productAttributeCombination.TierPrices.FirstOrDefault(x => x.Id == model.Id); - if (tierPrice != null) - { - tierPrice.Price = model.Price; - tierPrice.Quantity = model.Quantity; - tierPrice.StoreId = model.StoreId; - tierPrice.CustomerGroupId = model.CustomerGroupId; - await productAttributeService.UpdateProductAttributeCombination(productAttributeCombination, - product.Id); - } - } - } - - public virtual async Task DeleteProductAttributeCombinationTierPrices(Product product, - ProductAttributeCombination productAttributeCombination, ProductCombinationTierPrices tierPrice) - { - productAttributeCombination.TierPrices.Remove(tierPrice); - await productAttributeService.UpdateProductAttributeCombination(productAttributeCombination, product.Id); - } - protected virtual async Task UpdatePictureSeoNames(Product product) { var picturesename = pictureService.GetPictureSeName(product.Name); diff --git a/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs index 394cefd0e..2cb15ddbc 100644 --- a/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs +++ b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs @@ -77,6 +77,7 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Web/Grand.Web.Store/Controllers/ProductAttributeCombinationController.cs b/src/Web/Grand.Web.Store/Controllers/ProductAttributeCombinationController.cs index a2d89bd69..72987c514 100644 --- a/src/Web/Grand.Web.Store/Controllers/ProductAttributeCombinationController.cs +++ b/src/Web/Grand.Web.Store/Controllers/ProductAttributeCombinationController.cs @@ -14,9 +14,9 @@ namespace Grand.Web.Store.Controllers; [AuthorizeStore] [AuthorizeMenu] public class ProductAttributeCombinationController( - IProductViewModelService productViewModelService, + IProductAttributeCombinationViewModelService productAttributeCombinationViewModelService, IProductService productService, IInventoryManageService inventoryManageService, IRequestTranslationService translationService, IAdminDataScope scope) - : BaseProductAttributeCombinationController(productViewModelService, productService, inventoryManageService, translationService, scope); + : BaseProductAttributeCombinationController(productAttributeCombinationViewModelService, productService, inventoryManageService, translationService, scope); diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductAttributeCombinationController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductAttributeCombinationController.cs index 4c2d5aaba..19385071e 100644 --- a/src/Web/Grand.Web.Vendor/Controllers/ProductAttributeCombinationController.cs +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductAttributeCombinationController.cs @@ -14,9 +14,9 @@ namespace Grand.Web.Vendor.Controllers; [AuthorizeVendor] [AuthorizeMenu] public class ProductAttributeCombinationController( - IProductViewModelService productViewModelService, + IProductAttributeCombinationViewModelService productAttributeCombinationViewModelService, IProductService productService, IInventoryManageService inventoryManageService, IRequestTranslationService translationService, IAdminDataScope scope) - : BaseProductAttributeCombinationController(productViewModelService, productService, inventoryManageService, translationService, scope); + : BaseProductAttributeCombinationController(productAttributeCombinationViewModelService, productService, inventoryManageService, translationService, scope); From edf465117e284ff269e19c5258d40bc4c31dbd2e Mon Sep 17 00:00:00 2001 From: support Date: Thu, 22 Oct 2026 07:51:31 +0200 Subject: [PATCH 23/27] Drop the tab dependencies left on the product view-model service --- .../Services/ProductViewModelServiceTests.cs | 17 ----------------- .../Interfaces/IProductViewModelService.cs | 1 - .../Services/ProductViewModelService.cs | 16 ---------------- 3 files changed, 34 deletions(-) diff --git a/src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs b/src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs index 490e75568..c5e8f6aff 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs @@ -1,14 +1,11 @@ using Grand.Business.Core.Interfaces.Catalog.Brands; using Grand.Business.Core.Interfaces.Catalog.Categories; -using Grand.Business.Core.Interfaces.Catalog.Collections; using Grand.Business.Core.Interfaces.Catalog.Directory; using Grand.Business.Core.Interfaces.Catalog.Discounts; -using Grand.Business.Core.Interfaces.Catalog.Prices; using Grand.Business.Core.Interfaces.Catalog.Products; using Grand.Business.Core.Interfaces.Catalog.Tax; using Grand.Business.Core.Interfaces.Checkout.Shipping; using Grand.Business.Core.Interfaces.Common.Directory; -using Grand.Business.Core.Interfaces.Common.Localization; using Grand.Business.Core.Interfaces.Common.Seo; using Grand.Business.Core.Interfaces.Common.Stores; using Grand.Business.Core.Interfaces.Customers; @@ -46,7 +43,6 @@ public class ProductViewModelServiceTests private Mock _discountServiceMock; private Mock _enumTranslationServiceMock; - private Mock _groupServiceMock; private Mock _measureServiceMock; private Mock _productServiceMock; private ProductViewModelService _productViewModelService; @@ -65,7 +61,6 @@ public void Setup() _discountServiceMock = new Mock(); _enumTranslationServiceMock = new Mock(); - _groupServiceMock = new Mock(); _measureServiceMock = new Mock(); _productServiceMock = new Mock(); _storeServiceMock = new Mock(); @@ -111,9 +106,6 @@ public void Setup() _storeServiceMock.Setup(s => s.GetAllStores()).ReturnsAsync(new List()); - _groupServiceMock.Setup(g => g.GetAllCustomerGroups(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new PagedList()); - _enumTranslationServiceMock .Setup(e => e.ToSelectList(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new SelectList(Enumerable.Empty())); @@ -136,16 +128,11 @@ public void Setup() _measureServiceMock.Object, new Mock().Object, new Mock().Object, - new Mock().Object, - new Mock().Object, new Mock().Object, - new Mock().Object, _vendorServiceMock.Object, _translationServiceMock.Object, productLayoutServiceMock.Object, - new Mock().Object, contextAccessorMock.Object, - _groupServiceMock.Object, _warehouseServiceMock.Object, deliveryDateServiceMock.Object, _taxCategoryServiceMock.Object, @@ -154,15 +141,11 @@ public void Setup() _storeServiceMock.Object, new Mock().Object, new Mock().Object, - new Mock().Object, - new Mock().Object, new Mock().Object, new CurrencySettings(), new MeasureSettings(), new TaxSettings(), new SeoSettings(), - new Mock().Object, - new Mock().Object, new Mock().Object, _enumTranslationServiceMock.Object, _scopeMock.Object); diff --git a/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs b/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs index a58a95897..968e8104d 100644 --- a/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs @@ -1,5 +1,4 @@ using Grand.Domain.Catalog; -using Grand.Domain.Media; using Grand.Web.AdminShared.Models.Catalog; namespace Grand.Web.AdminShared.Interfaces; diff --git a/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs index 43ef0918b..4ee08325a 100644 --- a/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs @@ -1,26 +1,20 @@ using Grand.Business.Core.Extensions; using Grand.Business.Core.Interfaces.Catalog.Brands; using Grand.Business.Core.Interfaces.Catalog.Categories; -using Grand.Business.Core.Interfaces.Catalog.Collections; using Grand.Business.Core.Interfaces.Catalog.Directory; using Grand.Business.Core.Interfaces.Catalog.Discounts; -using Grand.Business.Core.Interfaces.Catalog.Prices; using Grand.Business.Core.Interfaces.Catalog.Products; using Grand.Business.Core.Interfaces.Catalog.Tax; using Grand.Business.Core.Interfaces.Checkout.Shipping; using Grand.Business.Core.Interfaces.Common.Directory; -using Grand.Business.Core.Interfaces.Common.Localization; using Grand.Business.Core.Interfaces.Common.Seo; using Grand.Business.Core.Interfaces.Common.Stores; using Grand.Business.Core.Interfaces.Customers; using Grand.Business.Core.Interfaces.Storage; using Grand.Business.Core.Utilities.Catalog; using Grand.Domain.Catalog; -using Grand.Domain.Common; using Grand.Domain.Directory; using Grand.Domain.Discounts; -using Grand.Domain.Localization; -using Grand.Domain.Media; using Grand.Domain.Seo; using Grand.Domain.Tax; using Grand.Infrastructure; @@ -32,7 +26,6 @@ using Grand.Web.Common.Localization; using Microsoft.AspNetCore.Mvc.Rendering; using System.Net; -using ProductExtensions = Grand.Domain.Catalog.ProductExtensions; namespace Grand.Web.AdminShared.Services; @@ -46,16 +39,11 @@ public class ProductViewModelService( IMeasureService measureService, IDateTimeService dateTimeService, IBrandService brandService, - ICollectionService collectionService, - IProductCollectionService productCollectionService, ICategoryService categoryService, - IProductCategoryService productCategoryService, IVendorService vendorService, IRequestTranslationService translationService, IProductLayoutService productLayoutService, - ISpecificationAttributeService specificationAttributeService, IContextAccessor contextAccessor, - IGroupService groupService, IWarehouseService warehouseService, IDeliveryDateService deliveryDateService, ITaxCategoryService taxCategoryService, @@ -64,15 +52,11 @@ public class ProductViewModelService( IStoreService storeService, IOutOfStockSubscriptionService outOfStockSubscriptionService, IDownloadService downloadService, - ILanguageService languageService, - IProductAttributeFormatter productAttributeFormatter, IStockQuantityService stockQuantityService, CurrencySettings currencySettings, MeasureSettings measureSettings, TaxSettings taxSettings, SeoSettings seoSettings, - IAuctionService auctionService, - IPriceFormatter priceFormatter, ISeNameService seNameService, IEnumTranslationService enumTranslationService, IAdminDataScope scope) From 123522a66b67aedd67f8df817c72de246981b9ca Mon Sep 17 00:00:00 2001 From: support Date: Thu, 22 Oct 2026 11:56:16 +0200 Subject: [PATCH 24/27] Remove the stray bash.exe.stackdump from the Admin tests --- .../Controllers/bash.exe.stackdump | 29 ------------------- 1 file changed, 29 deletions(-) delete mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/bash.exe.stackdump diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/bash.exe.stackdump b/src/Tests/Grand.Web.Admin.Tests/Controllers/bash.exe.stackdump deleted file mode 100644 index aa91f7e17..000000000 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/bash.exe.stackdump +++ /dev/null @@ -1,29 +0,0 @@ -Stack trace: -Frame Function Args -0007FFFFBEF0 00021005FE8E (000210285F68, 00021026AB6E, 000000000000, 0007FFFFADF0) msys-2.0.dll+0x1FE8E -0007FFFFBEF0 0002100467F9 (000000000000, 000000000000, 000000000000, 0007FFFFC1C8) msys-2.0.dll+0x67F9 -0007FFFFBEF0 000210046832 (000210286019, 0007FFFFBDA8, 000000000000, 000000000000) msys-2.0.dll+0x6832 -0007FFFFBEF0 000210068CF6 (000000000000, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x28CF6 -0007FFFFBEF0 000210068E24 (0007FFFFBF00, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x28E24 -0007FFFFC1D0 00021006A225 (0007FFFFBF00, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x2A225 -End of stack trace -Loaded modules: -000100400000 bash.exe -7FFEBEF20000 ntdll.dll -7FFEBE1D0000 KERNEL32.DLL -7FFEBC110000 KERNELBASE.dll -7FFEB8800000 apphelp.dll -7FFEBD680000 USER32.dll -7FFEBC520000 win32u.dll -000210040000 msys-2.0.dll -7FFEBE4B0000 GDI32.dll -7FFEBCB00000 gdi32full.dll -7FFEBC8F0000 msvcp_win.dll -7FFEBC9A0000 ucrtbase.dll -7FFEBD860000 advapi32.dll -7FFEBE400000 msvcrt.dll -7FFEBD930000 sechost.dll -7FFEBE2B0000 RPCRT4.dll -7FFEBB690000 CRYPTBASE.DLL -7FFEBC830000 bcryptPrimitives.dll -7FFEBD9E0000 IMM32.DLL From c25422742e4ef127b5ebdc6c97ee3b46ca805957 Mon Sep 17 00:00:00 2001 From: support Date: Thu, 22 Oct 2026 14:06:25 +0200 Subject: [PATCH 25/27] Recalculate attribute combination stock on the loaded product The stock total now includes a newly added combination, a multi-warehouse update sums reserved quantity as the insert does, and saving a combination that no longer exists returns a warning. --- ...tributeCombinationViewModelServiceTests.cs | 155 ++++++++++++++++++ ...uctAttributeCombinationViewModelService.cs | 19 ++- 2 files changed, 168 insertions(+), 6 deletions(-) create mode 100644 src/Tests/Grand.Web.Admin.Tests/Services/ProductAttributeCombinationViewModelServiceTests.cs diff --git a/src/Tests/Grand.Web.Admin.Tests/Services/ProductAttributeCombinationViewModelServiceTests.cs b/src/Tests/Grand.Web.Admin.Tests/Services/ProductAttributeCombinationViewModelServiceTests.cs new file mode 100644 index 000000000..eda47d323 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Services/ProductAttributeCombinationViewModelServiceTests.cs @@ -0,0 +1,155 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Catalog.Tax; +using Grand.Business.Core.Interfaces.Checkout.Shipping; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Stores; +using Grand.Business.Core.Interfaces.Storage; +using Grand.Domain; +using Grand.Domain.Catalog; +using Grand.Domain.Common; +using Grand.Domain.Directory; +using Grand.Domain.Shipping; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Services; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Models; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Admin.Tests.Services; + +[TestClass] +public class ProductAttributeCombinationViewModelServiceTests +{ + private Mock _inventoryManageServiceMock; + private Mock _productAttributeServiceMock; + private Mock _warehouseServiceMock; + private ProductAttributeCombinationViewModelService _service; + + [TestInitialize] + public void Setup() + { + _inventoryManageServiceMock = new Mock(); + _productAttributeServiceMock = new Mock(); + _warehouseServiceMock = new Mock(); + + _service = new ProductAttributeCombinationViewModelService( + _inventoryManageServiceMock.Object, + new Mock().Object, + _productAttributeServiceMock.Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + _warehouseServiceMock.Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new CurrencySettings()); + } + + private static Product ProductWithOneCombination() + { + var product = new Product { + Id = "p1", + ManageInventoryMethodId = ManageInventoryMethod.ManageStockByAttributes + }; + var mapping = new ProductAttributeMapping { + Id = "m1", + Combination = true, + AttributeControlTypeId = AttributeControlType.DropdownList + }; + mapping.ProductAttributeValues.Add(new ProductAttributeValue { Id = "v1" }); + mapping.ProductAttributeValues.Add(new ProductAttributeValue { Id = "v2" }); + product.ProductAttributeMappings.Add(mapping); + product.ProductAttributeCombinations.Add(new ProductAttributeCombination { + Id = "c1", + Attributes = [new CustomAttribute { Key = "m1", Value = "v2" }], + StockQuantity = 5, + ReservedQuantity = 1 + }); + return product; + } + + [TestMethod] + public async Task Insert_ManageStockByAttributes_ProductStockIncludesTheNewCombination() + { + var product = ProductWithOneCombination(); + var model = new ProductAttributeCombinationModel { + SelectedAttributes = [new CustomAttributeModel { Key = "m1", Value = "v1" }], + StockQuantity = 7, + ReservedQuantity = 2 + }; + + var warnings = await _service.InsertOrUpdateProductAttributeCombinationPopup(product, model); + + Assert.AreEqual(0, warnings.Count); + _inventoryManageServiceMock.Verify(i => i.UpdateStockProduct( + It.Is(p => p.Id == "p1" && p.StockQuantity == 12 && p.ReservedQuantity == 3), false), Times.Once); + } + + [TestMethod] + public async Task Update_ManageStockByAttributes_RecalculatesTheGivenProductNotModelProductId() + { + var product = ProductWithOneCombination(); + var model = new ProductAttributeCombinationModel { + Id = "c1", + ProductId = "another-product", + StockQuantity = 3, + ReservedQuantity = 0 + }; + + var warnings = await _service.InsertOrUpdateProductAttributeCombinationPopup(product, model); + + Assert.AreEqual(0, warnings.Count); + _productAttributeServiceMock.Verify(s => s.UpdateProductAttributeCombination( + It.Is(c => c.Id == "c1"), "p1"), Times.Once); + _inventoryManageServiceMock.Verify(i => i.UpdateStockProduct( + It.Is(p => p.Id == "p1" && p.StockQuantity == 3), false), Times.Once); + _inventoryManageServiceMock.Verify(i => i.UpdateStockProduct( + It.Is(p => p.Id != "p1"), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Update_UnknownCombination_ReturnsWarningWithoutSaving() + { + var product = ProductWithOneCombination(); + var model = new ProductAttributeCombinationModel { Id = "missing" }; + + var warnings = await _service.InsertOrUpdateProductAttributeCombinationPopup(product, model); + + Assert.AreEqual(1, warnings.Count); + _productAttributeServiceMock.Verify(s => s.UpdateProductAttributeCombination( + It.IsAny(), It.IsAny()), Times.Never); + _inventoryManageServiceMock.Verify(i => i.UpdateStockProduct(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task Update_MultipleWarehouses_ReservedQuantityIsTheWarehouseSum() + { + var product = ProductWithOneCombination(); + product.UseMultipleWarehouses = true; + _warehouseServiceMock.Setup(w => w.GetAllWarehouses(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new PagedList { new() { Id = "w1" } }); + var model = new ProductAttributeCombinationModel { + Id = "c1", + StockQuantity = 100, + ReservedQuantity = 90, + WarehouseInventoryModels = [ + new ProductAttributeCombinationModel.WarehouseInventoryModel { + WarehouseId = "w1", WarehouseUsed = true, StockQuantity = 4, ReservedQuantity = 2 + } + ] + }; + + await _service.InsertOrUpdateProductAttributeCombinationPopup(product, model); + + var combination = product.ProductAttributeCombinations.Single(c => c.Id == "c1"); + Assert.AreEqual(4, combination.StockQuantity); + Assert.AreEqual(2, combination.ReservedQuantity); + _inventoryManageServiceMock.Verify(i => i.UpdateStockProduct( + It.Is(p => p.StockQuantity == 4 && p.ReservedQuantity == 2), false), Times.Once); + } +} diff --git a/src/Web/Grand.Web.AdminShared/Services/ProductAttributeCombinationViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/ProductAttributeCombinationViewModelService.cs index 9e892b11a..730b9cc00 100644 --- a/src/Web/Grand.Web.AdminShared/Services/ProductAttributeCombinationViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/ProductAttributeCombinationViewModelService.cs @@ -20,7 +20,6 @@ namespace Grand.Web.AdminShared.Services; public class ProductAttributeCombinationViewModelService( - IProductService productService, IInventoryManageService inventoryManageService, IPictureService pictureService, IProductAttributeService productAttributeService, @@ -359,6 +358,7 @@ async Task PrepareCombinationWarehouseInventory(ProductAttributeCombination comb } await productAttributeService.InsertProductAttributeCombination(combination, product.Id); + product.ProductAttributeCombinations.Add(combination); if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes) { @@ -371,7 +371,13 @@ async Task PrepareCombinationWarehouseInventory(ProductAttributeCombination comb else { var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == model.Id); - var prevCombination = (ProductAttributeCombination)combination!.Clone(); + if (combination == null) + { + warnings.Add("This combination doesn't exist!"); + return warnings; + } + + var prevCombination = (ProductAttributeCombination)combination.Clone(); combination.StockQuantity = model.StockQuantity; combination.ReservedQuantity = model.ReservedQuantity; @@ -388,6 +394,7 @@ async Task PrepareCombinationWarehouseInventory(ProductAttributeCombination comb { await PrepareCombinationWarehouseInventory(combination); combination.StockQuantity = combination.WarehouseInventory.Sum(x => x.StockQuantity); + combination.ReservedQuantity = combination.WarehouseInventory.Sum(x => x.ReservedQuantity); } //notification - out of stock @@ -398,10 +405,9 @@ async Task PrepareCombinationWarehouseInventory(ProductAttributeCombination comb if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes) { - var pr = await productService.GetProductById(model.ProductId); - pr.StockQuantity = pr.ProductAttributeCombinations.Sum(x => x.StockQuantity); - pr.ReservedQuantity = pr.ProductAttributeCombinations.Sum(x => x.ReservedQuantity); - await inventoryManageService.UpdateStockProduct(pr, false); + product.StockQuantity = product.ProductAttributeCombinations.Sum(x => x.StockQuantity); + product.ReservedQuantity = product.ProductAttributeCombinations.Sum(x => x.ReservedQuantity); + await inventoryManageService.UpdateStockProduct(product, false); } } @@ -438,6 +444,7 @@ public virtual async Task GenerateAllAttributeCombinations(Product product) NotifyAdminForQuantityBelow = 1 }; await productAttributeService.InsertProductAttributeCombination(combination, product.Id); + product.ProductAttributeCombinations.Add(combination); } if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes) From 886ff1360dbfde1cb2447640b0331d1903823882 Mon Sep 17 00:00:00 2001 From: support Date: Thu, 22 Oct 2026 14:06:26 +0200 Subject: [PATCH 26/27] Move the add-product popup view-model logic into its own service The product tab controllers no longer depend on the product view-model service. Also corrects the scope comments left behind by the split. --- .../BaseProductAssociatedControllerTests.cs | 16 +-- ...eProductAttributeMappingControllerTests.cs | 14 +- .../BaseProductBundleControllerTests.cs | 16 +-- .../Controllers/BaseProductControllerTests.cs | 12 +- .../BaseProductRelatedControllerTests.cs | 46 +++---- .../Controllers/ProductControllerTestsBase.cs | 2 + .../ProductAddPopupViewModelServiceTests.cs | 110 ++++++++++++++++ .../Services/ProductViewModelServiceTests.cs | 38 ------ .../Controllers/ProductControllerTests.cs | 1 + .../ProductAssociatedController.cs | 4 +- .../ProductAttributeMappingController.cs | 4 +- .../Controllers/ProductBundleController.cs | 4 +- .../Controllers/ProductController.cs | 3 +- .../Controllers/ProductRelatedController.cs | 4 +- .../BaseProductAssociatedController.cs | 22 ++-- .../BaseProductAttributeMappingController.cs | 6 +- .../BaseProductBundleController.cs | 8 +- .../Controllers/BaseProductController.cs | 5 +- .../BaseProductRelatedController.cs | 26 ++-- .../IProductAddPopupViewModelService.cs | 20 +++ .../Interfaces/IProductViewModelService.cs | 15 --- .../ProductAddPopupViewModelService.cs | 120 ++++++++++++++++++ .../Services/ProductBundleViewModelService.cs | 3 +- .../ProductRelatedViewModelService.cs | 2 + .../Services/ProductViewModelService.cs | 87 ------------- .../Startup/StartupApplication.cs | 1 + .../ProductAssociatedController.cs | 4 +- .../ProductAttributeMappingController.cs | 4 +- .../Controllers/ProductBundleController.cs | 4 +- .../Controllers/ProductController.cs | 3 +- .../Controllers/ProductRelatedController.cs | 4 +- .../ProductAssociatedController.cs | 4 +- .../ProductAttributeMappingController.cs | 4 +- .../Controllers/ProductBundleController.cs | 4 +- .../Controllers/ProductController.cs | 3 +- .../Controllers/ProductRelatedController.cs | 4 +- 36 files changed, 373 insertions(+), 254 deletions(-) create mode 100644 src/Tests/Grand.Web.Admin.Tests/Services/ProductAddPopupViewModelServiceTests.cs create mode 100644 src/Web/Grand.Web.AdminShared/Interfaces/IProductAddPopupViewModelService.cs create mode 100644 src/Web/Grand.Web.AdminShared/Services/ProductAddPopupViewModelService.cs diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAssociatedControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAssociatedControllerTests.cs index d8820ffb7..ff0f47de0 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAssociatedControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAssociatedControllerTests.cs @@ -23,15 +23,15 @@ public class BaseProductAssociatedControllerTests : ProductControllerTestsBase scope) - : BaseProductAssociatedController(productViewModelService, productAssociatedViewModelService, productService, translationService, scope); + : BaseProductAssociatedController(productAddPopupViewModelService, productAssociatedViewModelService, productService, translationService, scope); protected override TestProductAssociatedController CreateController() => new( - _productViewModelServiceMock.Object, + _productAddPopupViewModelServiceMock.Object, _productAssociatedViewModelServiceMock.Object, _productServiceMock.Object, _translationServiceMock.Object, @@ -159,7 +159,7 @@ public async Task AssociatedProductDelete_ScopeGrantsAccess_ValidModel_Deletes() public async Task AssociatedProductAddPopupGet_CallsPrepareAssociatedProductModel() { // PrepareAssociatedProductModel() now reads scope internally (Task 9). - _productViewModelServiceMock.Setup(s => s.PrepareAssociatedProductModel()) + _productAddPopupViewModelServiceMock.Setup(s => s.PrepareAssociatedProductModel()) .ReturnsAsync(new ProductModel.AddAssociatedProductModel()); var result = await _controller.AssociatedProductAddPopup("p1") as ViewResult; @@ -168,7 +168,7 @@ public async Task AssociatedProductAddPopupGet_CallsPrepareAssociatedProductMode var model = result.Model as ProductModel.AddAssociatedProductModel; Assert.IsNotNull(model); Assert.AreEqual("p1", model.ProductId); - _productViewModelServiceMock.Verify(s => s.PrepareAssociatedProductModel(), Times.Once); + _productAddPopupViewModelServiceMock.Verify(s => s.PrepareAssociatedProductModel(), Times.Once); } // --- AssociatedProductAddPopupList --------------------------------------------------------------- @@ -177,7 +177,7 @@ public async Task AssociatedProductAddPopupGet_CallsPrepareAssociatedProductMode public async Task AssociatedProductAddPopupList_UsesScopeDefaultStoreId() { _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); - _productViewModelServiceMock + _productAddPopupViewModelServiceMock .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) .ReturnsAsync((new List(), 0)); @@ -193,7 +193,7 @@ public async Task AssociatedProductAddPopupList_UsesScopeDefaultStoreId() public async Task AssociatedProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() { _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); - _productViewModelServiceMock + _productAddPopupViewModelServiceMock .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) .ReturnsAsync((new List(), 0)); @@ -297,7 +297,7 @@ public async Task AssociatedProductAddPopupPost_ScopeGrantsAccess_InvalidModel_R _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(parent); _scopeMock.Setup(s => s.HasAccess(parent)).ReturnsAsync(true); var reprepared = new ProductModel.AddAssociatedProductModel(); - _productViewModelServiceMock.Setup(s => s.PrepareAssociatedProductModel()).ReturnsAsync(reprepared); + _productAddPopupViewModelServiceMock.Setup(s => s.PrepareAssociatedProductModel()).ReturnsAsync(reprepared); var model = new ProductModel.AddAssociatedProductModel { ProductId = "p1" }; _controller.ModelState.AddModelError("x", "error"); diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAttributeMappingControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAttributeMappingControllerTests.cs index ef2443a89..fd9795909 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAttributeMappingControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAttributeMappingControllerTests.cs @@ -24,16 +24,16 @@ public class BaseProductAttributeMappingControllerTests : ProductControllerTests // BaseProductAttributeMappingController is abstract. This minimal subclass exists only so the actions under test // can be invoked directly. public class TestProductAttributeMappingController( - IProductViewModelService productViewModelService, + IProductAddPopupViewModelService productAddPopupViewModelService, IProductAttributeMappingViewModelService productAttributeMappingViewModelService, IProductService productService, ILanguageService languageService, IRequestTranslationService translationService, IAdminDataScope scope) - : BaseProductAttributeMappingController(productViewModelService, productAttributeMappingViewModelService, productService, languageService, translationService, scope); + : BaseProductAttributeMappingController(productAddPopupViewModelService, productAttributeMappingViewModelService, productService, languageService, translationService, scope); protected override TestProductAttributeMappingController CreateController() => new( - _productViewModelServiceMock.Object, + _productAddPopupViewModelServiceMock.Object, _productAttributeMappingViewModelServiceMock.Object, _productServiceMock.Object, _languageServiceMock.Object, @@ -1065,13 +1065,13 @@ public async Task ProductAttributeValueDelete_ScopeGrantsAccess_InvalidModelStat public async Task AssociateProductToAttributeValuePopup_Get_CallsPrepareAssociateProductToAttributeValueModel() { // PrepareAssociateProductToAttributeValueModel() now reads scope internally (Task 9). - _productViewModelServiceMock.Setup(s => s.PrepareAssociateProductToAttributeValueModel()) + _productAddPopupViewModelServiceMock.Setup(s => s.PrepareAssociateProductToAttributeValueModel()) .ReturnsAsync(new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel()); var result = await _controller.AssociateProductToAttributeValuePopup(); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.PrepareAssociateProductToAttributeValueModel(), + _productAddPopupViewModelServiceMock.Verify(s => s.PrepareAssociateProductToAttributeValueModel(), Times.Once); } @@ -1082,7 +1082,7 @@ public async Task AssociateProductToAttributeValuePopupList_DefaultStoreIdSet_Ap { _scopeMock.Setup(s => s.DefaultStoreId).Returns("store1"); var model = new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel(); - _productViewModelServiceMock.Setup(s => s.PrepareProductModel(model, 1, 10)) + _productAddPopupViewModelServiceMock.Setup(s => s.PrepareProductModel(model, 1, 10)) .ReturnsAsync((new List(), 0)); var result = await _controller.AssociateProductToAttributeValuePopupList( @@ -1097,7 +1097,7 @@ public async Task AssociateProductToAttributeValuePopupList_DefaultStoreIdNull_L { var model = new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel { SearchStoreId = "preset" }; - _productViewModelServiceMock.Setup(s => s.PrepareProductModel(model, 1, 10)) + _productAddPopupViewModelServiceMock.Setup(s => s.PrepareProductModel(model, 1, 10)) .ReturnsAsync((new List(), 0)); var result = await _controller.AssociateProductToAttributeValuePopupList( diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductBundleControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductBundleControllerTests.cs index ef3c9a372..bc8a65be6 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductBundleControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductBundleControllerTests.cs @@ -22,15 +22,15 @@ public class BaseProductBundleControllerTests : ProductControllerTestsBase scope) - : BaseProductBundleController(productViewModelService, productBundleViewModelService, productService, translationService, scope); + : BaseProductBundleController(productAddPopupViewModelService, productBundleViewModelService, productService, translationService, scope); protected override TestProductBundleController CreateController() => new( - _productViewModelServiceMock.Object, + _productAddPopupViewModelServiceMock.Object, _productBundleViewModelServiceMock.Object, _productServiceMock.Object, _translationServiceMock.Object, @@ -138,7 +138,7 @@ public async Task BundleProductDelete_ScopeGrantsAccess_ValidModel_Deletes() public async Task BundleProductAddPopupGet_CallsPrepareBundleProductModel() { // PrepareBundleProductModel() now reads scope internally (Task 9). - _productViewModelServiceMock.Setup(s => s.PrepareBundleProductModel()) + _productAddPopupViewModelServiceMock.Setup(s => s.PrepareBundleProductModel()) .ReturnsAsync(new ProductModel.AddBundleProductModel()); var result = await _controller.BundleProductAddPopup("p1") as ViewResult; @@ -147,7 +147,7 @@ public async Task BundleProductAddPopupGet_CallsPrepareBundleProductModel() var model = result.Model as ProductModel.AddBundleProductModel; Assert.IsNotNull(model); Assert.AreEqual("p1", model.ProductId); - _productViewModelServiceMock.Verify(s => s.PrepareBundleProductModel(), Times.Once); + _productAddPopupViewModelServiceMock.Verify(s => s.PrepareBundleProductModel(), Times.Once); } // --- BundleProductAddPopupList ----------------------------------------------------------------- @@ -156,7 +156,7 @@ public async Task BundleProductAddPopupGet_CallsPrepareBundleProductModel() public async Task BundleProductAddPopupList_UsesScopeDefaultStoreId() { _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); - _productViewModelServiceMock + _productAddPopupViewModelServiceMock .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) .ReturnsAsync((new List(), 0)); @@ -172,7 +172,7 @@ public async Task BundleProductAddPopupList_UsesScopeDefaultStoreId() public async Task BundleProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() { _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); - _productViewModelServiceMock + _productAddPopupViewModelServiceMock .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) .ReturnsAsync((new List(), 0)); @@ -227,7 +227,7 @@ public async Task BundleProductAddPopupPost_ScopeGrantsAccess_InvalidModel_Retur _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); var reprepared = new ProductModel.AddBundleProductModel(); - _productViewModelServiceMock.Setup(s => s.PrepareBundleProductModel()).ReturnsAsync(reprepared); + _productAddPopupViewModelServiceMock.Setup(s => s.PrepareBundleProductModel()).ReturnsAsync(reprepared); var model = new ProductModel.AddBundleProductModel { ProductId = "p1" }; _controller.ModelState.AddModelError("x", "error"); diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index b3a388005..3492ba202 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -33,17 +33,19 @@ public class BaseProductControllerTests : ProductControllerTestsBase scope) - : BaseProductController(productViewModelService, productService, languageService, translationService, + : BaseProductController(productViewModelService, productAddPopupViewModelService, productService, languageService, translationService, dateTimeService, permissionService, scope); protected override TestProductController CreateController() => new( _productViewModelServiceMock.Object, + _productAddPopupViewModelServiceMock.Object, _productServiceMock.Object, _languageServiceMock.Object, _translationServiceMock.Object, @@ -503,13 +505,13 @@ private static string GetTextProperty(object value) => public async Task RequiredProductAddPopup_CallsPrepareAddRequiredProductModel() { // PrepareAddRequiredProductModel() now reads scope internally (Task 9). - _productViewModelServiceMock.Setup(s => s.PrepareAddRequiredProductModel()) + _productAddPopupViewModelServiceMock.Setup(s => s.PrepareAddRequiredProductModel()) .ReturnsAsync(new ProductModel.AddRequiredProductModel()); var result = await _controller.RequiredProductAddPopup("input1") as ViewResult; Assert.IsNotNull(result); - _productViewModelServiceMock.Verify(s => s.PrepareAddRequiredProductModel(), Times.Once); + _productAddPopupViewModelServiceMock.Verify(s => s.PrepareAddRequiredProductModel(), Times.Once); Assert.AreEqual("input1", _controller.ViewBag.productIdsInput); } @@ -519,7 +521,7 @@ public async Task RequiredProductAddPopup_CallsPrepareAddRequiredProductModel() public async Task RequiredProductAddPopupList_UsesScopeDefaultStoreId() { _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); - _productViewModelServiceMock + _productAddPopupViewModelServiceMock .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) .ReturnsAsync((new List(), 0)); @@ -535,7 +537,7 @@ public async Task RequiredProductAddPopupList_UsesScopeDefaultStoreId() public async Task RequiredProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() { _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); - _productViewModelServiceMock + _productAddPopupViewModelServiceMock .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) .ReturnsAsync((new List(), 0)); diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductRelatedControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductRelatedControllerTests.cs index 5b1d660db..060bd5bf2 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductRelatedControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductRelatedControllerTests.cs @@ -23,15 +23,15 @@ public class BaseProductRelatedControllerTests : ProductControllerTestsBase scope) - : BaseProductRelatedController(productViewModelService, productRelatedViewModelService, productService, translationService, scope); + : BaseProductRelatedController(productAddPopupViewModelService, productRelatedViewModelService, productService, translationService, scope); protected override TestProductRelatedController CreateController() => new( - _productViewModelServiceMock.Object, + _productAddPopupViewModelServiceMock.Object, _productRelatedViewModelServiceMock.Object, _productServiceMock.Object, _translationServiceMock.Object, @@ -139,7 +139,7 @@ public async Task RelatedProductDelete_ScopeGrantsAccess_ValidModel_Deletes() public async Task RelatedProductAddPopupGet_CallsPrepareRelatedProductModel() { // PrepareRelatedProductModel() now reads scope internally (Task 9). - _productViewModelServiceMock.Setup(s => s.PrepareRelatedProductModel()) + _productAddPopupViewModelServiceMock.Setup(s => s.PrepareRelatedProductModel()) .ReturnsAsync(new ProductModel.AddRelatedProductModel()); var result = await _controller.RelatedProductAddPopup("p1") as ViewResult; @@ -148,7 +148,7 @@ public async Task RelatedProductAddPopupGet_CallsPrepareRelatedProductModel() var model = result.Model as ProductModel.AddRelatedProductModel; Assert.IsNotNull(model); Assert.AreEqual("p1", model.ProductId); - _productViewModelServiceMock.Verify(s => s.PrepareRelatedProductModel(), Times.Once); + _productAddPopupViewModelServiceMock.Verify(s => s.PrepareRelatedProductModel(), Times.Once); } // --- RelatedProductAddPopupList ----------------------------------------------------------------- @@ -157,7 +157,7 @@ public async Task RelatedProductAddPopupGet_CallsPrepareRelatedProductModel() public async Task RelatedProductAddPopupList_UsesScopeDefaultStoreId() { _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); - _productViewModelServiceMock + _productAddPopupViewModelServiceMock .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) .ReturnsAsync((new List(), 0)); @@ -173,7 +173,7 @@ public async Task RelatedProductAddPopupList_UsesScopeDefaultStoreId() public async Task RelatedProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() { _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); - _productViewModelServiceMock + _productAddPopupViewModelServiceMock .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) .ReturnsAsync((new List(), 0)); @@ -228,7 +228,7 @@ public async Task RelatedProductAddPopupPost_ScopeGrantsAccess_InvalidModel_Retu _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); var reprepared = new ProductModel.AddRelatedProductModel(); - _productViewModelServiceMock.Setup(s => s.PrepareRelatedProductModel()).ReturnsAsync(reprepared); + _productAddPopupViewModelServiceMock.Setup(s => s.PrepareRelatedProductModel()).ReturnsAsync(reprepared); var model = new ProductModel.AddRelatedProductModel { ProductId = "p1" }; _controller.ModelState.AddModelError("x", "error"); @@ -342,7 +342,7 @@ public async Task SimilarProductDelete_ScopeGrantsAccess_ValidModel_Deletes() public async Task SimilarProductAddPopupGet_CallsPrepareSimilarProductModel() { // PrepareSimilarProductModel() now reads scope internally (Task 9). - _productViewModelServiceMock.Setup(s => s.PrepareSimilarProductModel()) + _productAddPopupViewModelServiceMock.Setup(s => s.PrepareSimilarProductModel()) .ReturnsAsync(new ProductModel.AddSimilarProductModel()); var result = await _controller.SimilarProductAddPopup("p1") as ViewResult; @@ -351,7 +351,7 @@ public async Task SimilarProductAddPopupGet_CallsPrepareSimilarProductModel() var model = result.Model as ProductModel.AddSimilarProductModel; Assert.IsNotNull(model); Assert.AreEqual("p1", model.ProductId); - _productViewModelServiceMock.Verify(s => s.PrepareSimilarProductModel(), Times.Once); + _productAddPopupViewModelServiceMock.Verify(s => s.PrepareSimilarProductModel(), Times.Once); } // --- SimilarProductAddPopupList ----------------------------------------------------------------- @@ -360,7 +360,7 @@ public async Task SimilarProductAddPopupGet_CallsPrepareSimilarProductModel() public async Task SimilarProductAddPopupList_UsesScopeDefaultStoreId() { _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); - _productViewModelServiceMock + _productAddPopupViewModelServiceMock .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) .ReturnsAsync((new List(), 0)); @@ -376,7 +376,7 @@ public async Task SimilarProductAddPopupList_UsesScopeDefaultStoreId() public async Task SimilarProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() { _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); - _productViewModelServiceMock + _productAddPopupViewModelServiceMock .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) .ReturnsAsync((new List(), 0)); @@ -431,7 +431,7 @@ public async Task SimilarProductAddPopupPost_ScopeGrantsAccess_InvalidModel_Retu _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); var reprepared = new ProductModel.AddSimilarProductModel(); - _productViewModelServiceMock.Setup(s => s.PrepareSimilarProductModel()).ReturnsAsync(reprepared); + _productAddPopupViewModelServiceMock.Setup(s => s.PrepareSimilarProductModel()).ReturnsAsync(reprepared); var model = new ProductModel.AddSimilarProductModel { ProductId = "p1" }; _controller.ModelState.AddModelError("x", "error"); @@ -539,7 +539,7 @@ public async Task CrossSellProductDelete_ScopeGrantsAccess_ValidModel_Deletes() public async Task CrossSellProductAddPopupGet_CallsPrepareCrossSellProductModel() { // PrepareCrossSellProductModel() now reads scope internally (Task 9). - _productViewModelServiceMock.Setup(s => s.PrepareCrossSellProductModel()) + _productAddPopupViewModelServiceMock.Setup(s => s.PrepareCrossSellProductModel()) .ReturnsAsync(new ProductModel.AddCrossSellProductModel()); var result = await _controller.CrossSellProductAddPopup("p1") as ViewResult; @@ -548,7 +548,7 @@ public async Task CrossSellProductAddPopupGet_CallsPrepareCrossSellProductModel( var model = result.Model as ProductModel.AddCrossSellProductModel; Assert.IsNotNull(model); Assert.AreEqual("p1", model.ProductId); - _productViewModelServiceMock.Verify(s => s.PrepareCrossSellProductModel(), Times.Once); + _productAddPopupViewModelServiceMock.Verify(s => s.PrepareCrossSellProductModel(), Times.Once); } // --- CrossSellProductAddPopupList --------------------------------------------------------------- @@ -557,7 +557,7 @@ public async Task CrossSellProductAddPopupGet_CallsPrepareCrossSellProductModel( public async Task CrossSellProductAddPopupList_UsesScopeDefaultStoreId() { _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); - _productViewModelServiceMock + _productAddPopupViewModelServiceMock .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) .ReturnsAsync((new List(), 0)); @@ -573,7 +573,7 @@ public async Task CrossSellProductAddPopupList_UsesScopeDefaultStoreId() public async Task CrossSellProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() { _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); - _productViewModelServiceMock + _productAddPopupViewModelServiceMock .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) .ReturnsAsync((new List(), 0)); @@ -628,7 +628,7 @@ public async Task CrossSellProductAddPopupPost_ScopeGrantsAccess_InvalidModel_Re _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); var reprepared = new ProductModel.AddCrossSellProductModel(); - _productViewModelServiceMock.Setup(s => s.PrepareCrossSellProductModel()).ReturnsAsync(reprepared); + _productAddPopupViewModelServiceMock.Setup(s => s.PrepareCrossSellProductModel()).ReturnsAsync(reprepared); var model = new ProductModel.AddCrossSellProductModel { ProductId = "p1" }; _controller.ModelState.AddModelError("x", "error"); @@ -737,7 +737,7 @@ public async Task RecommendedProductDelete_ScopeGrantsAccess_ValidModel_Deletes( public async Task RecommendedProductAddPopupGet_CallsPrepareRecommendedProductModel() { // PrepareRecommendedProductModel() now reads scope internally (Task 9). - _productViewModelServiceMock.Setup(s => s.PrepareRecommendedProductModel()) + _productAddPopupViewModelServiceMock.Setup(s => s.PrepareRecommendedProductModel()) .ReturnsAsync(new ProductModel.AddRecommendedProductModel()); var result = await _controller.RecommendedProductAddPopup("p1") as ViewResult; @@ -746,7 +746,7 @@ public async Task RecommendedProductAddPopupGet_CallsPrepareRecommendedProductMo var model = result.Model as ProductModel.AddRecommendedProductModel; Assert.IsNotNull(model); Assert.AreEqual("p1", model.ProductId); - _productViewModelServiceMock.Verify(s => s.PrepareRecommendedProductModel(), Times.Once); + _productAddPopupViewModelServiceMock.Verify(s => s.PrepareRecommendedProductModel(), Times.Once); } // --- RecommendedProductAddPopupList --------------------------------------------------------------- @@ -755,7 +755,7 @@ public async Task RecommendedProductAddPopupGet_CallsPrepareRecommendedProductMo public async Task RecommendedProductAddPopupList_UsesScopeDefaultStoreId() { _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); - _productViewModelServiceMock + _productAddPopupViewModelServiceMock .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) .ReturnsAsync((new List(), 0)); @@ -771,7 +771,7 @@ public async Task RecommendedProductAddPopupList_UsesScopeDefaultStoreId() public async Task RecommendedProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() { _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); - _productViewModelServiceMock + _productAddPopupViewModelServiceMock .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) .ReturnsAsync((new List(), 0)); @@ -826,7 +826,7 @@ public async Task RecommendedProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); var reprepared = new ProductModel.AddRecommendedProductModel(); - _productViewModelServiceMock.Setup(s => s.PrepareRecommendedProductModel()).ReturnsAsync(reprepared); + _productAddPopupViewModelServiceMock.Setup(s => s.PrepareRecommendedProductModel()).ReturnsAsync(reprepared); var model = new ProductModel.AddRecommendedProductModel { ProductId = "p1" }; _controller.ModelState.AddModelError("x", "error"); diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs index a16ce80a6..0aa8be8a8 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs @@ -28,6 +28,7 @@ public abstract class ProductControllerTestsBase where TController protected TController _controller; protected Mock _productServiceMock; protected Mock _productViewModelServiceMock; + protected Mock _productAddPopupViewModelServiceMock; protected Mock _productAttributeCombinationViewModelServiceMock; protected Mock _productAttributeMappingViewModelServiceMock; protected Mock _productAssociatedViewModelServiceMock; @@ -63,6 +64,7 @@ public void Setup() _productServiceMock = new Mock(); _productViewModelServiceMock = new Mock(); + _productAddPopupViewModelServiceMock = new Mock(); _productAttributeCombinationViewModelServiceMock = new Mock(); _productAttributeMappingViewModelServiceMock = new Mock(); _productAssociatedViewModelServiceMock = new Mock(); diff --git a/src/Tests/Grand.Web.Admin.Tests/Services/ProductAddPopupViewModelServiceTests.cs b/src/Tests/Grand.Web.Admin.Tests/Services/ProductAddPopupViewModelServiceTests.cs new file mode 100644 index 000000000..f8c567250 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Services/ProductAddPopupViewModelServiceTests.cs @@ -0,0 +1,110 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Stores; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Utilities.Catalog; +using Grand.Domain; +using Grand.Domain.Catalog; +using Grand.Domain.Stores; +using Grand.Domain.Vendors; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Services; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Admin.Tests.Services; + +[TestClass] +public class ProductAddPopupViewModelServiceTests +{ + private Mock _productServiceMock; + private Mock _vendorServiceMock; + private Mock> _scopeMock; + private ProductAddPopupViewModelService _productAddPopupViewModelService; + + [TestInitialize] + public void Setup() + { + _productServiceMock = new Mock(); + _vendorServiceMock = new Mock(); + + var storeServiceMock = new Mock(); + storeServiceMock.Setup(s => s.GetAllStores()).ReturnsAsync(new List()); + + var translationServiceMock = new Mock(); + translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns("resource"); + + var enumTranslationServiceMock = new Mock(); + enumTranslationServiceMock + .Setup(e => e.ToSelectList(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(new SelectList(Enumerable.Empty())); + + // Default: Admin's Global scope - no default store/vendor, store dropdown shows. + _scopeMock = new Mock>(); + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _scopeMock.Setup(s => s.ShowStoreSelector).Returns(true); + _scopeMock.Setup(s => s.DefaultVendorId).Returns((string)null); + + _productAddPopupViewModelService = new ProductAddPopupViewModelService( + _productServiceMock.Object, + new Mock().Object, + _vendorServiceMock.Object, + storeServiceMock.Object, + translationServiceMock.Object, + enumTranslationServiceMock.Object, + _scopeMock.Object); + } + + [TestMethod] + public async Task PrepareProductModel_AddProductModel_VendorScope_OverridesModelVendorId() + { + _scopeMock.Setup(s => s.DefaultVendorId).Returns("vendor1"); + _productServiceMock.Setup(p => p.SearchProducts( + 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(), It.IsAny(), It.IsAny>(), It.IsAny>(), + It.IsAny(), It.IsAny())) + .ReturnsAsync((new PagedList(), new List())); + + var model = new ProductModel.AddRelatedProductModel { SearchVendorId = "vendor2" }; + await _productAddPopupViewModelService.PrepareProductModel(model, 1, 10); + + _productServiceMock.Verify(p => p.SearchProducts( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), + It.IsAny(), It.IsAny(), "vendor1", 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()), Times.Once); + } + + [TestMethod] + public async Task PrepareRelatedProductModel_VendorScope_HidesVendorDropdown() + { + _scopeMock.Setup(s => s.DefaultVendorId).Returns("vendor1"); + + var model = await _productAddPopupViewModelService.PrepareRelatedProductModel(); + + Assert.IsFalse(model.AvailableVendors.Any(), + "Vendor's original PrepareAddProductModel never populated a vendor picker - the search is always forced to the current vendor."); + _vendorServiceMock.Verify(v => v.GetAllVendors(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task PrepareRelatedProductModel_GlobalScope_IncludesVendorDropdown() + { + _vendorServiceMock.Setup(v => v.GetAllVendors(It.IsAny(), It.IsAny(), It.IsAny(), true)) + .ReturnsAsync(new PagedList { new() { Id = "vendor1", Name = "Vendor 1" } }); + + var model = await _productAddPopupViewModelService.PrepareRelatedProductModel(); + + Assert.IsTrue(model.AvailableVendors.Any(x => x.Value == "vendor1"), + "Admin/Store should keep offering a vendor picker."); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs b/src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs index c5e8f6aff..c48489f36 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs @@ -363,44 +363,6 @@ public async Task PrepareProducts_VendorScope_OverridesModelVendorId() VerifySearchProductsCalledWithVendorId("vendor1"); } - [TestMethod] - public async Task PrepareProductModel_AddProductModel_VendorScope_OverridesModelVendorId() - { - _scopeMock.Setup(s => s.DefaultVendorId).Returns("vendor1"); - SetupSearchProducts(); - - var model = new ProductModel.AddRelatedProductModel { SearchVendorId = "vendor2" }; - await _productViewModelService.PrepareProductModel(model, 1, 10); - - VerifySearchProductsCalledWithVendorId("vendor1"); - } - - [TestMethod] - public async Task PrepareRelatedProductModel_VendorScope_HidesVendorDropdown() - { - _scopeMock.Setup(s => s.DefaultVendorId).Returns("vendor1"); - - var model = await _productViewModelService.PrepareRelatedProductModel(); - - Assert.IsFalse(model.AvailableVendors.Any(), - "Vendor's original PrepareAddProductModel never populated a vendor picker - the search is always forced to the current vendor."); - _vendorServiceMock.Verify(v => v.GetAllVendors(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), - Times.Never); - } - - [TestMethod] - public async Task PrepareRelatedProductModel_GlobalScope_IncludesVendorDropdown() - { - // Default Setup() scope: Admin's Global scope (DefaultVendorId null). - _vendorServiceMock.Setup(v => v.GetAllVendors(It.IsAny(), It.IsAny(), It.IsAny(), true)) - .ReturnsAsync(new PagedList { new() { Id = "vendor1", Name = "Vendor 1" } }); - - var model = await _productViewModelService.PrepareRelatedProductModel(); - - Assert.IsTrue(model.AvailableVendors.Any(x => x.Value == "vendor1"), - "Admin/Store should keep offering a vendor picker."); - } - [TestMethod] public async Task InsertProductModel_VendorScope_ForcesVendorId() { diff --git a/src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerTests.cs b/src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerTests.cs index a683b3095..7a944a66f 100644 --- a/src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerTests.cs +++ b/src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerTests.cs @@ -47,6 +47,7 @@ public void Setup() _controller = new ProductController( productViewModelServiceMock.Object, + new Mock().Object, _productServiceMock.Object, languageServiceMock.Object, translationServiceMock.Object, diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductAssociatedController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductAssociatedController.cs index 805700a23..89adebb23 100644 --- a/src/Web/Grand.Web.Admin/Controllers/ProductAssociatedController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/ProductAssociatedController.cs @@ -14,9 +14,9 @@ namespace Grand.Web.Admin.Controllers; [Area(Constants.AreaAdmin)] [AuthorizeMenu] public class ProductAssociatedController( - IProductViewModelService productViewModelService, + IProductAddPopupViewModelService productAddPopupViewModelService, IProductAssociatedViewModelService productAssociatedViewModelService, IProductService productService, IRequestTranslationService translationService, IAdminDataScope scope) - : BaseProductAssociatedController(productViewModelService, productAssociatedViewModelService, productService, translationService, scope); + : BaseProductAssociatedController(productAddPopupViewModelService, productAssociatedViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductAttributeMappingController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductAttributeMappingController.cs index 8d21faab6..9db1b80d6 100644 --- a/src/Web/Grand.Web.Admin/Controllers/ProductAttributeMappingController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/ProductAttributeMappingController.cs @@ -15,10 +15,10 @@ namespace Grand.Web.Admin.Controllers; [Area(Constants.AreaAdmin)] [AuthorizeMenu] public class ProductAttributeMappingController( - IProductViewModelService productViewModelService, + IProductAddPopupViewModelService productAddPopupViewModelService, IProductAttributeMappingViewModelService productAttributeMappingViewModelService, IProductService productService, ILanguageService languageService, IRequestTranslationService translationService, IAdminDataScope scope) - : BaseProductAttributeMappingController(productViewModelService, productAttributeMappingViewModelService, productService, languageService, translationService, scope); + : BaseProductAttributeMappingController(productAddPopupViewModelService, productAttributeMappingViewModelService, productService, languageService, translationService, scope); diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductBundleController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductBundleController.cs index 961f97591..318f0f932 100644 --- a/src/Web/Grand.Web.Admin/Controllers/ProductBundleController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/ProductBundleController.cs @@ -14,9 +14,9 @@ namespace Grand.Web.Admin.Controllers; [Area(Constants.AreaAdmin)] [AuthorizeMenu] public class ProductBundleController( - IProductViewModelService productViewModelService, + IProductAddPopupViewModelService productAddPopupViewModelService, IProductBundleViewModelService productBundleViewModelService, IProductService productService, IRequestTranslationService translationService, IAdminDataScope scope) - : BaseProductBundleController(productViewModelService, productBundleViewModelService, productService, translationService, scope); + : BaseProductBundleController(productAddPopupViewModelService, productBundleViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductController.cs index 0a7e0a44f..827f2a336 100644 --- a/src/Web/Grand.Web.Admin/Controllers/ProductController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/ProductController.cs @@ -18,11 +18,12 @@ namespace Grand.Web.Admin.Controllers; [AuthorizeMenu] public class ProductController( IProductViewModelService productViewModelService, + IProductAddPopupViewModelService productAddPopupViewModelService, IProductService productService, ILanguageService languageService, IRequestTranslationService translationService, IDateTimeService dateTimeService, IRequestPermissionService permissionService, IAdminDataScope scope) - : BaseProductController(productViewModelService, productService, languageService, translationService, + : BaseProductController(productViewModelService, productAddPopupViewModelService, productService, languageService, translationService, dateTimeService, permissionService, scope); diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductRelatedController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductRelatedController.cs index e8c1cd508..8077d6838 100644 --- a/src/Web/Grand.Web.Admin/Controllers/ProductRelatedController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/ProductRelatedController.cs @@ -14,9 +14,9 @@ namespace Grand.Web.Admin.Controllers; [Area(Constants.AreaAdmin)] [AuthorizeMenu] public class ProductRelatedController( - IProductViewModelService productViewModelService, + IProductAddPopupViewModelService productAddPopupViewModelService, IProductRelatedViewModelService productRelatedViewModelService, IProductService productService, IRequestTranslationService translationService, IAdminDataScope scope) - : BaseProductRelatedController(productViewModelService, productRelatedViewModelService, productService, translationService, scope); + : BaseProductRelatedController(productAddPopupViewModelService, productRelatedViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAssociatedController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAssociatedController.cs index a5c3b139a..82df40324 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAssociatedController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAssociatedController.cs @@ -18,7 +18,7 @@ namespace Grand.Web.AdminShared.Controllers; [PermissionAuthorize(PermissionSystemName.Products)] [AutoValidateAntiforgeryToken] public abstract class BaseProductAssociatedController( - IProductViewModelService productViewModelService, + IProductAddPopupViewModelService productAddPopupViewModelService, IProductAssociatedViewModelService productAssociatedViewModelService, IProductService productService, IRequestTranslationService translationService, @@ -118,7 +118,7 @@ public async Task AssociatedProductAddPopup(string productId) // No access check here in any of the three original hosts (all open this popup unconditionally // once the Edit permission is satisfied) - only the mutating actions below tie access to a // specific product. PrepareAssociatedProductModel() now reads scope internally (Task 9). - var model = await productViewModelService.PrepareAssociatedProductModel(); + var model = await productAddPopupViewModelService.PrepareAssociatedProductModel(); model.ProductId = productId; return View(model); } @@ -131,7 +131,7 @@ public async Task AssociatedProductAddPopupList(DataSourceRequest if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; var (products, totalCount) = - await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + await productAddPopupViewModelService.PrepareProductModel(model, command.Page, command.PageSize); var gridModel = new DataSourceResult { Data = products.ToList(), Total = totalCount @@ -160,15 +160,11 @@ public async Task AssociatedProductAddPopup(ProductModel.AddAssoc // ParentGroupedProductId directly onto that product's own record - unlike // Related/Similar/Bundle/Cross-sell/Recommended, whose Insert*ProductModel only adds a mapping // entry that references the selected product's id from the parent's own list; the selected - // product's record is never itself modified there. So here, unlike those simpler regions, - // every selected id must independently pass HasAccess too - the same per-id filtering Store's - // controller-level original already did, and which Vendor's original also had, but only at the - // service layer (Grand.Web.Vendor/Services/ProductViewModelService.cs InsertAssociatedProductModel: - // `if (product == null || !HasAccessToProduct(product)) continue;`). BaseProductController - // injects AdminShared's IProductViewModelService - the unfiltered variant - not Vendor's own, so - // once Vendor is subclassed onto this base (Task 11) it loses that service-layer filter - // entirely. Enforcing the per-id check here at the controller level is what preserves the - // invariant going forward. + // product's record is never itself modified there. So here every selected id must independently + // pass HasAccess too - the per-id filtering Store's and Vendor's originals both had. + // IProductAssociatedViewModelService.InsertAssociatedProductModel does not check scope itself + // (unlike the Related and Bundle services), so this filter is the only guard against reparenting + // another vendor's or store's products. if (model.SelectedProductIds != null) { var validIds = new List(); @@ -190,7 +186,7 @@ public async Task AssociatedProductAddPopup(ProductModel.AddAssoc // AssociatedProductAddPopup(POST) does not use the Content(ModelState.GetErrors()) shortcut it // uses in those other regions, so no host-specific hook is needed for this action. Error(ModelState); - model = await productViewModelService.PrepareAssociatedProductModel(); + model = await productAddPopupViewModelService.PrepareAssociatedProductModel(); return View(model); } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAttributeMappingController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAttributeMappingController.cs index 6901d959a..a99ecedb9 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAttributeMappingController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAttributeMappingController.cs @@ -19,7 +19,7 @@ namespace Grand.Web.AdminShared.Controllers; [PermissionAuthorize(PermissionSystemName.Products)] [AutoValidateAntiforgeryToken] public abstract class BaseProductAttributeMappingController( - IProductViewModelService productViewModelService, + IProductAddPopupViewModelService productAddPopupViewModelService, IProductAttributeMappingViewModelService productAttributeMappingViewModelService, IProductService productService, ILanguageService languageService, @@ -471,7 +471,7 @@ public async Task AssociateProductToAttributeValuePopup() // PrepareAssociateProductToAttributeValueModel() now reads scope internally (Task 9): // StaffStoreId for Store; null for Admin/Vendor (no store concept). var model = - await productViewModelService.PrepareAssociateProductToAttributeValueModel(); + await productAddPopupViewModelService.PrepareAssociateProductToAttributeValueModel(); return View(model); } @@ -483,7 +483,7 @@ public async Task AssociateProductToAttributeValuePopupList(DataS if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; var (products, totalCount) = - await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + await productAddPopupViewModelService.PrepareProductModel(model, command.Page, command.PageSize); var gridModel = new DataSourceResult { Data = products.ToList(), Total = totalCount diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductBundleController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductBundleController.cs index 3a4a1bf93..6f079e846 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductBundleController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductBundleController.cs @@ -16,7 +16,7 @@ namespace Grand.Web.AdminShared.Controllers; [PermissionAuthorize(PermissionSystemName.Products)] [AutoValidateAntiforgeryToken] public abstract class BaseProductBundleController( - IProductViewModelService productViewModelService, + IProductAddPopupViewModelService productAddPopupViewModelService, IProductBundleViewModelService productBundleViewModelService, IProductService productService, IRequestTranslationService translationService, @@ -101,7 +101,7 @@ public async Task BundleProductAddPopup(string productId) // popup unconditionally once the Edit permission is satisfied) - only the mutating POST below // ties access to a specific product. PrepareBundleProductModel() now reads scope // internally (Task 9). - var model = await productViewModelService.PrepareBundleProductModel(); + var model = await productAddPopupViewModelService.PrepareBundleProductModel(); model.ProductId = productId; return View(model); } @@ -114,7 +114,7 @@ public async Task BundleProductAddPopupList(DataSourceRequest com if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; var (products, totalCount) = - await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + await productAddPopupViewModelService.PrepareProductModel(model, command.Page, command.PageSize); var gridModel = new DataSourceResult { Data = products.ToList(), Total = totalCount @@ -152,7 +152,7 @@ public async Task BundleProductAddPopup(ProductModel.AddBundlePro protected virtual async Task InvalidBundleProductAddPopupResult(ProductModel.AddBundleProductModel model) { Error(ModelState); - model = await productViewModelService.PrepareBundleProductModel(); + model = await productAddPopupViewModelService.PrepareBundleProductModel(); return View(model); } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index 754402b01..9ac771e03 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -29,6 +29,7 @@ namespace Grand.Web.AdminShared.Controllers; [AutoValidateAntiforgeryToken] public abstract class BaseProductController( IProductViewModelService productViewModelService, + IProductAddPopupViewModelService productAddPopupViewModelService, IProductService productService, ILanguageService languageService, IRequestTranslationService translationService, @@ -328,7 +329,7 @@ public async Task RequiredProductAddPopup(string productIdsInput) // PrepareAddRequiredProductModel() now reads scope internally (Task 9) - the injected // IAdminDataScope already encodes the per-host default exactly: null for Admin // (global) and Vendor (not store-scoped), StaffStoreId for Store. - var model = await productViewModelService.PrepareAddRequiredProductModel(); + var model = await productAddPopupViewModelService.PrepareAddRequiredProductModel(); // Unused by any of the three views (all three read productIdsInput straight off the query string // via Context.Request.Query, not ViewBag), but Admin and Vendor both set it and Store silently // drops its own parameter - kept here for parity; it is inert either way. @@ -344,7 +345,7 @@ public async Task RequiredProductAddPopupList(DataSourceRequest c if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; var (products, totalCount) = - await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + await productAddPopupViewModelService.PrepareProductModel(model, command.Page, command.PageSize); var gridModel = new DataSourceResult { Data = products.ToList(), Total = totalCount diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductRelatedController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductRelatedController.cs index df3399ad0..d4eb7b8fe 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductRelatedController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductRelatedController.cs @@ -16,7 +16,7 @@ namespace Grand.Web.AdminShared.Controllers; [PermissionAuthorize(PermissionSystemName.Products)] [AutoValidateAntiforgeryToken] public abstract class BaseProductRelatedController( - IProductViewModelService productViewModelService, + IProductAddPopupViewModelService productAddPopupViewModelService, IProductRelatedViewModelService productRelatedViewModelService, IProductService productService, IRequestTranslationService translationService, @@ -102,7 +102,7 @@ public async Task RelatedProductAddPopup(string productId) // popup unconditionally once the Edit permission is satisfied) - only the mutating POST below // ties access to a specific product. PrepareRelatedProductModel() now reads scope // internally (Task 9). - var model = await productViewModelService.PrepareRelatedProductModel(); + var model = await productAddPopupViewModelService.PrepareRelatedProductModel(); model.ProductId = productId; return View(model); } @@ -115,7 +115,7 @@ public async Task RelatedProductAddPopupList(DataSourceRequest co if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; var (products, totalCount) = - await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + await productAddPopupViewModelService.PrepareProductModel(model, command.Page, command.PageSize); var gridModel = new DataSourceResult { Data = products.ToList(), Total = totalCount @@ -153,7 +153,7 @@ public async Task RelatedProductAddPopup(ProductModel.AddRelatedP protected virtual async Task InvalidRelatedProductAddPopupResult(ProductModel.AddRelatedProductModel model) { Error(ModelState); - model = await productViewModelService.PrepareRelatedProductModel(); + model = await productAddPopupViewModelService.PrepareRelatedProductModel(); return View(model); } @@ -237,7 +237,7 @@ public async Task SimilarProductAddPopup(string productId) // popup unconditionally once the Edit permission is satisfied) - only the mutating POST below // ties access to a specific product. PrepareSimilarProductModel() now reads scope // internally (Task 9). - var model = await productViewModelService.PrepareSimilarProductModel(); + var model = await productAddPopupViewModelService.PrepareSimilarProductModel(); model.ProductId = productId; return View(model); } @@ -250,7 +250,7 @@ public async Task SimilarProductAddPopupList(DataSourceRequest co if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; var (products, totalCount) = - await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + await productAddPopupViewModelService.PrepareProductModel(model, command.Page, command.PageSize); var gridModel = new DataSourceResult { Data = products.ToList(), Total = totalCount @@ -288,7 +288,7 @@ public async Task SimilarProductAddPopup(ProductModel.AddSimilarP protected virtual async Task InvalidSimilarProductAddPopupResult(ProductModel.AddSimilarProductModel model) { Error(ModelState); - model = await productViewModelService.PrepareSimilarProductModel(); + model = await productAddPopupViewModelService.PrepareSimilarProductModel(); return View(model); } @@ -361,7 +361,7 @@ public async Task CrossSellProductAddPopup(string productId) // popup unconditionally once the Edit permission is satisfied) - only the mutating POST below // ties access to a specific product. PrepareCrossSellProductModel() now reads scope // internally (Task 9). - var model = await productViewModelService.PrepareCrossSellProductModel(); + var model = await productAddPopupViewModelService.PrepareCrossSellProductModel(); model.ProductId = productId; return View(model); } @@ -374,7 +374,7 @@ public async Task CrossSellProductAddPopupList(DataSourceRequest if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; var (products, totalCount) = - await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + await productAddPopupViewModelService.PrepareProductModel(model, command.Page, command.PageSize); var gridModel = new DataSourceResult { Data = products.ToList(), Total = totalCount @@ -412,7 +412,7 @@ public async Task CrossSellProductAddPopup(ProductModel.AddCrossS protected virtual async Task InvalidCrossSellProductAddPopupResult(ProductModel.AddCrossSellProductModel model) { Error(ModelState); - model = await productViewModelService.PrepareCrossSellProductModel(); + model = await productAddPopupViewModelService.PrepareCrossSellProductModel(); return View(model); } @@ -485,7 +485,7 @@ public async Task RecommendedProductAddPopup(string productId) // popup unconditionally once the Edit permission is satisfied) - only the mutating POST below // ties access to a specific product. PrepareRecommendedProductModel() now reads scope // internally (Task 9). - var model = await productViewModelService.PrepareRecommendedProductModel(); + var model = await productAddPopupViewModelService.PrepareRecommendedProductModel(); model.ProductId = productId; return View(model); } @@ -498,7 +498,7 @@ public async Task RecommendedProductAddPopupList(DataSourceReques if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; var (products, totalCount) = - await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + await productAddPopupViewModelService.PrepareProductModel(model, command.Page, command.PageSize); var gridModel = new DataSourceResult { Data = products.ToList(), Total = totalCount @@ -536,7 +536,7 @@ public async Task RecommendedProductAddPopup(ProductModel.AddReco protected virtual async Task InvalidRecommendedProductAddPopupResult(ProductModel.AddRecommendedProductModel model) { Error(ModelState); - model = await productViewModelService.PrepareRecommendedProductModel(); + model = await productAddPopupViewModelService.PrepareRecommendedProductModel(); return View(model); } diff --git a/src/Web/Grand.Web.AdminShared/Interfaces/IProductAddPopupViewModelService.cs b/src/Web/Grand.Web.AdminShared/Interfaces/IProductAddPopupViewModelService.cs new file mode 100644 index 000000000..44bf167bc --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Interfaces/IProductAddPopupViewModelService.cs @@ -0,0 +1,20 @@ +using Grand.Web.AdminShared.Models.Catalog; + +namespace Grand.Web.AdminShared.Interfaces; + +public interface IProductAddPopupViewModelService +{ + Task PrepareAddRequiredProductModel(); + Task PrepareRelatedProductModel(); + Task PrepareSimilarProductModel(); + Task PrepareBundleProductModel(); + Task PrepareCrossSellProductModel(); + Task PrepareRecommendedProductModel(); + Task PrepareAssociatedProductModel(); + + Task + PrepareAssociateProductToAttributeValueModel(); + + Task<(IList products, int totalCount)> PrepareProductModel(ProductModel.AddProductModel model, + int pageIndex, int pageSize); +} diff --git a/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs b/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs index 968e8104d..3e5b79480 100644 --- a/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs @@ -24,17 +24,6 @@ Task OutOfStockNotifications(Product product, int prevStockQuantity, Task UpdateProductModel(Product product, ProductModel model); Task DeleteProduct(Product product); Task DeleteSelected(IEnumerable selectedIds); - Task PrepareAddRequiredProductModel(); - - Task<(IList products, int totalCount)> PrepareProductModel(ProductModel.AddProductModel model, - int pageIndex, int pageSize); - - Task PrepareRelatedProductModel(); - Task PrepareSimilarProductModel(); - Task PrepareBundleProductModel(); - Task PrepareCrossSellProductModel(); - Task PrepareRecommendedProductModel(); - Task PrepareAssociatedProductModel(); Task PrepareBulkEditListModel(); Task<(IEnumerable bulkEditProductModels, int totalCount)> PrepareBulkEditProductModel( @@ -45,8 +34,4 @@ Task OutOfStockNotifications(Product product, int prevStockQuantity, Task InsertBulkEdit(IEnumerable products); Task DeleteBulkEdit(IEnumerable products); - - Task - PrepareAssociateProductToAttributeValueModel(); - } \ No newline at end of file diff --git a/src/Web/Grand.Web.AdminShared/Services/ProductAddPopupViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/ProductAddPopupViewModelService.cs new file mode 100644 index 000000000..161604a17 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Services/ProductAddPopupViewModelService.cs @@ -0,0 +1,120 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Stores; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Extensions.Mapping; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.Common.Extensions; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace Grand.Web.AdminShared.Services; + +public class ProductAddPopupViewModelService( + IProductService productService, + IDateTimeService dateTimeService, + IVendorService vendorService, + IStoreService storeService, + IRequestTranslationService translationService, + IEnumTranslationService enumTranslationService, + IAdminDataScope scope) + : IProductAddPopupViewModelService +{ + public virtual async Task PrepareAddRequiredProductModel() + { + var model = await PrepareAddProductModel(); + return model; + } + + public virtual async Task PrepareRelatedProductModel() + { + var model = await PrepareAddProductModel(); + return model; + } + + public virtual async Task PrepareSimilarProductModel() + { + var model = await PrepareAddProductModel(); + return model; + } + + public virtual async Task PrepareBundleProductModel() + { + var model = await PrepareAddProductModel(); + return model; + } + + public virtual async Task PrepareCrossSellProductModel() + { + var model = await PrepareAddProductModel(); + return model; + } + + public virtual async Task PrepareRecommendedProductModel() + { + var model = await PrepareAddProductModel(); + return model; + } + + public virtual async Task PrepareAssociatedProductModel() + { + var model = await PrepareAddProductModel(); + return model; + } + + public virtual async Task PrepareAssociateProductToAttributeValueModel() + { + var model = await PrepareAddProductModel(); + return model; + } + + public virtual async Task<(IList products, int totalCount)> PrepareProductModel( + ProductModel.AddProductModel model, int pageIndex, int pageSize) + { + // vendorId: scope.DefaultVendorId (when set) overrides the search model - mirrors Vendor's original + // PrepareProductModel(AddProductModel,...), which always passed vendorId: CurrentVendor.Id (and + // storeId: string.Empty) regardless of the model, so the "add related/similar/bundle/etc. product" + // popups can never surface another vendor's products. + var products = await productService.PrepareProductList(model.SearchCategoryId, model.SearchBrandId, + model.SearchCollectionId, model.SearchStoreId, scope.DefaultVendorId ?? model.SearchVendorId, model.SearchProductTypeId, + model.SearchProductName, pageIndex, pageSize); + return (products.Select(x => x.ToModel(dateTimeService)).ToList(), products.TotalCount); + } + + protected virtual async Task PrepareAddProductModel() where T : ProductModel.AddProductModel, new() + { + var storeId = scope.DefaultStoreId ?? ""; + var model = new T(); + + //stores - same capability gate as PrepareProductListModel: Vendor's original PrepareAddProductModel + //never populated a stores dropdown at all (vendors don't pick stores), so this must not run for + //Vendor even though its storeId also resolves to "". + if (scope.ShowStoreSelector) + { + model.AvailableStores.Add(new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = " " }); + foreach (var s in (await storeService.GetAllStores()).Where(x => + x.Id == storeId || string.IsNullOrWhiteSpace(storeId))) + model.AvailableStores.Add(new SelectListItem { Text = s.Shortcut, Value = s.Id }); + } + + //vendors - only when the host isn't already forced onto a single vendor (scope.DefaultVendorId): + //Vendor's original PrepareAddProductModel never populated a vendor picker at all (matches the + //ShowStoreSelector gate above for the same reason - there's nothing to pick, the search is always + //forced to the current vendor). + if (scope.DefaultVendorId is null) + { + model.AvailableVendors.Add(new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = " " }); + foreach (var v in await vendorService.GetAllVendors(showHidden: true)) + model.AvailableVendors.Add(new SelectListItem { Text = v.Name, Value = v.Id }); + } + + //product types + model.AvailableProductTypes = enumTranslationService.ToSelectList(ProductType.SimpleProduct, false).ToList(); + model.AvailableProductTypes.Insert(0, + new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = "0" }); + + return model; + } +} diff --git a/src/Web/Grand.Web.AdminShared/Services/ProductBundleViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/ProductBundleViewModelService.cs index ac8a7a44e..b762ae67e 100644 --- a/src/Web/Grand.Web.AdminShared/Services/ProductBundleViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/ProductBundleViewModelService.cs @@ -18,7 +18,8 @@ public virtual async Task InsertBundleProductModel(ProductModel.AddBundleProduct foreach (var id in model.SelectedProductIds) { var product = await productService.GetProductById(id); - // scope.HasAccess: see InsertRelatedProductModel above. + // scope.HasAccess: the popup's search is already scoped, but the posted ids are not - skip any + // product the current panel (Store, Vendor) has no access to, so it cannot be bundled by id. if (product == null || !await scope.HasAccess(product)) continue; var existingBundleProducts = productId1.BundleProducts; diff --git a/src/Web/Grand.Web.AdminShared/Services/ProductRelatedViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/ProductRelatedViewModelService.cs index 8b7dfc609..458e570e6 100644 --- a/src/Web/Grand.Web.AdminShared/Services/ProductRelatedViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/ProductRelatedViewModelService.cs @@ -18,6 +18,8 @@ public virtual async Task InsertRelatedProductModel(ProductModel.AddRelatedProdu foreach (var id in model.SelectedProductIds) { var product = await productService.GetProductById(id); + // scope.HasAccess: the popup's search is already scoped, but the posted ids are not - skip any + // product the current panel (Store, Vendor) has no access to, so it cannot be linked by id. if (product == null || !await scope.HasAccess(product)) continue; var existingRelatedProducts = productId1.RelatedProducts; diff --git a/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs index 4ee08325a..434205bac 100644 --- a/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs @@ -724,60 +724,13 @@ public virtual async Task DeleteSelected(IEnumerable selectedIds) } } - public virtual async Task PrepareAddRequiredProductModel() - { - var model = await PrepareAddProductModel(); - return model; - } - public virtual async Task<(IList products, int totalCount)> PrepareProductModel( - ProductModel.AddProductModel model, int pageIndex, int pageSize) - { - // vendorId: scope.DefaultVendorId (when set) overrides the search model - mirrors Vendor's original - // PrepareProductModel(AddProductModel,...), which always passed vendorId: CurrentVendor.Id (and - // storeId: string.Empty) regardless of the model, so the "add related/similar/bundle/etc. product" - // popups can never surface another vendor's products. - var products = await productService.PrepareProductList(model.SearchCategoryId, model.SearchBrandId, - model.SearchCollectionId, model.SearchStoreId, scope.DefaultVendorId ?? model.SearchVendorId, model.SearchProductTypeId, - model.SearchProductName, pageIndex, pageSize); - return (products.Select(x => x.ToModel(dateTimeService)).ToList(), products.TotalCount); - } - public virtual async Task PrepareRelatedProductModel() - { - var model = await PrepareAddProductModel(); - return model; - } - public virtual async Task PrepareSimilarProductModel() - { - var model = await PrepareAddProductModel(); - return model; - } - public virtual async Task PrepareBundleProductModel() - { - var model = await PrepareAddProductModel(); - return model; - } - public virtual async Task PrepareCrossSellProductModel() - { - var model = await PrepareAddProductModel(); - return model; - } - public virtual async Task PrepareRecommendedProductModel() - { - var model = await PrepareAddProductModel(); - return model; - } - public virtual async Task PrepareAssociatedProductModel() - { - var model = await PrepareAddProductModel(); - return model; - } public virtual async Task PrepareBulkEditListModel() { @@ -926,11 +879,6 @@ public virtual async Task DeleteBulkEdit(IEnumerable produ } } - public virtual async Task PrepareAssociateProductToAttributeValueModel() - { - var model = await PrepareAddProductModel(); - return model; - } protected virtual async Task UpdatePictureSeoNames(Product product) { @@ -1021,39 +969,4 @@ protected virtual async Task SaveProductTags(Product product, string[] productTa await productTagService.AttachProductTag(productTag, product.Id); } } - - protected virtual async Task PrepareAddProductModel() where T : ProductModel.AddProductModel, new() - { - var storeId = scope.DefaultStoreId ?? ""; - var model = new T(); - - //stores - same capability gate as PrepareProductListModel: Vendor's original PrepareAddProductModel - //never populated a stores dropdown at all (vendors don't pick stores), so this must not run for - //Vendor even though its storeId also resolves to "". - if (scope.ShowStoreSelector) - { - model.AvailableStores.Add(new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = " " }); - foreach (var s in (await storeService.GetAllStores()).Where(x => - x.Id == storeId || string.IsNullOrWhiteSpace(storeId))) - model.AvailableStores.Add(new SelectListItem { Text = s.Shortcut, Value = s.Id }); - } - - //vendors - only when the host isn't already forced onto a single vendor (scope.DefaultVendorId): - //Vendor's original PrepareAddProductModel never populated a vendor picker at all (matches the - //ShowStoreSelector gate above for the same reason - there's nothing to pick, the search is always - //forced to the current vendor). - if (scope.DefaultVendorId is null) - { - model.AvailableVendors.Add(new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = " " }); - foreach (var v in await vendorService.GetAllVendors(showHidden: true)) - model.AvailableVendors.Add(new SelectListItem { Text = v.Name, Value = v.Id }); - } - - //product types - model.AvailableProductTypes = enumTranslationService.ToSelectList(ProductType.SimpleProduct, false).ToList(); - model.AvailableProductTypes.Insert(0, - new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = "0" }); - - return model; - } } \ No newline at end of file diff --git a/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs index 2cb15ddbc..573519c64 100644 --- a/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs +++ b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs @@ -73,6 +73,7 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Web/Grand.Web.Store/Controllers/ProductAssociatedController.cs b/src/Web/Grand.Web.Store/Controllers/ProductAssociatedController.cs index 370a5a6d2..ae06af7e6 100644 --- a/src/Web/Grand.Web.Store/Controllers/ProductAssociatedController.cs +++ b/src/Web/Grand.Web.Store/Controllers/ProductAssociatedController.cs @@ -14,9 +14,9 @@ namespace Grand.Web.Store.Controllers; [AuthorizeStore] [AuthorizeMenu] public class ProductAssociatedController( - IProductViewModelService productViewModelService, + IProductAddPopupViewModelService productAddPopupViewModelService, IProductAssociatedViewModelService productAssociatedViewModelService, IProductService productService, IRequestTranslationService translationService, IAdminDataScope scope) - : BaseProductAssociatedController(productViewModelService, productAssociatedViewModelService, productService, translationService, scope); + : BaseProductAssociatedController(productAddPopupViewModelService, productAssociatedViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.Store/Controllers/ProductAttributeMappingController.cs b/src/Web/Grand.Web.Store/Controllers/ProductAttributeMappingController.cs index 3201eeb57..e0dde0f45 100644 --- a/src/Web/Grand.Web.Store/Controllers/ProductAttributeMappingController.cs +++ b/src/Web/Grand.Web.Store/Controllers/ProductAttributeMappingController.cs @@ -15,10 +15,10 @@ namespace Grand.Web.Store.Controllers; [AuthorizeStore] [AuthorizeMenu] public class ProductAttributeMappingController( - IProductViewModelService productViewModelService, + IProductAddPopupViewModelService productAddPopupViewModelService, IProductAttributeMappingViewModelService productAttributeMappingViewModelService, IProductService productService, ILanguageService languageService, IRequestTranslationService translationService, IAdminDataScope scope) - : BaseProductAttributeMappingController(productViewModelService, productAttributeMappingViewModelService, productService, languageService, translationService, scope); + : BaseProductAttributeMappingController(productAddPopupViewModelService, productAttributeMappingViewModelService, productService, languageService, translationService, scope); diff --git a/src/Web/Grand.Web.Store/Controllers/ProductBundleController.cs b/src/Web/Grand.Web.Store/Controllers/ProductBundleController.cs index 19d76dc8f..a2cd8baed 100644 --- a/src/Web/Grand.Web.Store/Controllers/ProductBundleController.cs +++ b/src/Web/Grand.Web.Store/Controllers/ProductBundleController.cs @@ -14,9 +14,9 @@ namespace Grand.Web.Store.Controllers; [AuthorizeStore] [AuthorizeMenu] public class ProductBundleController( - IProductViewModelService productViewModelService, + IProductAddPopupViewModelService productAddPopupViewModelService, IProductBundleViewModelService productBundleViewModelService, IProductService productService, IRequestTranslationService translationService, IAdminDataScope scope) - : BaseProductBundleController(productViewModelService, productBundleViewModelService, productService, translationService, scope); + : BaseProductBundleController(productAddPopupViewModelService, productBundleViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.Store/Controllers/ProductController.cs b/src/Web/Grand.Web.Store/Controllers/ProductController.cs index 4ff451b3c..b0770dccf 100644 --- a/src/Web/Grand.Web.Store/Controllers/ProductController.cs +++ b/src/Web/Grand.Web.Store/Controllers/ProductController.cs @@ -18,13 +18,14 @@ namespace Grand.Web.Store.Controllers; [AuthorizeMenu] public class ProductController( IProductViewModelService productViewModelService, + IProductAddPopupViewModelService productAddPopupViewModelService, IProductService productService, ILanguageService languageService, IRequestTranslationService translationService, IDateTimeService dateTimeService, IRequestPermissionService permissionService, IAdminDataScope scope) - : BaseProductController(productViewModelService, productService, languageService, translationService, + : BaseProductController(productViewModelService, productAddPopupViewModelService, productService, languageService, translationService, dateTimeService, permissionService, scope) { // Re-derived from the original Store ProductController.Edit(GET) (pre-Task-11: diff --git a/src/Web/Grand.Web.Store/Controllers/ProductRelatedController.cs b/src/Web/Grand.Web.Store/Controllers/ProductRelatedController.cs index 628e522a6..ce785e36b 100644 --- a/src/Web/Grand.Web.Store/Controllers/ProductRelatedController.cs +++ b/src/Web/Grand.Web.Store/Controllers/ProductRelatedController.cs @@ -14,9 +14,9 @@ namespace Grand.Web.Store.Controllers; [AuthorizeStore] [AuthorizeMenu] public class ProductRelatedController( - IProductViewModelService productViewModelService, + IProductAddPopupViewModelService productAddPopupViewModelService, IProductRelatedViewModelService productRelatedViewModelService, IProductService productService, IRequestTranslationService translationService, IAdminDataScope scope) - : BaseProductRelatedController(productViewModelService, productRelatedViewModelService, productService, translationService, scope); + : BaseProductRelatedController(productAddPopupViewModelService, productRelatedViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductAssociatedController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductAssociatedController.cs index acbd3f409..f8e2b4bad 100644 --- a/src/Web/Grand.Web.Vendor/Controllers/ProductAssociatedController.cs +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductAssociatedController.cs @@ -15,13 +15,13 @@ namespace Grand.Web.Vendor.Controllers; [AuthorizeVendor] [AuthorizeMenu] public class ProductAssociatedController( - IProductViewModelService productViewModelService, + IProductAddPopupViewModelService productAddPopupViewModelService, IProductAssociatedViewModelService productAssociatedViewModelService, IProductService productService, IRequestTranslationService translationService, IAdminDataScope scope, IContextAccessor contextAccessor) - : BaseProductAssociatedController(productViewModelService, productAssociatedViewModelService, productService, translationService, scope) + : BaseProductAssociatedController(productAddPopupViewModelService, productAssociatedViewModelService, productService, translationService, scope) { // Vendor's original passed CurrentVendor.Id into GetAssociatedProducts(vendorId:) so a vendor only // sees the subset of a grouped product's associated products that they themselves own. Overriding diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductAttributeMappingController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductAttributeMappingController.cs index bcf00bcae..bf816911d 100644 --- a/src/Web/Grand.Web.Vendor/Controllers/ProductAttributeMappingController.cs +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductAttributeMappingController.cs @@ -15,10 +15,10 @@ namespace Grand.Web.Vendor.Controllers; [AuthorizeVendor] [AuthorizeMenu] public class ProductAttributeMappingController( - IProductViewModelService productViewModelService, + IProductAddPopupViewModelService productAddPopupViewModelService, IProductAttributeMappingViewModelService productAttributeMappingViewModelService, IProductService productService, ILanguageService languageService, IRequestTranslationService translationService, IAdminDataScope scope) - : BaseProductAttributeMappingController(productViewModelService, productAttributeMappingViewModelService, productService, languageService, translationService, scope); + : BaseProductAttributeMappingController(productAddPopupViewModelService, productAttributeMappingViewModelService, productService, languageService, translationService, scope); diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductBundleController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductBundleController.cs index db9e56745..8317c4b83 100644 --- a/src/Web/Grand.Web.Vendor/Controllers/ProductBundleController.cs +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductBundleController.cs @@ -15,12 +15,12 @@ namespace Grand.Web.Vendor.Controllers; [AuthorizeVendor] [AuthorizeMenu] public class ProductBundleController( - IProductViewModelService productViewModelService, + IProductAddPopupViewModelService productAddPopupViewModelService, IProductBundleViewModelService productBundleViewModelService, IProductService productService, IRequestTranslationService translationService, IAdminDataScope scope) - : BaseProductBundleController(productViewModelService, productBundleViewModelService, productService, translationService, scope) + : BaseProductBundleController(productAddPopupViewModelService, productBundleViewModelService, productService, translationService, scope) { // Vendor's original AddPopup(POST) actions returned Content(ModelState.GetErrors()) on an invalid // model, instead of Admin/Store's re-prepare-and-View. Overriding the base's Admin/Store default, diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductController.cs index b790514ca..a15f7a401 100644 --- a/src/Web/Grand.Web.Vendor/Controllers/ProductController.cs +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductController.cs @@ -18,11 +18,12 @@ namespace Grand.Web.Vendor.Controllers; [AuthorizeMenu] public class ProductController( IProductViewModelService productViewModelService, + IProductAddPopupViewModelService productAddPopupViewModelService, IProductService productService, ILanguageService languageService, IRequestTranslationService translationService, IDateTimeService dateTimeService, IRequestPermissionService permissionService, IAdminDataScope scope) - : BaseProductController(productViewModelService, productService, languageService, translationService, + : BaseProductController(productViewModelService, productAddPopupViewModelService, productService, languageService, translationService, dateTimeService, permissionService, scope); diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductRelatedController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductRelatedController.cs index 0f6d5eefd..7c534ac57 100644 --- a/src/Web/Grand.Web.Vendor/Controllers/ProductRelatedController.cs +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductRelatedController.cs @@ -15,12 +15,12 @@ namespace Grand.Web.Vendor.Controllers; [AuthorizeVendor] [AuthorizeMenu] public class ProductRelatedController( - IProductViewModelService productViewModelService, + IProductAddPopupViewModelService productAddPopupViewModelService, IProductRelatedViewModelService productRelatedViewModelService, IProductService productService, IRequestTranslationService translationService, IAdminDataScope scope) - : BaseProductRelatedController(productViewModelService, productRelatedViewModelService, productService, translationService, scope) + : BaseProductRelatedController(productAddPopupViewModelService, productRelatedViewModelService, productService, translationService, scope) { // Vendor's original AddPopup(POST) actions returned Content(ModelState.GetErrors()) on an invalid // model, instead of Admin/Store's re-prepare-and-View. Overriding the base's Admin/Store default, From 933a03272874dd4f10992cff77f8c95a19d676ac Mon Sep 17 00:00:00 2001 From: support Date: Thu, 22 Oct 2026 19:16:24 +0200 Subject: [PATCH 27/27] Set the blog post dates after insert in the tag paging test InsertAsync stamps CreatedOnUtc with the current time, so the dates the test gave were dropped and posts inserted in the same millisecond came back in any order. --- .../Services/BlogServiceTests.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Tests/Grand.Business.Cms.Tests/Services/BlogServiceTests.cs b/src/Tests/Grand.Business.Cms.Tests/Services/BlogServiceTests.cs index 37e8018c9..afd6a51ba 100644 --- a/src/Tests/Grand.Business.Cms.Tests/Services/BlogServiceTests.cs +++ b/src/Tests/Grand.Business.Cms.Tests/Services/BlogServiceTests.cs @@ -238,9 +238,12 @@ public async Task GetAllBlogPostsByTag_LoadsOnlyTheRequestedPageNewestFirst() _blogCategoryRepositoryMock.Object, _blogProductRepositoryMock.Object, _mediatorMock.Object, new AccessControlConfig()); for (var i = 0; i < 5; i++) - await repository.InsertAsync(new BlogPost { - Title = $"post {i}", Tags = "news", CreatedOnUtc = new DateTime(2026, 1, 1 + i, 0, 0, 0, DateTimeKind.Utc) - }); + { + //InsertAsync stamps CreatedOnUtc with the current time, so set the date afterwards + var post = await repository.InsertAsync(new BlogPost { Title = $"post {i}", Tags = "news" }); + post.CreatedOnUtc = new DateTime(2026, 1, 1 + i, 0, 0, 0, DateTimeKind.Utc); + await repository.UpdateAsync(post); + } var result = await blogService.GetAllBlogPostsByTag("", "news", 1, 2);