diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseGiftVoucherControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseGiftVoucherControllerTests.cs new file mode 100644 index 0000000000..3559e6023f --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseGiftVoucherControllerTests.cs @@ -0,0 +1,384 @@ +using Grand.Business.Core.Interfaces.Checkout.GiftVouchers; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Domain.Orders; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.DataSource; +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; + +[TestClass] +public class BaseGiftVoucherControllerTests +{ + private Mock _viewModelService; + private Mock _giftVoucherService; + private Mock _translationService; + private Mock> _scope; + + private class TestableGiftVoucherController( + IGiftVoucherViewModelService viewModelService, + IGiftVoucherService giftVoucherService, + ITranslationService translationService, + IAdminDataScope scope) + : BaseGiftVoucherController(viewModelService, giftVoucherService, translationService, scope); + + private TestableGiftVoucherController CreateController() + { + var controller = new TestableGiftVoucherController(_viewModelService.Object, _giftVoucherService.Object, + _translationService.Object, _scope.Object); + + // Set up HTTP context and controller context for BaseController methods (Success, etc.) + var httpContext = new DefaultHttpContext(); + 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); + + return controller; + } + + [TestInitialize] + public void Setup() + { + _viewModelService = new Mock(); + _giftVoucherService = new Mock(); + _translationService = new Mock(); + _scope = new Mock>(); + } + + [TestMethod] + public void List_ReturnsViewWithModel() + { + var listModel = new GiftVoucherListModel(); + _viewModelService.Setup(s => s.PrepareGiftVoucherListModel()).Returns(listModel); + + var result = CreateController().List() as ViewResult; + + Assert.IsNotNull(result); + Assert.AreSame(listModel, result.Model); + } + + [TestMethod] + public async Task GiftVoucherList_Admin_PassesNullDefaultStoreIdAsEmptyString() + { + _scope.Setup(s => s.DefaultStoreId).Returns((string)null); + _viewModelService + .Setup(s => s.PrepareGiftVoucherModel(It.IsAny(), 1, 10, "")) + .ReturnsAsync((Enumerable.Empty(), 0)); + + var result = await CreateController().GiftVoucherList( + new DataSourceRequest { Page = 1, PageSize = 10 }, new GiftVoucherListModel()) as JsonResult; + + Assert.IsNotNull(result); + _viewModelService.Verify(s => s.PrepareGiftVoucherModel(It.IsAny(), 1, 10, ""), Times.Once); + } + + [TestMethod] + public async Task GiftVoucherList_Store_PassesDefaultStoreId() + { + _scope.Setup(s => s.DefaultStoreId).Returns("store-1"); + _viewModelService + .Setup(s => s.PrepareGiftVoucherModel(It.IsAny(), 1, 10, "store-1")) + .ReturnsAsync((Enumerable.Empty(), 0)); + + await CreateController().GiftVoucherList( + new DataSourceRequest { Page = 1, PageSize = 10 }, new GiftVoucherListModel()); + + _viewModelService.Verify(s => s.PrepareGiftVoucherModel(It.IsAny(), 1, 10, "store-1"), Times.Once); + } + + [TestMethod] + public void GenerateCouponCode_ReturnsJsonWithGeneratedCode() + { + _giftVoucherService.Setup(s => s.GenerateGiftVoucherCode()).Returns("ABC123"); + + var result = CreateController().GenerateCouponCode() as JsonResult; + + Assert.IsNotNull(result); + } + + [TestMethod] + public async Task CreateGet_Admin_DoesNotForceStoreId() + { + _scope.Setup(s => s.DefaultStoreId).Returns((string)null); + var model = new GiftVoucherModel { + AvailableStores = { new Microsoft.AspNetCore.Mvc.Rendering.SelectListItem { Value = "", Text = "All" }, + new Microsoft.AspNetCore.Mvc.Rendering.SelectListItem { Value = "store-1", Text = "Store 1" } } + }; + _viewModelService.Setup(s => s.PrepareGiftVoucherModel((GiftVoucherModel)null)).ReturnsAsync(model); + + var result = await CreateController().Create() as ViewResult; + var returnedModel = result?.Model as GiftVoucherModel; + + Assert.IsNotNull(returnedModel); + Assert.AreEqual(2, returnedModel.AvailableStores.Count); + Assert.AreEqual("", returnedModel.StoreId ?? ""); + } + + [TestMethod] + public async Task CreateGet_Store_ForcesStoreIdAndFiltersAvailableStores() + { + _scope.Setup(s => s.DefaultStoreId).Returns("store-1"); + var model = new GiftVoucherModel { + AvailableStores = { new Microsoft.AspNetCore.Mvc.Rendering.SelectListItem { Value = "", Text = "All" }, + new Microsoft.AspNetCore.Mvc.Rendering.SelectListItem { Value = "store-1", Text = "Store 1" }, + new Microsoft.AspNetCore.Mvc.Rendering.SelectListItem { Value = "store-2", Text = "Store 2" } } + }; + _viewModelService.Setup(s => s.PrepareGiftVoucherModel((GiftVoucherModel)null)).ReturnsAsync(model); + + var result = await CreateController().Create() as ViewResult; + var returnedModel = result?.Model as GiftVoucherModel; + + Assert.IsNotNull(returnedModel); + Assert.AreEqual("store-1", returnedModel.StoreId); + Assert.AreEqual(1, returnedModel.AvailableStores.Count); + Assert.AreEqual("store-1", returnedModel.AvailableStores[0].Value); + } + + [TestMethod] + public async Task CreatePost_ValidModel_ForcesStoreIdWhenScoped_ThenInserts() + { + _scope.Setup(s => s.DefaultStoreId).Returns("store-1"); + var inserted = new GiftVoucher { Id = "gv-1", StoreId = "store-1" }; + _viewModelService.Setup(s => s.InsertGiftVoucherModel(It.IsAny())).ReturnsAsync(inserted); + _translationService.Setup(s => s.GetResource(It.IsAny())).Returns("Added"); + + var controller = CreateController(); + var model = new GiftVoucherModel(); + + var result = await controller.Create(model, false) as RedirectToActionResult; + + Assert.IsNotNull(result); + Assert.AreEqual("List", result.ActionName); + Assert.AreEqual("store-1", model.StoreId); + _viewModelService.Verify(s => s.InsertGiftVoucherModel(model), Times.Once); + } + + [TestMethod] + public async Task EditGet_NotFound_RedirectsToList() + { + _giftVoucherService.Setup(s => s.GetGiftVoucherById("missing")).ReturnsAsync((GiftVoucher)null); + + var result = await CreateController().Edit("missing") as RedirectToActionResult; + + Assert.IsNotNull(result); + Assert.AreEqual("List", result.ActionName); + } + + [TestMethod] + public async Task EditGet_CanViewFalse_RedirectsToList() + { + var giftVoucher = new GiftVoucher { Id = "gv-1", StoreId = "store-2" }; + _giftVoucherService.Setup(s => s.GetGiftVoucherById("gv-1")).ReturnsAsync(giftVoucher); + _scope.Setup(s => s.CanView(giftVoucher)).ReturnsAsync(false); + + var result = await CreateController().Edit("gv-1") as RedirectToActionResult; + + Assert.IsNotNull(result); + Assert.AreEqual("List", result.ActionName); + } + + [TestMethod] + public async Task EditGet_CanViewTrue_ReturnsViewWithModel() + { + var giftVoucher = new GiftVoucher { Id = "gv-1", StoreId = "" }; + var model = new GiftVoucherModel { Id = "gv-1" }; + _giftVoucherService.Setup(s => s.GetGiftVoucherById("gv-1")).ReturnsAsync(giftVoucher); + _scope.Setup(s => s.CanView(giftVoucher)).ReturnsAsync(true); + _scope.Setup(s => s.DefaultStoreId).Returns((string)null); + _viewModelService.Setup(s => s.PrepareGiftVoucherModel(giftVoucher)).ReturnsAsync(model); + + var result = await CreateController().Edit("gv-1") as ViewResult; + + Assert.IsNotNull(result); + Assert.AreSame(model, result.Model); + } + + [TestMethod] + public async Task EditPost_HasAccessFalse_RedirectsToEditWithoutSaving() + { + var giftVoucher = new GiftVoucher { Id = "gv-1", StoreId = "store-2" }; + _giftVoucherService.Setup(s => s.GetGiftVoucherById("gv-1")).ReturnsAsync(giftVoucher); + _scope.Setup(s => s.HasAccess(giftVoucher)).ReturnsAsync(false); + + var result = await CreateController().Edit(new GiftVoucherModel { Id = "gv-1" }, false) as RedirectToActionResult; + + Assert.IsNotNull(result); + Assert.AreEqual("Edit", result.ActionName); + _viewModelService.Verify(s => s.UpdateGiftVoucherModel(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task EditPost_HasAccessTrue_ForcesStoreIdWhenScoped_ThenSaves() + { + var giftVoucher = new GiftVoucher { Id = "gv-1", StoreId = "store-1" }; + _giftVoucherService.Setup(s => s.GetGiftVoucherById("gv-1")).ReturnsAsync(giftVoucher); + _scope.Setup(s => s.HasAccess(giftVoucher)).ReturnsAsync(true); + _scope.Setup(s => s.DefaultStoreId).Returns("store-1"); + _viewModelService.Setup(s => s.FillGiftVoucherModel(giftVoucher, It.IsAny())) + .ReturnsAsync((GiftVoucher gv2, GiftVoucherModel m2) => m2); + _viewModelService.Setup(s => s.UpdateGiftVoucherModel(giftVoucher, It.IsAny())).ReturnsAsync(giftVoucher); + _translationService.Setup(s => s.GetResource(It.IsAny())).Returns("Updated"); + + var model = new GiftVoucherModel { Id = "gv-1" }; + var result = await CreateController().Edit(model, false) as RedirectToActionResult; + + Assert.IsNotNull(result); + Assert.AreEqual("List", result.ActionName); + Assert.AreEqual("store-1", model.StoreId); + } + + [TestMethod] + public async Task Delete_HasAccessFalse_RedirectsToEditWithoutDeleting() + { + var giftVoucher = new GiftVoucher { Id = "gv-1", StoreId = "store-2" }; + _giftVoucherService.Setup(s => s.GetGiftVoucherById("gv-1")).ReturnsAsync(giftVoucher); + _scope.Setup(s => s.HasAccess(giftVoucher)).ReturnsAsync(false); + + var result = await CreateController().Delete(new GiftVoucherDeleteModel("gv-1")) as RedirectToActionResult; + + Assert.IsNotNull(result); + Assert.AreEqual("Edit", result.ActionName); + _viewModelService.Verify(s => s.DeleteGiftVoucher(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Delete_HasAccessTrue_InvalidModelState_DoesNotDeleteAndRedirectsToEdit() + { + var giftVoucher = new GiftVoucher { Id = "gv-1", StoreId = "store-1" }; + _giftVoucherService.Setup(s => s.GetGiftVoucherById("gv-1")).ReturnsAsync(giftVoucher); + _scope.Setup(s => s.HasAccess(giftVoucher)).ReturnsAsync(true); + + var controller = CreateController(); + controller.ModelState.AddModelError("Test", "Test error"); + + var result = await controller.Delete(new GiftVoucherDeleteModel("gv-1")) as RedirectToActionResult; + + Assert.IsNotNull(result); + Assert.AreEqual("Edit", result.ActionName); + _viewModelService.Verify(s => s.DeleteGiftVoucher(It.IsAny()), Times.Never); + Assert.IsTrue(controller.TempData["grand.notifications.Error"] is List errors + && errors.Contains("Test error")); + } + + [TestMethod] + public async Task Delete_HasAccessTrue_DeletesAndRedirectsToList() + { + var giftVoucher = new GiftVoucher { Id = "gv-1", StoreId = "store-1" }; + _giftVoucherService.Setup(s => s.GetGiftVoucherById("gv-1")).ReturnsAsync(giftVoucher); + _scope.Setup(s => s.HasAccess(giftVoucher)).ReturnsAsync(true); + _translationService.Setup(s => s.GetResource(It.IsAny())).Returns("Deleted"); + + var result = await CreateController().Delete(new GiftVoucherDeleteModel("gv-1")) as RedirectToActionResult; + + Assert.IsNotNull(result); + Assert.AreEqual("List", result.ActionName); + _viewModelService.Verify(s => s.DeleteGiftVoucher(giftVoucher), Times.Once); + } + + [TestMethod] + public async Task NotifyRecipient_HasAccessFalse_RedirectsWithoutNotifying() + { + var giftVoucher = new GiftVoucher { Id = "gv-1", StoreId = "store-2" }; + _giftVoucherService.Setup(s => s.GetGiftVoucherById("gv-1")).ReturnsAsync(giftVoucher); + _scope.Setup(s => s.HasAccess(giftVoucher)).ReturnsAsync(false); + + var result = await CreateController().NotifyRecipient(new GiftVoucherNotifyRecipient("gv-1")) as RedirectToActionResult; + + Assert.IsNotNull(result); + Assert.AreEqual("Edit", result.ActionName); + _viewModelService.Verify(s => s.NotifyRecipient(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task UsageHistoryList_HasAccessTrue_ReturnsGrid() + { + var giftVoucher = new GiftVoucher { Id = "gv-1", StoreId = "store-1" }; + _giftVoucherService.Setup(s => s.GetGiftVoucherById("gv-1")).ReturnsAsync(giftVoucher); + _scope.Setup(s => s.HasAccess(giftVoucher)).ReturnsAsync(true); + _viewModelService.Setup(s => s.PrepareGiftVoucherUsageHistoryModels(giftVoucher, 1, 10)) + .ReturnsAsync((Enumerable.Empty(), 0)); + + var result = await CreateController().UsageHistoryList("gv-1", + new DataSourceRequest { Page = 1, PageSize = 10 }) as JsonResult; + + Assert.IsNotNull(result); + } + + [TestMethod] + public async Task UsageHistoryList_HasAccessFalse_ThrowsArgumentException() + { + var giftVoucher = new GiftVoucher { Id = "gv-1", StoreId = "store-2" }; + _giftVoucherService.Setup(s => s.GetGiftVoucherById("gv-1")).ReturnsAsync(giftVoucher); + _scope.Setup(s => s.HasAccess(giftVoucher)).ReturnsAsync(false); + + await Assert.ThrowsExactlyAsync(() => + CreateController().UsageHistoryList("gv-1", new DataSourceRequest { Page = 1, PageSize = 10 })); + } + + // Regression test for the leak this fix closes: a global voucher (empty StoreId) is + // CanView == true (Edit itself stays viewable read-only for it) but must NOT be admitted to + // UsageHistoryList, because its usage-history rows can reference other stores' orders. Before + // the fix, UsageHistoryList gated on CanView and would have returned the grid here. + [TestMethod] + public async Task UsageHistoryList_GlobalVoucher_CanViewTrueButHasAccessFalse_ThrowsArgumentException() + { + var giftVoucher = new GiftVoucher { Id = "gv-1", StoreId = "" }; + _giftVoucherService.Setup(s => s.GetGiftVoucherById("gv-1")).ReturnsAsync(giftVoucher); + _scope.Setup(s => s.CanView(giftVoucher)).ReturnsAsync(true); + _scope.Setup(s => s.HasAccess(giftVoucher)).ReturnsAsync(false); + + await Assert.ThrowsExactlyAsync(() => + CreateController().UsageHistoryList("gv-1", new DataSourceRequest { Page = 1, PageSize = 10 })); + } + + [TestMethod] + public async Task EditPost_InvalidModel_CallsPrepareGiftVoucherModelToRepopulateAvailableStores() + { + var giftVoucher = new GiftVoucher { Id = "gv-1", StoreId = "" }; + _giftVoucherService.Setup(s => s.GetGiftVoucherById("gv-1")).ReturnsAsync(giftVoucher); + _scope.Setup(s => s.HasAccess(giftVoucher)).ReturnsAsync(true); + _scope.Setup(s => s.DefaultStoreId).Returns((string)null); + + // Simulate invalid model state by returning the model with AvailableStores populated + var preparedModel = new GiftVoucherModel { + Id = "gv-1", + AvailableStores = { + new Microsoft.AspNetCore.Mvc.Rendering.SelectListItem { Value = "", Text = "All" }, + new Microsoft.AspNetCore.Mvc.Rendering.SelectListItem { Value = "store-1", Text = "Store 1" } + } + }; + _viewModelService.Setup(s => s.FillGiftVoucherModel(giftVoucher, It.IsAny())) + .ReturnsAsync((GiftVoucher gv2, GiftVoucherModel m2) => m2); + _viewModelService.Setup(s => s.PrepareGiftVoucherModel(It.IsAny())) + .ReturnsAsync(preparedModel); + + var controller = CreateController(); + // Force ModelState.IsValid to be false by adding a model error + controller.ModelState.AddModelError("Test", "Test error"); + + var model = new GiftVoucherModel { Id = "gv-1" }; + var result = await controller.Edit(model, false) as ViewResult; + + Assert.IsNotNull(result); + var returnedModel = result.Model as GiftVoucherModel; + Assert.IsNotNull(returnedModel); + // Verify that AvailableStores was populated by PrepareGiftVoucherModel + Assert.AreEqual(2, returnedModel.AvailableStores.Count); + _viewModelService.Verify(s => s.PrepareGiftVoucherModel(It.IsAny()), Times.Once); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/GiftVoucherControllerAttributeTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/GiftVoucherControllerAttributeTests.cs new file mode 100644 index 0000000000..4d127b8339 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/GiftVoucherControllerAttributeTests.cs @@ -0,0 +1,71 @@ +using System.Linq; +using System.Reflection; +using Grand.Domain.Permissions; +using Grand.Web.Admin.Controllers; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class GiftVoucherControllerAttributeTests +{ + [TestMethod] + public void IsThinSubclassOfBaseGiftVoucherController() + { + Assert.IsTrue(typeof(BaseGiftVoucherController).IsAssignableFrom(typeof(GiftVoucherController))); + Assert.AreEqual(typeof(BaseGiftVoucherController), typeof(GiftVoucherController).BaseType); + } + + [TestMethod] + public void HasAuthorizeAdminAttribute() + { + var attr = typeof(GiftVoucherController).GetCustomAttributes(typeof(AuthorizeAdminAttribute), inherit: false); + Assert.AreEqual(1, attr.Length); + } + + [TestMethod] + public void HasAreaAdminAttribute() + { + var attr = typeof(GiftVoucherController) + .GetCustomAttributes(typeof(AreaAttribute), inherit: false) + .Cast().Single(); + Assert.AreEqual("Admin", attr.RouteValue); + } + + [TestMethod] + public void HasAutoValidateAntiforgeryTokenAttribute() + { + var attr = typeof(GiftVoucherController) + .GetCustomAttributes(typeof(AutoValidateAntiforgeryTokenAttribute), inherit: true); + Assert.AreEqual(1, attr.Length); + } + + [TestMethod] + public void HasAuthorizeMenuAttribute() + { + var attr = typeof(GiftVoucherController).GetCustomAttributes(typeof(AuthorizeMenuAttribute), inherit: false); + Assert.AreEqual(1, attr.Length); + } + + // Regression test for the disclosed bug fix noted on BaseGiftVoucherController.Create(POST): + // pre-consolidation, Admin's own Create(POST) required PermissionActionName.Edit while + // everything else on Create required .Create. Pin the fixed permission via reflection so a + // future edit can't silently regress it. + [TestMethod] + public void CreatePost_RequiresCreatePermission() + { + var method = typeof(BaseGiftVoucherController).GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Single(m => m.Name == "Create" && m.GetParameters().Length == 2 + && m.GetParameters()[0].ParameterType == typeof(GiftVoucherModel)); + + var attr = method.GetCustomAttributes(typeof(PermissionAuthorizeActionAttribute), inherit: false) + .Cast().Single(); + + Assert.AreEqual(PermissionActionName.Create, attr.PermissionAction); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/RoutedGiftVoucherDataScopeTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/RoutedGiftVoucherDataScopeTests.cs new file mode 100644 index 0000000000..7420b2f616 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/RoutedGiftVoucherDataScopeTests.cs @@ -0,0 +1,77 @@ +using Grand.Domain.Orders; +using Grand.Web.AdminShared.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class RoutedGiftVoucherDataScopeTests +{ + private static RoutedGiftVoucherDataScope CreateScope(string area, out StoreGiftVoucherDataScope storeScope) + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.RouteValues = new RouteValueDictionary { ["area"] = area }; + var httpContextAccessor = new Mock(); + httpContextAccessor.Setup(a => a.HttpContext).Returns(httpContext); + + var contextAccessor = new Mock(); + var workContext = new Mock(); + workContext.Setup(w => w.CurrentCustomer).Returns(new Grand.Domain.Customers.Customer { StaffStoreId = "store-1" }); + contextAccessor.Setup(c => c.WorkContext).Returns(workContext.Object); + + storeScope = new StoreGiftVoucherDataScope(contextAccessor.Object); + return new RoutedGiftVoucherDataScope(httpContextAccessor.Object, + new GlobalAdminDataScope(), storeScope); + } + + [TestMethod] + public async Task AdminArea_ResolvesToGlobalScope_HasAccessAlwaysTrue() + { + var scope = CreateScope("Admin", out _); + var result = await scope.HasAccess(new GiftVoucher { StoreId = "any-other-store" }); + Assert.IsTrue(result); + } + + [TestMethod] + public void AdminArea_DefaultStoreId_IsNull() + { + var scope = CreateScope("Admin", out _); + Assert.IsNull(scope.DefaultStoreId); + } + + [TestMethod] + public async Task StoreArea_ResolvesToStoreScope_HasAccessMatchesOwnership() + { + var scope = CreateScope("Store", out _); + var owned = await scope.HasAccess(new GiftVoucher { StoreId = "store-1" }); + var other = await scope.HasAccess(new GiftVoucher { StoreId = "store-2" }); + Assert.IsTrue(owned); + Assert.IsFalse(other); + } + + [TestMethod] + public void StoreArea_DefaultStoreId_IsStaffStoreId() + { + var scope = CreateScope("Store", out _); + Assert.AreEqual("store-1", scope.DefaultStoreId); + } + + [TestMethod] + public async Task UnrecognizedArea_ThrowsInvalidOperationException() + { + var scope = CreateScope("Vendor", out _); + await Assert.ThrowsAsync( + () => scope.HasAccess(new GiftVoucher { StoreId = "store-1" })); + } + + [TestMethod] + public async Task MissingArea_ThrowsInvalidOperationException() + { + var scope = CreateScope(null, out _); + await Assert.ThrowsAsync( + () => scope.HasAccess(new GiftVoucher { StoreId = "store-1" })); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/StoreGiftVoucherDataScopeTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/StoreGiftVoucherDataScopeTests.cs new file mode 100644 index 0000000000..1cf3f04c02 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/StoreGiftVoucherDataScopeTests.cs @@ -0,0 +1,101 @@ +using Grand.Domain.Customers; +using Grand.Domain.Orders; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class StoreGiftVoucherDataScopeTests +{ + private static StoreGiftVoucherDataScope CreateScope(string staffStoreId) + { + var customer = new Customer { StaffStoreId = staffStoreId }; + var workContext = new Mock(); + workContext.Setup(w => w.CurrentCustomer).Returns(customer); + var contextAccessor = new Mock(); + contextAccessor.Setup(c => c.WorkContext).Returns(workContext.Object); + return new StoreGiftVoucherDataScope(contextAccessor.Object); + } + + [TestMethod] + public async Task HasAccess_OwnStore_ReturnsTrue() + { + var scope = CreateScope("store-1"); + var result = await scope.HasAccess(new GiftVoucher { StoreId = "store-1" }); + Assert.IsTrue(result); + } + + [TestMethod] + public async Task HasAccess_OtherStore_ReturnsFalse() + { + var scope = CreateScope("store-1"); + var result = await scope.HasAccess(new GiftVoucher { StoreId = "store-2" }); + Assert.IsFalse(result); + } + + [TestMethod] + public async Task HasAccess_GlobalVoucher_ReturnsFalse() + { + var scope = CreateScope("store-1"); + var result = await scope.HasAccess(new GiftVoucher { StoreId = "" }); + Assert.IsFalse(result); + } + + [TestMethod] + public async Task HasAccess_NullEntity_ReturnsFalse() + { + var scope = CreateScope("store-1"); + var result = await scope.HasAccess(null); + Assert.IsFalse(result); + } + + [TestMethod] + public async Task CanView_OwnStore_ReturnsTrue() + { + var scope = CreateScope("store-1"); + var result = await scope.CanView(new GiftVoucher { StoreId = "store-1" }); + Assert.IsTrue(result); + } + + [TestMethod] + public async Task CanView_GlobalVoucher_ReturnsTrue() + { + var scope = CreateScope("store-1"); + var result = await scope.CanView(new GiftVoucher { StoreId = "" }); + Assert.IsTrue(result); + } + + [TestMethod] + public async Task CanView_NullStoreId_ReturnsTrue() + { + var scope = CreateScope("store-1"); + var result = await scope.CanView(new GiftVoucher { StoreId = null }); + Assert.IsTrue(result); + } + + [TestMethod] + public async Task CanView_OtherStore_ReturnsFalse() + { + var scope = CreateScope("store-1"); + var result = await scope.CanView(new GiftVoucher { StoreId = "store-2" }); + Assert.IsFalse(result); + } + + [TestMethod] + public async Task CanView_NullEntity_ReturnsFalse() + { + var scope = CreateScope("store-1"); + var result = await scope.CanView(null); + Assert.IsFalse(result); + } + + [TestMethod] + public void DefaultStoreId_ReturnsStaffStoreId() + { + var scope = CreateScope("store-1"); + Assert.AreEqual("store-1", scope.DefaultStoreId); + } +} diff --git a/src/Tests/Grand.Web.Store.Tests/Controllers/GiftVoucherControllerAttributeTests.cs b/src/Tests/Grand.Web.Store.Tests/Controllers/GiftVoucherControllerAttributeTests.cs new file mode 100644 index 0000000000..81681034a4 --- /dev/null +++ b/src/Tests/Grand.Web.Store.Tests/Controllers/GiftVoucherControllerAttributeTests.cs @@ -0,0 +1,140 @@ +using Grand.Business.Core.Interfaces.Checkout.GiftVouchers; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Domain.Orders; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Security.Authorization; +using Grand.Web.Store.Controllers; +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.Store.Tests.Controllers; + +[TestClass] +public class GiftVoucherControllerAttributeTests +{ + [TestMethod] + public void IsThinSubclassOfBaseGiftVoucherController() + { + Assert.IsTrue(typeof(BaseGiftVoucherController).IsAssignableFrom(typeof(GiftVoucherController))); + } + + [TestMethod] + public void HasAuthorizeStoreAttribute() + { + var attr = typeof(GiftVoucherController).GetCustomAttributes(typeof(AuthorizeStoreAttribute), inherit: false); + Assert.AreEqual(1, attr.Length); + } + + [TestMethod] + public void HasAreaStoreAttribute() + { + var attr = typeof(GiftVoucherController) + .GetCustomAttributes(typeof(AreaAttribute), inherit: false) + .Cast().Single(); + Assert.AreEqual("Store", attr.RouteValue); + } + + [TestMethod] + public void HasAuthorizeMenuAttribute() + { + var attr = typeof(GiftVoucherController).GetCustomAttributes(typeof(AuthorizeMenuAttribute), inherit: false); + Assert.AreEqual(1, attr.Length); + } + + [TestMethod] + public void HasAutoValidateAntiforgeryTokenAttribute() + { + var attr = typeof(GiftVoucherController) + .GetCustomAttributes(typeof(AutoValidateAntiforgeryTokenAttribute), inherit: true); + Assert.AreEqual(1, attr.Length); + } + + // --- EditWarningCheck ---------------------------------------------------------------------- + // + // Behavioral tests for GiftVoucherController.EditWarningCheck, exercised indirectly through + // the public Edit(GET) action since EditWarningCheck itself is protected. Mirrors + // BrandControllerTests.EditWarningCheckTests's pattern. + + [TestClass] + public class EditWarningCheckTests + { + private const string PermissionsResourceKey = "Admin.GiftVouchers.Permissions"; + private const string DefaultStoreId = "store-1"; + + private GiftVoucherController _controller; + private Mock _viewModelServiceMock; + private Mock _giftVoucherServiceMock; + private Mock _translationServiceMock; + private Mock> _scopeMock; + + [TestInitialize] + public void Setup() + { + _viewModelServiceMock = new Mock(); + _viewModelServiceMock.Setup(s => s.PrepareGiftVoucherModel(It.IsAny())) + .ReturnsAsync(new Grand.Web.AdminShared.Models.Orders.GiftVoucherModel()); + _giftVoucherServiceMock = new Mock(); + _translationServiceMock = new Mock(); + _translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns("resource"); + + _scopeMock = new Mock>(); + _scopeMock.Setup(s => s.DefaultStoreId).Returns(DefaultStoreId); + _scopeMock.Setup(s => s.CanView(It.IsAny())).ReturnsAsync(true); + + _controller = new GiftVoucherController( + _viewModelServiceMock.Object, + _giftVoucherServiceMock.Object, + _translationServiceMock.Object, + _scopeMock.Object); + + var httpContext = new DefaultHttpContext(); + 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); + } + + private bool WarningWasRaised() + { + return _controller.TempData["grand.notifications.Warning"] is List warnings + && warnings.Contains("resource"); + } + + [TestMethod] + public async Task EditGet_GlobalVoucher_RaisesPermissionsWarning() + { + var giftVoucher = new GiftVoucher { Id = "gv-1", StoreId = "" }; + _giftVoucherServiceMock.Setup(s => s.GetGiftVoucherById("gv-1")).ReturnsAsync(giftVoucher); + + await _controller.Edit("gv-1"); + + Assert.IsTrue(WarningWasRaised()); + _translationServiceMock.Verify(t => t.GetResource(PermissionsResourceKey), Times.Once); + } + + [TestMethod] + public async Task EditGet_OwnStoreVoucher_DoesNotRaiseWarning() + { + var giftVoucher = new GiftVoucher { Id = "gv-1", StoreId = DefaultStoreId }; + _giftVoucherServiceMock.Setup(s => s.GetGiftVoucherById("gv-1")).ReturnsAsync(giftVoucher); + + await _controller.Edit("gv-1"); + + Assert.IsFalse(WarningWasRaised()); + _translationServiceMock.Verify(t => t.GetResource(PermissionsResourceKey), Times.Never); + } + } +} diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Create.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Create.cshtml deleted file mode 100644 index 2805358297..0000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Create.cshtml +++ /dev/null @@ -1,35 +0,0 @@ -@model GiftVoucherModel -@{ - //page title - ViewBag.Title = Loc["Admin.GiftVouchers.AddNew"]; -} -
- -
-
-
-
-
- - @Loc["Admin.GiftVouchers.AddNew"] - - @Html.ActionLink(Loc["Admin.GiftVouchers.BackToList"], "List") - -
-
- - - -
-
-
- -
-
-
-
-
\ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Edit.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Edit.cshtml deleted file mode 100644 index 1455845f6a..0000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Edit.cshtml +++ /dev/null @@ -1,39 +0,0 @@ -@model GiftVoucherModel -@{ - //page title - ViewBag.Title = Loc["Admin.GiftVouchers.EditGiftVoucherDetails"]; -} -
- -
-
-
-
-
- - @Loc["Admin.GiftVouchers.EditGiftVoucherDetails"] - - @Html.ActionLink(Loc["Admin.GiftVouchers.BackToList"], "List") - -
-
- - - - @Loc["Admin.Common.Delete"] - - -
-
-
- -
-
-
-
-
- \ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/List.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/List.cshtml deleted file mode 100644 index e0fd17a2be..0000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/List.cshtml +++ /dev/null @@ -1,166 +0,0 @@ -@model GiftVoucherListModel -@inject AdminAreaSettings adminAreaSettings - -@{ - //page title - ViewBag.Title = Loc["Admin.GiftVouchers"]; -} - -
-
-
-
-
- - @Loc["Admin.GiftVouchers"] -
- -
-
-
-
-
-
-
- - -
-
-
-
- - -
-
-
-
- - -
-
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
- - - \ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Partials/CreateOrUpdate.TabHistory.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Partials/CreateOrUpdate.TabHistory.cshtml deleted file mode 100644 index 441ee0f3a2..0000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Partials/CreateOrUpdate.TabHistory.cshtml +++ /dev/null @@ -1,62 +0,0 @@ -@model GiftVoucherModel -@inject AdminAreaSettings adminAreaSettings -@{ - -
- - -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Partials/CreateOrUpdate.TabInfo.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Partials/CreateOrUpdate.TabInfo.cshtml deleted file mode 100644 index b77d4d125e..0000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Partials/CreateOrUpdate.TabInfo.cshtml +++ /dev/null @@ -1,190 +0,0 @@ -@model GiftVoucherModel - -
- -
-
- -
- - -
-
- @if (!string.IsNullOrEmpty(Model.PurchasedWithOrderId)) - { - - } -
- -
- - @if (!string.IsNullOrEmpty(Model.Id)) - { - [@Model.CurrencyCode] - } - -
-
- @if (string.IsNullOrEmpty(Model.Id)) - { -
- -
- - -
-
- } - @if (!string.IsNullOrEmpty(Model.Id)) - { -
- -
- -
-
- } -
- -
- - -
-
-
- -
- - -
-
-
- -
-
-
- -
-
- -
-
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
- @if (!string.IsNullOrEmpty(Model.Id)) - { -
- -
- - -
-
- } - @if (!string.IsNullOrEmpty(Model.Id)) - { -
- -
- -
-
- } -
- -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Partials/CreateOrUpdate.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Partials/CreateOrUpdate.cshtml deleted file mode 100644 index a94f69cf30..0000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Partials/CreateOrUpdate.cshtml +++ /dev/null @@ -1,26 +0,0 @@ -@model GiftVoucherModel - -
- - - - - -
- -
-
-
- @if (!string.IsNullOrEmpty(Model.Id)) - { - - -
- -
-
-
- } - -
-
\ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Partials/WidgetZone.DetailsButtons.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Partials/WidgetZone.DetailsButtons.cshtml new file mode 100644 index 0000000000..1be1a97735 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Partials/WidgetZone.DetailsButtons.cshtml @@ -0,0 +1,2 @@ +@model GiftVoucherModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Partials/WidgetZone.HistoryBottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Partials/WidgetZone.HistoryBottom.cshtml new file mode 100644 index 0000000000..722246fe57 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Partials/WidgetZone.HistoryBottom.cshtml @@ -0,0 +1,2 @@ +@model GiftVoucherModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Partials/WidgetZone.HistoryTop.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Partials/WidgetZone.HistoryTop.cshtml new file mode 100644 index 0000000000..b925a6e3b1 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Partials/WidgetZone.HistoryTop.cshtml @@ -0,0 +1,2 @@ +@model GiftVoucherModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Partials/WidgetZone.InfoBottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Partials/WidgetZone.InfoBottom.cshtml new file mode 100644 index 0000000000..177cda8d91 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Partials/WidgetZone.InfoBottom.cshtml @@ -0,0 +1,2 @@ +@model GiftVoucherModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Partials/WidgetZone.InfoTop.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Partials/WidgetZone.InfoTop.cshtml new file mode 100644 index 0000000000..3283a65cdd --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Partials/WidgetZone.InfoTop.cshtml @@ -0,0 +1,2 @@ +@model GiftVoucherModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Partials/WidgetZone.ListButtons.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Partials/WidgetZone.ListButtons.cshtml new file mode 100644 index 0000000000..49ac035468 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Partials/WidgetZone.ListButtons.cshtml @@ -0,0 +1,2 @@ +@model GiftVoucherListModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Partials/WidgetZone.Tabs.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Partials/WidgetZone.Tabs.cshtml new file mode 100644 index 0000000000..05a3dcf832 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Partials/WidgetZone.Tabs.cshtml @@ -0,0 +1,2 @@ +@model GiftVoucherModel + diff --git a/src/Web/Grand.Web.Admin/Controllers/GiftVoucherController.cs b/src/Web/Grand.Web.Admin/Controllers/GiftVoucherController.cs index e531579c2a..946b53d8c3 100644 --- a/src/Web/Grand.Web.Admin/Controllers/GiftVoucherController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/GiftVoucherController.cs @@ -1,203 +1,28 @@ -using Grand.Business.Core.Interfaces.Checkout.GiftVouchers; +using Grand.Business.Core.Interfaces.Checkout.GiftVouchers; using Grand.Business.Core.Interfaces.Common.Localization; -using Grand.Domain.Permissions; +using Grand.Domain.Orders; +using Grand.Web.Admin.Extensions; +using Grand.Web.AdminShared.Controllers; using Grand.Web.AdminShared.Interfaces; -using Grand.Web.AdminShared.Models.Orders; -using Grand.Web.Common.DataSource; using Grand.Web.Common.Filters; -using Grand.Web.Common.Security.Authorization; using Microsoft.AspNetCore.Mvc; namespace Grand.Web.Admin.Controllers; -[PermissionAuthorize(PermissionSystemName.GiftVouchers)] -public class GiftVoucherController : BaseAdminController -{ - #region Constructors - - public GiftVoucherController( - IGiftVoucherViewModelService giftVoucherViewModelService, - IGiftVoucherService giftVoucherService, - ITranslationService translationService) - { - _giftVoucherViewModelService = giftVoucherViewModelService; - _giftVoucherService = giftVoucherService; - _translationService = translationService; - } - - #endregion - - #region Fields - - private readonly IGiftVoucherViewModelService _giftVoucherViewModelService; - private readonly IGiftVoucherService _giftVoucherService; - private readonly ITranslationService _translationService; - - #endregion - - #region Methods - - //list - public IActionResult Index() - { - return RedirectToAction("List"); - } - - public IActionResult List() - { - var model = _giftVoucherViewModelService.PrepareGiftVoucherListModel(); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.List)] - [HttpPost] - public async Task GiftVoucherList(DataSourceRequest command, GiftVoucherListModel model) - { - var (giftVoucherModels, totalCount) = - await _giftVoucherViewModelService.PrepareGiftVoucherModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = giftVoucherModels.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Create)] - public async Task Create() - { - var model = await _giftVoucherViewModelService.PrepareGiftVoucherModel(); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] - public async Task Create(GiftVoucherModel model, bool continueEditing) - { - if (ModelState.IsValid) - { - var giftVoucher = await _giftVoucherViewModelService.InsertGiftVoucherModel(model); - Success(_translationService.GetResource("Admin.GiftVouchers.Added")); - return continueEditing ? RedirectToAction("Edit", new { id = giftVoucher.Id }) : RedirectToAction("List"); - } - - //If we got this far, something failed, redisplay form - model = await _giftVoucherViewModelService.PrepareGiftVoucherModel(model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task Edit(string id) - { - var giftVoucher = await _giftVoucherService.GetGiftVoucherById(id); - if (giftVoucher == null) - //No gift voucher found with the specified id - return RedirectToAction("List"); - - var model = await _giftVoucherViewModelService.PrepareGiftVoucherModel(giftVoucher); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] - public async Task Edit(GiftVoucherModel model, bool continueEditing) - { - var giftVoucher = await _giftVoucherService.GetGiftVoucherById(model.Id); - if (giftVoucher == null) - return RedirectToAction("List"); - - await _giftVoucherViewModelService.FillGiftVoucherModel(giftVoucher, model); - - if (ModelState.IsValid) - { - giftVoucher = await _giftVoucherViewModelService.UpdateGiftVoucherModel(giftVoucher, model); - Success(_translationService.GetResource("Admin.GiftVouchers.Updated")); - - if (continueEditing) - { - //selected tab - await SaveSelectedTabIndex(); - - return RedirectToAction("Edit", new { id = giftVoucher.Id }); - } - - return RedirectToAction("List"); - } - - //If we got this far, something failed, redisplay form - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public IActionResult GenerateCouponCode() - { - return Json(new { CouponCode = _giftVoucherService.GenerateGiftVoucherCode() }); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task NotifyRecipient(GiftVoucherNotifyRecipient model) - { - var giftVoucher = await _giftVoucherService.GetGiftVoucherById(model.Id); - - try - { - if (ModelState.IsValid) - await _giftVoucherViewModelService.NotifyRecipient(giftVoucher); - else - Error(ModelState); - } - catch (Exception exc) - { - Error(exc, false); - } - - return RedirectToAction("Edit", new { id = model.Id }); - } - - [PermissionAuthorizeAction(PermissionActionName.Delete)] - [HttpPost] - public async Task Delete(GiftVoucherDeleteModel model) - { - var giftVoucher = await _giftVoucherService.GetGiftVoucherById(model.Id); - if (giftVoucher == null) - //No gift voucher found with the specified id - return RedirectToAction("List"); - - if (ModelState.IsValid) - { - await _giftVoucherViewModelService.DeleteGiftVoucher(giftVoucher); - Success(_translationService.GetResource("Admin.GiftVouchers.Deleted")); - return RedirectToAction("List"); - } - - Error(ModelState); - return RedirectToAction("Edit", new { id = giftVoucher.Id }); - } - - //Gif card usage history - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task UsageHistoryList(string giftVoucherId, DataSourceRequest command) - { - var giftVoucher = await _giftVoucherService.GetGiftVoucherById(giftVoucherId); - if (giftVoucher == null) - throw new ArgumentException("No gift voucher found with the specified id"); - - var (giftVoucherUsageHistoryModels, totalCount) = - await _giftVoucherViewModelService.PrepareGiftVoucherUsageHistoryModels(giftVoucher, command.Page, - command.PageSize); - var gridModel = new DataSourceResult { - Data = giftVoucherUsageHistoryModels.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - #endregion -} \ No newline at end of file +// Reduced to a thin subclass of BaseGiftVoucherController (ARCH-001 GiftVoucher +// consolidation). All regions of behavior live in the shared base; this class only supplies +// Admin's DI wiring plus the attributes that used to arrive transitively via +// BaseAdminController - BaseGiftVoucherController can't inherit any single host's base +// controller (it's shared across Admin/Store, each with a different [Area]/[Authorize*] pair), +// so each subclass restates its own host's attribute set explicitly. Same pattern as +// CategoryController (see that file). +[AuthorizeAdmin] +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaAdmin)] +[AuthorizeMenu] +public class GiftVoucherController( + IGiftVoucherViewModelService giftVoucherViewModelService, + IGiftVoucherService giftVoucherService, + ITranslationService translationService, + IAdminDataScope scope) + : BaseGiftVoucherController(giftVoucherViewModelService, giftVoucherService, translationService, scope); diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseGiftVoucherController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseGiftVoucherController.cs new file mode 100644 index 0000000000..8a18c09d1d --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseGiftVoucherController.cs @@ -0,0 +1,240 @@ +using Grand.Business.Core.Interfaces.Checkout.GiftVouchers; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Domain.Orders; +using Grand.Domain.Permissions; +using Grand.Web.AdminShared.Interfaces; +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.Security.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.AdminShared.Controllers; + +[PermissionAuthorize(PermissionSystemName.GiftVouchers)] +[AutoValidateAntiforgeryToken] +public abstract class BaseGiftVoucherController( + IGiftVoucherViewModelService giftVoucherViewModelService, + IGiftVoucherService giftVoucherService, + ITranslationService translationService, + IAdminDataScope scope) + : BaseController +{ + /// Hook for host-specific UI-copy warnings that aren't access-scope decisions. + /// Overridden by the Store subclass (Task 5); no-op everywhere else. Mirrors + /// BaseCategoryController.EditWarningCheck. + protected virtual void EditWarningCheck(GiftVoucher giftVoucher) { } + + // Exposed for host subclasses: primary-constructor parameters are not visible to derived + // classes by name in C#, so Store's EditWarningCheck override needs this. + protected ITranslationService TranslationService => translationService; + protected IAdminDataScope Scope => scope; + + #region List + + public IActionResult Index() => RedirectToAction("List"); + + public IActionResult List() + { + var model = giftVoucherViewModelService.PrepareGiftVoucherListModel(); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.List)] + [HttpPost] + public async Task GiftVoucherList(DataSourceRequest command, GiftVoucherListModel model) + { + var (giftVoucherModels, totalCount) = await giftVoucherViewModelService.PrepareGiftVoucherModel( + model, command.Page, command.PageSize, scope.DefaultStoreId ?? ""); + + return Json(new DataSourceResult { + Data = giftVoucherModels.ToList(), + Total = totalCount + }); + } + + #endregion + + #region Create + + [PermissionAuthorizeAction(PermissionActionName.Create)] + public async Task Create() + { + var model = await giftVoucherViewModelService.PrepareGiftVoucherModel(); + return ApplyDefaultStore(model); + } + + // Was PermissionActionName.Edit on Admin's pre-consolidation controller while Admin's own GET + // and both of Store's Create actions required Create - a disclosed bug fix, not a new + // restriction. See spec "Design > BaseGiftVoucherController" bullet on the Create(POST) fix. + [PermissionAuthorizeAction(PermissionActionName.Create)] + [HttpPost] + [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] + public async Task Create(GiftVoucherModel model, bool continueEditing) + { + if (!string.IsNullOrEmpty(scope.DefaultStoreId)) model.StoreId = scope.DefaultStoreId; + + if (ModelState.IsValid) + { + var giftVoucher = await giftVoucherViewModelService.InsertGiftVoucherModel(model); + Success(translationService.GetResource("Admin.GiftVouchers.Added")); + return continueEditing ? RedirectToAction("Edit", new { id = giftVoucher.Id }) : RedirectToAction("List"); + } + + model = await giftVoucherViewModelService.PrepareGiftVoucherModel(model); + return View(ApplyDefaultStoreToModel(model)); + } + + #endregion + + #region Edit / Delete + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + public async Task Edit(string id) + { + var giftVoucher = await giftVoucherService.GetGiftVoucherById(id); + if (giftVoucher == null) return RedirectToAction("List"); + + EditWarningCheck(giftVoucher); + // CanView, not HasAccess: viewing a global (empty-StoreId) voucher is allowed on Store + // (with a warning from EditWarningCheck above); only mutating one is restricted to the + // exclusive single-store owner. See IAdminDataScope.CanView's doc comment. + if (!await scope.CanView(giftVoucher)) return RedirectToAction("List"); + + var model = await giftVoucherViewModelService.PrepareGiftVoucherModel(giftVoucher); + return View(ApplyDefaultStoreToModel(model)); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] + public async Task Edit(GiftVoucherModel model, bool continueEditing) + { + var giftVoucher = await giftVoucherService.GetGiftVoucherById(model.Id); + if (giftVoucher == null) return RedirectToAction("List"); + if (!await scope.HasAccess(giftVoucher)) return RedirectToAction("Edit", new { id = giftVoucher.Id }); + + if (!string.IsNullOrEmpty(scope.DefaultStoreId)) model.StoreId = scope.DefaultStoreId; + await giftVoucherViewModelService.FillGiftVoucherModel(giftVoucher, model); + + if (ModelState.IsValid) + { + giftVoucher = await giftVoucherViewModelService.UpdateGiftVoucherModel(giftVoucher, model); + Success(translationService.GetResource("Admin.GiftVouchers.Updated")); + + if (continueEditing) + { + await SaveSelectedTabIndex(); + return RedirectToAction("Edit", new { id = giftVoucher.Id }); + } + + return RedirectToAction("List"); + } + + model = await giftVoucherViewModelService.PrepareGiftVoucherModel(model); + return View(ApplyDefaultStoreToModel(model)); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task NotifyRecipient(GiftVoucherNotifyRecipient model) + { + var giftVoucher = await giftVoucherService.GetGiftVoucherById(model.Id); + if (giftVoucher == null) return RedirectToAction("List"); + if (!await scope.HasAccess(giftVoucher)) return RedirectToAction("Edit", new { id = model.Id }); + + try + { + if (ModelState.IsValid) + await giftVoucherViewModelService.NotifyRecipient(giftVoucher); + else + Error(ModelState); + } + catch (Exception exc) + { + Error(exc, false); + } + + return RedirectToAction("Edit", new { id = model.Id }); + } + + [PermissionAuthorizeAction(PermissionActionName.Delete)] + [HttpPost] + public async Task Delete(GiftVoucherDeleteModel model) + { + var giftVoucher = await giftVoucherService.GetGiftVoucherById(model.Id); + if (giftVoucher == null) return RedirectToAction("List"); + if (!await scope.HasAccess(giftVoucher)) return RedirectToAction("Edit", new { id = giftVoucher.Id }); + + if (ModelState.IsValid) + { + await giftVoucherViewModelService.DeleteGiftVoucher(giftVoucher); + Success(translationService.GetResource("Admin.GiftVouchers.Deleted")); + return RedirectToAction("List"); + } + + Error(ModelState); + return RedirectToAction("Edit", new { id = giftVoucher.Id }); + } + + #endregion + + #region Gift voucher usage history + + // HasAccess, not CanView: a global voucher's usage-history rows can reference orders from any + // store that redeemed it (GetGiftVoucherQueryHandler makes an empty-StoreId voucher visible + // cross-store), so admitting CanView here would leak other stores' order ids/numbers/amounts + // to a Store user viewing a global voucher's History tab. This intentionally makes the History + // tab unavailable for a global voucher even though Edit itself is viewable read-only for it - + // filtering rows by store would be needlessly complex for this rarely-used tab. + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task UsageHistoryList(string giftVoucherId, DataSourceRequest command) + { + var giftVoucher = await giftVoucherService.GetGiftVoucherById(giftVoucherId); + if (giftVoucher == null || !await scope.HasAccess(giftVoucher)) + throw new ArgumentException("No gift voucher found with the specified id"); + + var (giftVoucherUsageHistoryModels, totalCount) = await giftVoucherViewModelService + .PrepareGiftVoucherUsageHistoryModels(giftVoucher, command.Page, command.PageSize); + + return Json(new DataSourceResult { + Data = giftVoucherUsageHistoryModels.ToList(), + Total = totalCount + }); + } + + #endregion + + #region Shared helpers + + private IActionResult ApplyDefaultStore(GiftVoucherModel model) + { + return View(ApplyDefaultStoreToModel(model)); + } + + // Forces the current store onto a new/edited voucher and hides every other store from the + // dropdown - a no-op for Admin (scope.DefaultStoreId is null), matches Store's original + // SetCurrentStore helper exactly. + private GiftVoucherModel ApplyDefaultStoreToModel(GiftVoucherModel model) + { + if (string.IsNullOrEmpty(scope.DefaultStoreId)) return model; + model.StoreId = scope.DefaultStoreId; + model.AvailableStores = model.AvailableStores.Where(x => x.Value == scope.DefaultStoreId).ToList(); + return model; + } + + #endregion + + #region Gift card generation + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public IActionResult GenerateCouponCode() + { + return Json(new { CouponCode = giftVoucherService.GenerateGiftVoucherCode() }); + } + + #endregion +} diff --git a/src/Web/Grand.Web.AdminShared/Services/RoutedGiftVoucherDataScope.cs b/src/Web/Grand.Web.AdminShared/Services/RoutedGiftVoucherDataScope.cs new file mode 100644 index 0000000000..e181129e89 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Services/RoutedGiftVoucherDataScope.cs @@ -0,0 +1,55 @@ +#nullable enable + +using Grand.Domain.Orders; +using Grand.Web.AdminShared.Interfaces; +using Microsoft.AspNetCore.Http; + +namespace Grand.Web.AdminShared.Services; + +/// +/// Resolves the correct per-host implementation at +/// request time, based on the current request's "area" route value — same fix and same reason as +/// (see that file's doc comment): Grand.Web (the combined +/// host) loads Admin and Store together in one DI container, so a plain +/// AddScoped<IAdminDataScope<GiftVoucher>, X>() per host would silently let whichever +/// host's StartupApplication ran last win for every area in that process. +/// +/// There is no Vendor branch: Vendor has no GiftVoucher screen at all, so any "Vendor" (or other +/// unrecognized/missing) area value fails closed. +/// +public class RoutedGiftVoucherDataScope( + IHttpContextAccessor httpContextAccessor, + GlobalAdminDataScope globalScope, + StoreGiftVoucherDataScope storeScope) : IAdminDataScope +{ + private IAdminDataScope Resolved + { + get + { + var area = httpContextAccessor.HttpContext?.Request.RouteValues["area"] as string; + return area switch { + "Admin" => globalScope, + "Store" => storeScope, + //fail closed: this object fronts store tenant isolation, so an unrecognized or + //missing area (including "Vendor" - GiftVoucher has no Vendor screen) must never + //silently resolve to the unscoped global scope + _ => throw new InvalidOperationException( + $"RoutedGiftVoucherDataScope: unrecognized or missing area '{area}'.") + }; + } + } + + public Task HasAccess(GiftVoucher entity) => Resolved.HasAccess(entity); + + public Task CanView(GiftVoucher entity) => Resolved.CanView(entity); + + public string? DefaultStoreId => Resolved.DefaultStoreId; + + public string ResourceKeyPrefix => Resolved.ResourceKeyPrefix; + + public bool ShowStoreSelector => Resolved.ShowStoreSelector; + + public string? DefaultVendorId => Resolved.DefaultVendorId; + + public bool CanFeatureOnHomepage => Resolved.CanFeatureOnHomepage; +} diff --git a/src/Web/Grand.Web.AdminShared/Services/StoreGiftVoucherDataScope.cs b/src/Web/Grand.Web.AdminShared/Services/StoreGiftVoucherDataScope.cs new file mode 100644 index 0000000000..1cc0a348fd --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Services/StoreGiftVoucherDataScope.cs @@ -0,0 +1,39 @@ +#nullable enable + +using Grand.Domain.Orders; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Interfaces; + +namespace Grand.Web.AdminShared.Services; + +/// +/// Store's . Bespoke, not the generic +/// : GiftVoucher is a plain +/// with a single StoreId field, not IStoreLinkEntity (no Stores/ +/// LimitedToStores list) — same shape family as Order/. +/// +/// Unlike Order, GiftVoucher has an implicit "global" concept: an empty/null StoreId is +/// visible from every store per GetGiftVoucherQueryHandler's +/// gc.StoreId == request.StoreId || gc.StoreId == null || gc.StoreId == "" filter, and +/// Store's original List.cshtml already rendered such vouchers (without an edit link, +/// since the original controller's ownership check denied Edit outright). +/// makes that loose visibility explicit so Edit can open it read-only with a warning instead of +/// redirecting away, matching Category/Collection/Product's established split. +/// +public class StoreGiftVoucherDataScope(IContextAccessor contextAccessor) : IAdminDataScope +{ + public Task HasAccess(GiftVoucher entity) => + Task.FromResult(entity is not null && + entity.StoreId == contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); + + public Task CanView(GiftVoucher entity) => + Task.FromResult(entity is not null && + (string.IsNullOrEmpty(entity.StoreId) || + entity.StoreId == contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)); + + public string? DefaultStoreId => contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; + public string ResourceKeyPrefix => "Admin"; + public bool ShowStoreSelector => true; + public string? DefaultVendorId => null; + public bool CanFeatureOnHomepage => true; +} diff --git a/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs index 7c6d91cbfe..d5dd59020c 100644 --- a/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs +++ b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs @@ -134,6 +134,13 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddScoped(); services.AddScoped, RoutedMerchandiseReturnDataScope>(); + // IAdminDataScope: registered once here for the same reason as + // Category/Collection above — see RoutedGiftVoucherDataScope's doc comment. No Vendor + // scope: GiftVoucher has no Vendor screen. + services.AddScoped>(); + services.AddScoped(); + services.AddScoped, RoutedGiftVoucherDataScope>(); + // IReportDataScope: NOT an IAdminDataScope registration (Reports has no entity — // see IReportDataScope's doc comment and ARCH-001 Reports consolidation spec §3). All three // hosts have a Reports screen, so all three concrete scopes are registered. diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/Create.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/GiftVoucher/Create.cshtml similarity index 84% rename from src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/Create.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/GiftVoucher/Create.cshtml index d0d00e9866..a86a972bd9 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/Create.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/GiftVoucher/Create.cshtml @@ -1,10 +1,9 @@ @model GiftVoucherModel - @{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); ViewBag.Title = Loc["Admin.GiftVouchers.AddNew"]; } - -
+
@@ -23,6 +22,7 @@ +
diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/Edit.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/GiftVoucher/Edit.cshtml similarity index 87% rename from src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/Edit.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/GiftVoucher/Edit.cshtml index f2d5c921b3..b72ce2f4ad 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/Edit.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/GiftVoucher/Edit.cshtml @@ -1,10 +1,9 @@ @model GiftVoucherModel - @{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); ViewBag.Title = Loc["Admin.GiftVouchers.EditGiftVoucherDetails"]; } - - +
@@ -26,6 +25,7 @@ @Loc["Admin.Common.Delete"] +
diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/List.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/GiftVoucher/List.cshtml similarity index 91% rename from src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/List.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/GiftVoucher/List.cshtml index 22465c5d88..53b62ef210 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/List.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/GiftVoucher/List.cshtml @@ -2,6 +2,7 @@ @inject AdminAreaSettings adminAreaSettings @{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); ViewBag.Title = Loc["Admin.GiftVouchers"]; } @@ -14,9 +15,10 @@ @Loc["Admin.GiftVouchers"]
@@ -65,7 +67,7 @@ dataSource: { transport: { read: { - url: "@Html.Raw(Url.Action("GiftVoucherList", "GiftVoucher", new { area = Constants.AreaStore }))", + url: "@Html.Raw(Url.Action("GiftVoucherList", "GiftVoucher", new { area }))", type: "POST", dataType: "json", data: additionalData @@ -97,17 +99,17 @@ columns: [{ field: "AmountStr", title: "@Loc["Admin.GiftVouchers.Fields.Amount"]", - template: '# if(StoreId) {# #=AmountStr# #} else {# #=AmountStr# #} #', + template: '#=AmountStr#', width: 100, }, { field: "RemainingAmountStr", title: "@Loc["Admin.GiftVouchers.Fields.RemainingAmount"]", - template: '# if(StoreId) {# #=RemainingAmountStr# #} else {# #=RemainingAmountStr# #} #', + template: '#=RemainingAmountStr#', width: 100, }, { field: "Code", title: "@Loc["Admin.GiftVouchers.Fields.Code"]", - template: '# if(StoreId) {# #=Code# #} else {# #=Code# #} #', + template: '#=Code#', width: 120, }, { field: "RecipientName", diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/Partials/CreateOrUpdate.TabHistory.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/GiftVoucher/Partials/CreateOrUpdate.TabHistory.cshtml similarity index 82% rename from src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/Partials/CreateOrUpdate.TabHistory.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/GiftVoucher/Partials/CreateOrUpdate.TabHistory.cshtml index 00e3a7c5d2..7a54268543 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/Partials/CreateOrUpdate.TabHistory.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/GiftVoucher/Partials/CreateOrUpdate.TabHistory.cshtml @@ -1,14 +1,18 @@ @model GiftVoucherModel @inject AdminAreaSettings adminAreaSettings - +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +} +
+ -
+
@@ -30,13 +29,16 @@
+ @{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); + } @if (!string.IsNullOrEmpty(Model.PurchasedWithOrderId)) { @@ -71,6 +73,16 @@
} + @if (Model.AvailableStores.Count > 1) + { +
+ +
+ + +
+
+ }
@@ -102,7 +114,7 @@ $.ajax({ cache:false, type: "POST", - url: "@(Url.Action("GenerateCouponCode", "GiftVoucher", new { area = Constants.AreaStore }))", + url: "@(Url.Action("GenerateCouponCode", "GiftVoucher", new { area }))", data: postData, success: function (data) { $('#@Html.IdFor(model => model.Code)').val(data.CouponCode); @@ -178,4 +190,5 @@
}
+
diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/Partials/CreateOrUpdate.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/GiftVoucher/Partials/CreateOrUpdate.cshtml similarity index 84% rename from src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/Partials/CreateOrUpdate.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/GiftVoucher/Partials/CreateOrUpdate.cshtml index e7a8fba1af..590830c6f4 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/Partials/CreateOrUpdate.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/GiftVoucher/Partials/CreateOrUpdate.cshtml @@ -2,7 +2,10 @@
- +@if (Model.AvailableStores.Count <= 1) +{ + +} @@ -22,5 +25,6 @@ } + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/Partials/WidgetZone.DetailsButtons.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/Partials/WidgetZone.DetailsButtons.cshtml new file mode 100644 index 0000000000..2f265cd934 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/Partials/WidgetZone.DetailsButtons.cshtml @@ -0,0 +1,2 @@ +@model GiftVoucherModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/Partials/WidgetZone.HistoryBottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/Partials/WidgetZone.HistoryBottom.cshtml new file mode 100644 index 0000000000..d8a91f7598 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/Partials/WidgetZone.HistoryBottom.cshtml @@ -0,0 +1,2 @@ +@model GiftVoucherModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/Partials/WidgetZone.HistoryTop.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/Partials/WidgetZone.HistoryTop.cshtml new file mode 100644 index 0000000000..7ec16e2d81 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/Partials/WidgetZone.HistoryTop.cshtml @@ -0,0 +1,2 @@ +@model GiftVoucherModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/Partials/WidgetZone.InfoBottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/Partials/WidgetZone.InfoBottom.cshtml new file mode 100644 index 0000000000..c986c10964 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/Partials/WidgetZone.InfoBottom.cshtml @@ -0,0 +1,2 @@ +@model GiftVoucherModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/Partials/WidgetZone.InfoTop.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/Partials/WidgetZone.InfoTop.cshtml new file mode 100644 index 0000000000..53c66e062e --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/Partials/WidgetZone.InfoTop.cshtml @@ -0,0 +1,2 @@ +@model GiftVoucherModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/Partials/WidgetZone.ListButtons.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/Partials/WidgetZone.ListButtons.cshtml new file mode 100644 index 0000000000..7ff11b1112 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/Partials/WidgetZone.ListButtons.cshtml @@ -0,0 +1,6 @@ +@* vc:store-widget, not vc:admin-widget: that tag helper is not registered in the Store app (no + ProjectReference to Grand.Web.Admin). Store's pre-consolidation views had no calls at + all for GiftVoucher (unlike every other ARCH-001 phase, which had dead copy-pasted + vc:admin-widget markup to convert) - these 7 satellites are new additions, not conversions. *@ +@model GiftVoucherListModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/Partials/WidgetZone.Tabs.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/Partials/WidgetZone.Tabs.cshtml new file mode 100644 index 0000000000..5b6d16db4a --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/Partials/WidgetZone.Tabs.cshtml @@ -0,0 +1,2 @@ +@model GiftVoucherModel + diff --git a/src/Web/Grand.Web.Store/Controllers/GiftVoucherController.cs b/src/Web/Grand.Web.Store/Controllers/GiftVoucherController.cs index f95998f868..b52b1fc494 100644 --- a/src/Web/Grand.Web.Store/Controllers/GiftVoucherController.cs +++ b/src/Web/Grand.Web.Store/Controllers/GiftVoucherController.cs @@ -1,214 +1,41 @@ using Grand.Business.Core.Interfaces.Checkout.GiftVouchers; using Grand.Business.Core.Interfaces.Common.Localization; using Grand.Domain.Orders; -using Grand.Domain.Permissions; -using Grand.Infrastructure; +using Grand.Web.AdminShared.Controllers; using Grand.Web.AdminShared.Interfaces; -using Grand.Web.AdminShared.Models.Orders; -using Grand.Web.Common.DataSource; using Grand.Web.Common.Filters; -using Grand.Web.Common.Security.Authorization; +using Grand.Web.Store.Extensions; using Microsoft.AspNetCore.Mvc; namespace Grand.Web.Store.Controllers; -[PermissionAuthorize(PermissionSystemName.GiftVouchers)] -public class GiftVoucherController : BaseStoreController +// Reduced to a thin subclass of BaseGiftVoucherController (ARCH-001 GiftVoucher +// consolidation). All regions of behavior live in the shared base; this class only supplies +// Store's DI wiring, the EditWarningCheck hook, and the attributes that used to arrive +// transitively via BaseStoreController. Same pattern as CategoryController's EditWarningCheck +// override (see that file). +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaStore)] +[AuthorizeStore] +[AuthorizeMenu] +public class GiftVoucherController( + IGiftVoucherViewModelService giftVoucherViewModelService, + IGiftVoucherService giftVoucherService, + ITranslationService translationService, + IAdminDataScope scope) + : BaseGiftVoucherController(giftVoucherViewModelService, giftVoucherService, translationService, scope) { - private readonly IGiftVoucherViewModelService _giftVoucherViewModelService; - private readonly IGiftVoucherService _giftVoucherService; - private readonly ITranslationService _translationService; - private readonly IContextAccessor _contextAccessor; - - public GiftVoucherController( - IGiftVoucherViewModelService giftVoucherViewModelService, - IGiftVoucherService giftVoucherService, - ITranslationService translationService, - IContextAccessor contextAccessor) - { - _giftVoucherViewModelService = giftVoucherViewModelService; - _giftVoucherService = giftVoucherService; - _translationService = translationService; - _contextAccessor = contextAccessor; - } - - private string CurrentStoreId => _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - - public IActionResult Index() - { - return RedirectToAction("List"); - } - - public IActionResult List() - { - var model = _giftVoucherViewModelService.PrepareGiftVoucherListModel(); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.List)] - [HttpPost] - public async Task GiftVoucherList(DataSourceRequest command, GiftVoucherListModel model) - { - var (giftVoucherModels, totalCount) = - await _giftVoucherViewModelService.PrepareGiftVoucherModel(model, command.Page, command.PageSize, - CurrentStoreId); - - return Json(new DataSourceResult { - Data = giftVoucherModels.ToList(), - Total = totalCount - }); - } - - [PermissionAuthorizeAction(PermissionActionName.Create)] - public async Task Create() - { - var model = await PrepareStoreGiftVoucherModel(); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Create)] - [HttpPost] - [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] - public async Task Create(GiftVoucherModel model, bool continueEditing) - { - model.StoreId = CurrentStoreId; - - if (ModelState.IsValid) - { - var giftVoucher = await _giftVoucherViewModelService.InsertGiftVoucherModel(model); - Success(_translationService.GetResource("Admin.GiftVouchers.Added")); - return continueEditing ? RedirectToAction("Edit", new { id = giftVoucher.Id }) : RedirectToAction("List"); - } - - model = await PrepareStoreGiftVoucherModel(model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task Edit(string id) - { - var giftVoucher = await GetCurrentStoreGiftVoucher(id); - if (giftVoucher == null) - return RedirectToAction("List"); - - var model = await _giftVoucherViewModelService.PrepareGiftVoucherModel(giftVoucher); - model = SetCurrentStore(model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] - public async Task Edit(GiftVoucherModel model, bool continueEditing) - { - var giftVoucher = await GetCurrentStoreGiftVoucher(model.Id); - if (giftVoucher == null) - return RedirectToAction("List"); - - model.StoreId = CurrentStoreId; - await _giftVoucherViewModelService.FillGiftVoucherModel(giftVoucher, model); - - if (ModelState.IsValid) - { - giftVoucher = await _giftVoucherViewModelService.UpdateGiftVoucherModel(giftVoucher, model); - Success(_translationService.GetResource("Admin.GiftVouchers.Updated")); - - if (continueEditing) - { - await SaveSelectedTabIndex(); - return RedirectToAction("Edit", new { id = giftVoucher.Id }); - } - - return RedirectToAction("List"); - } - - model = SetCurrentStore(model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public IActionResult GenerateCouponCode() - { - return Json(new { CouponCode = _giftVoucherService.GenerateGiftVoucherCode() }); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task NotifyRecipient(GiftVoucherNotifyRecipient model) - { - var giftVoucher = await GetCurrentStoreGiftVoucher(model.Id); - if (giftVoucher == null) - return RedirectToAction("List"); - - try - { - if (ModelState.IsValid) - await _giftVoucherViewModelService.NotifyRecipient(giftVoucher); - else - Error(ModelState); - } - catch (Exception exc) - { - Error(exc, false); - } - - return RedirectToAction("Edit", new { id = model.Id }); - } - - [PermissionAuthorizeAction(PermissionActionName.Delete)] - [HttpPost] - public async Task Delete(GiftVoucherDeleteModel model) - { - var giftVoucher = await GetCurrentStoreGiftVoucher(model.Id); - if (giftVoucher == null) - return RedirectToAction("List"); - - if (ModelState.IsValid) - { - await _giftVoucherViewModelService.DeleteGiftVoucher(giftVoucher); - Success(_translationService.GetResource("Admin.GiftVouchers.Deleted")); - return RedirectToAction("List"); - } - - Error(ModelState); - return RedirectToAction("Edit", new { id = giftVoucher.Id }); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task UsageHistoryList(string giftVoucherId, DataSourceRequest command) - { - var giftVoucher = await GetCurrentStoreGiftVoucher(giftVoucherId); - if (giftVoucher == null) - throw new ArgumentException("No gift voucher found with the specified id"); - - var (giftVoucherUsageHistoryModels, totalCount) = - await _giftVoucherViewModelService.PrepareGiftVoucherUsageHistoryModels(giftVoucher, command.Page, - command.PageSize); - - return Json(new DataSourceResult { - Data = giftVoucherUsageHistoryModels.ToList(), - Total = totalCount - }); - } - - private async Task PrepareStoreGiftVoucherModel(GiftVoucherModel model = null) - { - model = await _giftVoucherViewModelService.PrepareGiftVoucherModel(model); - return SetCurrentStore(model); - } - - private GiftVoucherModel SetCurrentStore(GiftVoucherModel model) - { - model.StoreId = CurrentStoreId; - model.AvailableStores = model.AvailableStores.Where(x => x.Value == CurrentStoreId).ToList(); - return model; - } - - private async Task GetCurrentStoreGiftVoucher(string id) - { - var giftVoucher = await _giftVoucherService.GetGiftVoucherById(id); - return giftVoucher?.StoreId == CurrentStoreId ? giftVoucher : null; + // Re-derived from the current design spec's finding: GetGiftVoucherQueryHandler treats an + // empty/null StoreId as visible from every store, so a global voucher must warn, not block, + // on Edit - matches Category/Collection/Page/News's proven EditWarningCheck idiom, adapted + // for GiftVoucher's flat StoreId (no LimitedToStores/Stores list to inspect). + // Only the genuinely global case (empty StoreId) warns here - a voucher owned by another store + // is denied a moment later by the CanView gate in the base class's Edit(GET), so warning on + // that condition too would leak a cross-tenant id-existence oracle (warn for another store's + // id, no warning for a nonexistent one). + protected override void EditWarningCheck(GiftVoucher giftVoucher) + { + if (string.IsNullOrEmpty(giftVoucher.StoreId)) + Warning(TranslationService.GetResource("Admin.GiftVouchers.Permissions")); } } diff --git a/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_240.xml b/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_240.xml index f3f4545fa5..05aa4dab58 100644 --- a/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_240.xml +++ b/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_240.xml @@ -45,4 +45,7 @@ Invalid value. + + You can't edit this gift voucher, because it can be used in many stores. +