From fb447308cd31aaed0637c613257bbea46b97c2e5 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Fri, 11 Sep 2026 03:54:27 +0200 Subject: [PATCH] ARCH-001 Phase 27: consolidate SearchController's Category/Collection/Brand picker Admin/Store/Vendor's SearchController each duplicate 3 Kendo-autocomplete picker methods (Category/Collection/Brand). Admin's Index (full admin command/menu search) and CustomerGroup/Stores/Vendor pickers have no Store/Vendor equivalent and stay in Grand.Web.Admin's own controller untouched - same "consolidate-a-sub-resource" shape as TaxCategory (Phase 22), but the first 3-way (Admin+Store+Vendor) instance of it. Design note - deliberately does NOT reuse IAdminDataScope// : those entities' routed scopes (RoutedCategoryDataScope etc.) fail closed for the "Vendor" area, because Category/Collection/Brand have no Vendor CRUD screen. This picker sub-resource DOES run under Vendor (Vendor's own SearchController already exposed it), so resolving one of those scopes here would throw InvalidOperationException on every Vendor picker call. Investigated each host's original code instead: Admin and Vendor both hardcoded storeId: "" (no store filter); only Store scoped by WorkContext.CurrentCustomer.StaffStoreId. Confirmed in CategoryService. GetAllCategories (and the same shape in Collection/Brand's services) that storeId: "" and storeId: null are handled identically (!string.IsNullOrEmpty(storeId) gates the filter both ways), so a new `protected virtual string PickerStoreId => "";` on BaseSearchController (Admin/Vendor's original behavior, no override needed) overridden only by Store's concrete subclass exactly replicates all 3 hosts' original behavior. No behavior change. New BaseSearchController in Grand.Web.AdminShared holds Category/Collection/ Brand. Admin/Store/Vendor's SearchController are now thin subclasses restating their host's [Authorize*]/[Area]/[AuthorizeMenu]/[AutoValidateAntiforgeryToken] attribute set (BaseSearchController can't inherit any single host's base controller - same reason as every other Base*Controller in this initiative). No prior test coverage existed for any of the 3 originals. Added: - BaseSearchControllerTests: Category/Collection/Brand x {default "" storeId, overridden storeId} against the shared base directly. - SearchControllerAttributeTests (Admin + Store) - subclass/attribute wiring, Store's includes one behavior test proving PickerStoreId actually reflects WorkContext.CurrentCustomer.StaffStoreId end-to-end. - SearchControllerSurfaceTests (Vendor) - same attribute-wiring shape, matching Grand.Web.Vendor.Tests' existing *SurfaceTests naming convention (VendorReviewControllerSurfaceTests). Verified: dotnet build GrandNode.sln 0 errors. Per-project test runs (parallel full-solution runs are known-flaky): Grand.Web.Admin.Tests 1439/1439, Grand.Web.Store.Tests 142/142, Grand.Web.Vendor.Tests 26/26. No live smoke test run this phase - deferred, not blocking (read-only picker endpoints, no cross-tenant/access-control surface changed, same justification class as Phase 24's deferral). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013y3MqvZq7y1Uc5p4JZad2i --- .../Controllers/BaseSearchControllerTests.cs | 161 ++++++++++++++++++ .../SearchControllerAttributeTests.cs | 67 ++++++++ .../SearchControllerAttributeTests.cs | 84 +++++++++ .../SearchControllerSurfaceTests.cs | 46 +++++ .../Controllers/SearchController.cs | 66 ++----- .../Controllers/BaseSearchController.cs | 79 +++++++++ .../Controllers/SearchController.cs | 86 +++------- .../Controllers/SearchController.cs | 85 +++------ 8 files changed, 496 insertions(+), 178 deletions(-) create mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/BaseSearchControllerTests.cs create mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/SearchControllerAttributeTests.cs create mode 100644 src/Tests/Grand.Web.Store.Tests/Controllers/SearchControllerAttributeTests.cs create mode 100644 src/Tests/Grand.Web.Vendor.Tests/Controllers/SearchControllerSurfaceTests.cs create mode 100644 src/Web/Grand.Web.AdminShared/Controllers/BaseSearchController.cs diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseSearchControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseSearchControllerTests.cs new file mode 100644 index 0000000000..56c5f6fa05 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseSearchControllerTests.cs @@ -0,0 +1,161 @@ +using Grand.Business.Core.Interfaces.Catalog.Brands; +using Grand.Business.Core.Interfaces.Catalog.Categories; +using Grand.Business.Core.Interfaces.Catalog.Collections; +using Grand.Domain; +using Grand.Domain.Admin; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.Common.DataSource; +using Microsoft.AspNetCore.Mvc; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class BaseSearchControllerTests +{ + // Concrete subclass exists only to instantiate the abstract base for direct-call unit tests, + // with PickerStoreId parameterized so both the Admin/Vendor shape ("") and the Store shape (a + // real store id) can be exercised without needing the 3 real host subclasses - same pattern as + // BaseTaxCategoryControllerTests. + private class TestSearchController( + ICategoryService categoryService, + IBrandService brandService, + ICollectionService collectionService, + AdminSearchSettings adminSearchSettings, + string pickerStoreId) + : BaseSearchController(categoryService, brandService, collectionService, adminSearchSettings) + { + protected override string PickerStoreId => pickerStoreId; + } + + private Mock _categoryServiceMock = null!; + private Mock _brandServiceMock = null!; + private Mock _collectionServiceMock = null!; + private AdminSearchSettings _settings = null!; + + [TestInitialize] + public void Setup() + { + _categoryServiceMock = new Mock(); + _brandServiceMock = new Mock(); + _collectionServiceMock = new Mock(); + _settings = new AdminSearchSettings { + CategorySizeLimit = 10, + CollectionSizeLimit = 10, + BrandSizeLimit = 10 + }; + } + + private TestSearchController CreateController(string pickerStoreId) => + new(_categoryServiceMock.Object, _brandServiceMock.Object, _collectionServiceMock.Object, _settings, pickerStoreId); + + private static DataSourceRequestFilter EmptyFilter() => new() { Filters = [] }; + + // Anonymous JSON payloads aren't used here (DataSourceResult is a public type with public + // members), but Data's items are instances of a private nested record - read them via + // reflection, same pattern established in BasePictureControllerTests. + private static (string Id, string Name)[] ReadRows(IActionResult result) + { + var data = (System.Collections.IEnumerable)((JsonResult)result).Value!.GetType().GetProperty("Data")! + .GetValue(((JsonResult)result).Value)!; + var rows = new List<(string, string)>(); + foreach (var item in data) + { + var id = (string)item.GetType().GetProperty("Id")!.GetValue(item)!; + var name = (string)item.GetType().GetProperty("Name")!.GetValue(item)!; + rows.Add((id, name)); + } + return rows.ToArray(); + } + + [TestMethod] + public async Task Category_DefaultPickerStoreId_PassesEmptyStoreIdToService() + { + var controller = CreateController(""); + _categoryServiceMock + .Setup(s => s.GetAllCategories(null, null, "", 0, 10, false)) + .ReturnsAsync(new PagedList([new Category { Id = "cat-1", Name = "Books" }], 0, 10)); + _categoryServiceMock.Setup(s => s.GetFormattedBreadCrumb(It.IsAny(), ">>", "")) + .ReturnsAsync("Books"); + + var result = await controller.Category(null, EmptyFilter()); + + var rows = ReadRows(result); + Assert.AreEqual(1, rows.Length); + Assert.AreEqual("cat-1", rows[0].Id); + _categoryServiceMock.Verify(s => s.GetAllCategories(null, null, "", 0, 10, false), Times.Once); + } + + [TestMethod] + public async Task Category_OverriddenPickerStoreId_PassesStoreIdToService() + { + var controller = CreateController("store-1"); + _categoryServiceMock + .Setup(s => s.GetAllCategories(null, null, "store-1", 0, 10, false)) + .ReturnsAsync(new PagedList([], 0, 10)); + + await controller.Category(null, EmptyFilter()); + + _categoryServiceMock.Verify(s => s.GetAllCategories(null, null, "store-1", 0, 10, false), Times.Once); + } + + [TestMethod] + public async Task Collection_DefaultPickerStoreId_PassesEmptyStoreIdToService() + { + var controller = CreateController(""); + _collectionServiceMock + .Setup(s => s.GetAllCollections(null, "", 0, 10, false)) + .ReturnsAsync(new PagedList([new Collection { Id = "col-1", Name = "Summer" }], 0, 10)); + + var result = await controller.Collection(null, EmptyFilter()); + + var rows = ReadRows(result); + Assert.AreEqual(1, rows.Length); + Assert.AreEqual("Summer", rows[0].Name); + _collectionServiceMock.Verify(s => s.GetAllCollections(null, "", 0, 10, false), Times.Once); + } + + [TestMethod] + public async Task Collection_OverriddenPickerStoreId_PassesStoreIdToService() + { + var controller = CreateController("store-1"); + _collectionServiceMock + .Setup(s => s.GetAllCollections(null, "store-1", 0, 10, false)) + .ReturnsAsync(new PagedList([], 0, 10)); + + await controller.Collection(null, EmptyFilter()); + + _collectionServiceMock.Verify(s => s.GetAllCollections(null, "store-1", 0, 10, false), Times.Once); + } + + [TestMethod] + public async Task Brand_DefaultPickerStoreId_PassesEmptyStoreIdToService() + { + var controller = CreateController(""); + _brandServiceMock + .Setup(s => s.GetAllBrands(null, "", 0, 10, false)) + .ReturnsAsync(new PagedList([new Brand { Id = "brand-1", Name = "Acme" }], 0, 10)); + + var result = await controller.Brand(null, EmptyFilter()); + + var rows = ReadRows(result); + Assert.AreEqual(1, rows.Length); + Assert.AreEqual("Acme", rows[0].Name); + _brandServiceMock.Verify(s => s.GetAllBrands(null, "", 0, 10, false), Times.Once); + } + + [TestMethod] + public async Task Brand_OverriddenPickerStoreId_PassesStoreIdToService() + { + var controller = CreateController("store-1"); + _brandServiceMock + .Setup(s => s.GetAllBrands(null, "store-1", 0, 10, false)) + .ReturnsAsync(new PagedList([], 0, 10)); + + await controller.Brand(null, EmptyFilter()); + + _brandServiceMock.Verify(s => s.GetAllBrands(null, "store-1", 0, 10, false), Times.Once); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/SearchControllerAttributeTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/SearchControllerAttributeTests.cs new file mode 100644 index 0000000000..1cef2377b6 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/SearchControllerAttributeTests.cs @@ -0,0 +1,67 @@ +using System.Linq; +using Grand.Web.Admin.Controllers; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.Common.Filters; +using Microsoft.AspNetCore.Mvc; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class SearchControllerAttributeTests +{ + [TestMethod] + public void IsSubclassOfBaseSearchController() + { + Assert.IsTrue(typeof(BaseSearchController).IsAssignableFrom(typeof(SearchController))); + Assert.AreEqual(typeof(BaseSearchController), typeof(SearchController).BaseType); + } + + [TestMethod] + public void HasAuthorizeAdminAttribute() + { + var attr = typeof(SearchController).GetCustomAttributes(typeof(AuthorizeAdminAttribute), inherit: false); + Assert.AreEqual(1, attr.Length); + } + + [TestMethod] + public void HasAreaAdminAttribute() + { + var attr = typeof(SearchController) + .GetCustomAttributes(typeof(AreaAttribute), inherit: false) + .Cast().Single(); + Assert.AreEqual("Admin", attr.RouteValue); + } + + [TestMethod] + public void HasAutoValidateAntiforgeryTokenAttribute() + { + var attr = typeof(SearchController) + .GetCustomAttributes(typeof(AutoValidateAntiforgeryTokenAttribute), inherit: true); + Assert.AreEqual(1, attr.Length); + } + + [TestMethod] + public void HasAuthorizeMenuAttribute() + { + var attr = typeof(SearchController).GetCustomAttributes(typeof(AuthorizeMenuAttribute), inherit: false); + Assert.AreEqual(1, attr.Length); + } + + // Regression guard: Index/CustomerGroup/Stores/Vendor have no Store/Vendor equivalent and must + // stay declared directly on Admin's own concrete controller, not migrate into the shared base + // (which would incorrectly expose them to Store/Vendor's SearchController too). + [TestMethod] + public void DeclaresHostOnlyPickerMethods() + { + var declaredMethodNames = typeof(SearchController) + .GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.DeclaredOnly) + .Select(m => m.Name) + .ToHashSet(); + + Assert.IsTrue(declaredMethodNames.Contains("Index")); + Assert.IsTrue(declaredMethodNames.Contains("CustomerGroup")); + Assert.IsTrue(declaredMethodNames.Contains("Stores")); + Assert.IsTrue(declaredMethodNames.Contains("Vendor")); + } +} diff --git a/src/Tests/Grand.Web.Store.Tests/Controllers/SearchControllerAttributeTests.cs b/src/Tests/Grand.Web.Store.Tests/Controllers/SearchControllerAttributeTests.cs new file mode 100644 index 0000000000..3b18c46ba1 --- /dev/null +++ b/src/Tests/Grand.Web.Store.Tests/Controllers/SearchControllerAttributeTests.cs @@ -0,0 +1,84 @@ +using System.Linq; +using Grand.Domain.Admin; +using Grand.Domain.Customers; +using Grand.Business.Core.Interfaces.Catalog.Brands; +using Grand.Business.Core.Interfaces.Catalog.Categories; +using Grand.Business.Core.Interfaces.Catalog.Collections; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.Common.Filters; +using Grand.Web.Store.Controllers; +using Microsoft.AspNetCore.Mvc; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Store.Tests.Controllers; + +[TestClass] +public class SearchControllerAttributeTests +{ + [TestMethod] + public void IsSubclassOfBaseSearchController() + { + Assert.IsTrue(typeof(BaseSearchController).IsAssignableFrom(typeof(SearchController))); + Assert.AreEqual(typeof(BaseSearchController), typeof(SearchController).BaseType); + } + + [TestMethod] + public void HasAuthorizeStoreAttribute() + { + var attr = typeof(SearchController).GetCustomAttributes(typeof(AuthorizeStoreAttribute), inherit: false); + Assert.AreEqual(1, attr.Length); + } + + [TestMethod] + public void HasAreaStoreAttribute() + { + var attr = typeof(SearchController) + .GetCustomAttributes(typeof(AreaAttribute), inherit: false) + .Cast().Single(); + Assert.AreEqual("Store", attr.RouteValue); + } + + [TestMethod] + public void HasAutoValidateAntiforgeryTokenAttribute() + { + var attr = typeof(SearchController) + .GetCustomAttributes(typeof(AutoValidateAntiforgeryTokenAttribute), inherit: true); + Assert.AreEqual(1, attr.Length); + } + + [TestMethod] + public void HasAuthorizeMenuAttribute() + { + var attr = typeof(SearchController).GetCustomAttributes(typeof(AuthorizeMenuAttribute), inherit: false); + Assert.AreEqual(1, attr.Length); + } + + // Store is the one host whose PickerStoreId override actually matters (Admin/Vendor keep the + // base's "" default) - regression guard for the storeId source itself, not just wiring. + [TestMethod] + public async System.Threading.Tasks.Task PickerStoreId_ReflectsCurrentCustomerStaffStoreId() + { + var categoryServiceMock = new Mock(); + categoryServiceMock + .Setup(s => s.GetAllCategories(null, null, "store-42", 0, It.IsAny(), false)) + .ReturnsAsync(new Grand.Domain.PagedList([], 0, 10)); + + var workContextMock = new Mock(); + workContextMock.Setup(w => w.CurrentCustomer).Returns(new Customer { StaffStoreId = "store-42" }); + var contextAccessorMock = new Mock(); + contextAccessorMock.Setup(c => c.WorkContext).Returns(workContextMock.Object); + + var controller = new SearchController( + categoryServiceMock.Object, + new Mock().Object, + new Mock().Object, + new AdminSearchSettings { CategorySizeLimit = 10 }, + contextAccessorMock.Object); + + await controller.Category(null, new Grand.Web.Common.DataSource.DataSourceRequestFilter { Filters = [] }); + + categoryServiceMock.Verify(s => s.GetAllCategories(null, null, "store-42", 0, 10, false), Times.Once); + } +} diff --git a/src/Tests/Grand.Web.Vendor.Tests/Controllers/SearchControllerSurfaceTests.cs b/src/Tests/Grand.Web.Vendor.Tests/Controllers/SearchControllerSurfaceTests.cs new file mode 100644 index 0000000000..e91b58e169 --- /dev/null +++ b/src/Tests/Grand.Web.Vendor.Tests/Controllers/SearchControllerSurfaceTests.cs @@ -0,0 +1,46 @@ +using Grand.Web.AdminShared.Controllers; +using Grand.Web.Common.Filters; +using Grand.Web.Vendor.Controllers; +using Grand.Web.Vendor.Extensions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Web.Vendor.Tests.Controllers; + +[TestClass] +public class SearchControllerSurfaceTests +{ + [TestMethod] + public void VendorSearchController_IsSubclassOfBaseSearchController() + { + Assert.IsTrue(typeof(BaseSearchController).IsAssignableFrom(typeof(SearchController))); + Assert.AreEqual(typeof(BaseSearchController), typeof(SearchController).BaseType); + } + + // Same defect class Task 17 (Order) and Task-level reviews on later phases caught: BaseSearchController + // can't carry a host's [Area]/[Authorize*] attributes itself (they differ per host), so each concrete + // subclass must restate its own - a missing one here would 404 or deauthorize the whole controller + // silently. Same shape as VendorReviewControllerSurfaceTests/OrderControllerSurfaceTests. + [TestMethod] + public void VendorSearchController_HasAreaAttributeWithVendorArea() + { + var areaAttr = (AreaAttribute)Attribute.GetCustomAttribute(typeof(SearchController), typeof(AreaAttribute), false); + Assert.IsNotNull(areaAttr, "Missing [Area]."); + Assert.AreEqual(Constants.AreaVendor, areaAttr.RouteValue); + } + + [TestMethod] + public void VendorSearchController_HasAuthorizeVendorAttribute() => + Assert.IsTrue(typeof(SearchController).IsDefined(typeof(AuthorizeVendorAttribute), false), + "Missing [AuthorizeVendor]."); + + [TestMethod] + public void VendorSearchController_HasAutoValidateAntiforgeryTokenAttribute() => + Assert.IsTrue(typeof(SearchController).IsDefined(typeof(AutoValidateAntiforgeryTokenAttribute), true), + "Missing [AutoValidateAntiforgeryToken]."); + + [TestMethod] + public void VendorSearchController_HasAuthorizeMenuAttribute() => + Assert.IsTrue(typeof(SearchController).IsDefined(typeof(AuthorizeMenuAttribute), false), + "Missing [AuthorizeMenu]."); +} diff --git a/src/Web/Grand.Web.Admin/Controllers/SearchController.cs b/src/Web/Grand.Web.Admin/Controllers/SearchController.cs index 59ebb38702..11d75afe92 100644 --- a/src/Web/Grand.Web.Admin/Controllers/SearchController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/SearchController.cs @@ -1,4 +1,4 @@ -using Grand.Business.Core.Interfaces.Catalog.Brands; +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.Products; @@ -13,18 +13,30 @@ using Grand.Domain.Customers; using Grand.Infrastructure; using Grand.Web.Admin.Extensions; +using Grand.Web.AdminShared.Controllers; using Grand.Web.AdminShared.Models.Settings; using Grand.Web.Common.DataSource; +using Grand.Web.Common.Filters; using Grand.Web.Common.Helpers; using Microsoft.AspNetCore.Mvc; namespace Grand.Web.Admin.Controllers; -public class SearchController : BaseAdminController +// Reduced to a thin subclass of BaseSearchController (ARCH-001) for the Category/Collection/Brand +// picker methods, which now live in the shared base. Index (full admin command/menu search) and the +// CustomerGroup/Stores/Vendor pickers stay here - they have no Store/Vendor equivalent, same shape as +// TaxController's Providers/Settings regions staying host-specific alongside its consolidated +// TaxCategory sub-resource. Restates the attribute set that used to arrive transitively via +// BaseAdminController - BaseSearchController can't inherit any single host's base controller (it's +// shared across Admin/Store/Vendor, each with a different [Area]/[Authorize*] pair). +[AuthorizeAdmin] +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaAdmin)] +[AuthorizeMenu] +public class SearchController : BaseSearchController { private readonly AdminSearchSettings _adminSearchSettings; private readonly IBlogService _blogService; - private readonly IBrandService _brandService; private readonly ICategoryService _categoryService; private readonly ICollectionService _collectionService; private readonly ICustomerService _customerService; @@ -45,10 +57,10 @@ public SearchController(IProductService productService, ICategoryService categor AdminSearchSettings adminSearchSettings, ITranslationService translationService, IContextAccessor contextAccessor, IGroupService groupService, IStoreService storeService, IVendorService vendorService) + : base(categoryService, brandService, collectionService, adminSearchSettings) { _productService = productService; _categoryService = categoryService; - _brandService = brandService; _collectionService = collectionService; _pageService = pageService; _newsService = newsService; @@ -226,38 +238,6 @@ public async Task Index(string searchTerm, FoundMenuItem[] foundM return Json(result.Take(_adminSearchSettings.MaxSearchResultsCount).Select(x => x.Item1).ToList()); } - - [HttpGet] - public async Task Category(string categoryId, DataSourceRequestFilter model) - { - var categories = await _categoryService.GetAllCategories( - parentId: null, - categoryName: model.GetNameFilterValue(), - storeId: "", - pageIndex: 0, - pageSize: _adminSearchSettings.CategorySizeLimit, - showHidden: false - ); - - var gridModel = await DataSourceResultHelper.GetSearchResult(categoryId, categories, async category => await _categoryService.GetFormattedBreadCrumb(category)); - return Json(gridModel); - } - - [HttpGet] - public async Task Collection(string collectionId, DataSourceRequestFilter model) - { - var collections = await _collectionService.GetAllCollections( - collectionName: model.GetNameFilterValue(), - storeId: "", - pageIndex: 0, - pageSize: _adminSearchSettings.CollectionSizeLimit, - showHidden: false - ); - - var gridModel = await DataSourceResultHelper.GetSearchResult(collectionId, collections, collection => Task.FromResult(collection.Name)); - return Json(gridModel); - } - [HttpGet] public async Task CustomerGroup(string customerGroupId, DataSourceRequestFilter model) { @@ -294,16 +274,4 @@ public async Task Vendor(string vendorId, DataSourceRequestFilter var gridModel = await DataSourceResultHelper.GetSearchResult(vendorId, vendors, vendor => Task.FromResult(vendor.Name)); return Json(gridModel); } - - [HttpGet] - public async Task Brand(string brandId, DataSourceRequestFilter model) - { - var brands = await _brandService.GetAllBrands( - model.GetNameFilterValue(), - storeId: "", - pageSize: _adminSearchSettings.BrandSizeLimit); - - var gridModel = await DataSourceResultHelper.GetSearchResult(brandId, brands, brand => Task.FromResult(brand.Name)); - return Json(gridModel); - } -} \ No newline at end of file +} diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseSearchController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseSearchController.cs new file mode 100644 index 0000000000..6488115b1b --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseSearchController.cs @@ -0,0 +1,79 @@ +using Grand.Business.Core.Interfaces.Catalog.Brands; +using Grand.Business.Core.Interfaces.Catalog.Categories; +using Grand.Business.Core.Interfaces.Catalog.Collections; +using Grand.Domain.Admin; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Helpers; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.AdminShared.Controllers; + +// ARCH-001: only the Category/Collection/Brand Kendo-autocomplete "picker" sub-resource of +// Admin/Store/Vendor's SearchController is consolidated here - same "consolidate a sub-resource, +// leave the rest duplicated" shape as BaseTaxCategoryController. Admin's own Index (full admin +// command/menu search) and CustomerGroup/Stores/Vendor pickers have no Store/Vendor equivalent and +// stay in Grand.Web.Admin.Controllers.SearchController untouched. +// +// Deliberately does NOT take IAdminDataScope//: those entities' routed +// scopes (RoutedCategoryDataScope etc.) fail closed for the "Vendor" area, because Category/ +// Collection/Brand have no Vendor CRUD screen - but this picker sub-resource DOES run under Vendor +// (Vendor's own SearchController already exposes it), so resolving one of those scopes here would +// throw on every Vendor picker call. Instead: Admin's and Vendor's original code both hardcoded +// storeId: "" (no store filter) for these 3 methods; only Store scoped by +// WorkContext.CurrentCustomer.StaffStoreId. That's preserved via the PickerStoreId virtual property +// below (null default = Admin/Vendor's original "" - IsNullOrEmpty("") and IsNullOrEmpty(null) are +// both true in the underlying service filters, confirmed in CategoryService.GetAllCategories), which +// Store's concrete subclass overrides. +public abstract class BaseSearchController( + ICategoryService categoryService, + IBrandService brandService, + ICollectionService collectionService, + AdminSearchSettings adminSearchSettings) + : BaseController +{ + protected virtual string PickerStoreId => ""; + + [HttpGet] + public virtual async Task Category(string categoryId, DataSourceRequestFilter model) + { + var categories = await categoryService.GetAllCategories( + parentId: null, + categoryName: model.GetNameFilterValue(), + storeId: PickerStoreId, + pageIndex: 0, + pageSize: adminSearchSettings.CategorySizeLimit, + showHidden: false + ); + + var gridModel = await DataSourceResultHelper.GetSearchResult(categoryId, categories, async category => await categoryService.GetFormattedBreadCrumb(category)); + return Json(gridModel); + } + + [HttpGet] + public virtual async Task Collection(string collectionId, DataSourceRequestFilter model) + { + var collections = await collectionService.GetAllCollections( + collectionName: model.GetNameFilterValue(), + storeId: PickerStoreId, + pageIndex: 0, + pageSize: adminSearchSettings.CollectionSizeLimit, + showHidden: false + ); + + var gridModel = await DataSourceResultHelper.GetSearchResult(collectionId, collections, collection => Task.FromResult(collection.Name)); + return Json(gridModel); + } + + [HttpGet] + public virtual async Task Brand(string brandId, DataSourceRequestFilter model) + { + var brands = await brandService.GetAllBrands( + model.GetNameFilterValue(), + storeId: PickerStoreId, + pageSize: adminSearchSettings.BrandSizeLimit); + + var gridModel = await DataSourceResultHelper.GetSearchResult(brandId, brands, brand => Task.FromResult(brand.Name)); + return Json(gridModel); + } +} diff --git a/src/Web/Grand.Web.Store/Controllers/SearchController.cs b/src/Web/Grand.Web.Store/Controllers/SearchController.cs index 992e0c5ed1..edf8e0498d 100644 --- a/src/Web/Grand.Web.Store/Controllers/SearchController.cs +++ b/src/Web/Grand.Web.Store/Controllers/SearchController.cs @@ -1,73 +1,31 @@ -using Grand.Business.Core.Interfaces.Catalog.Brands; +using Grand.Business.Core.Interfaces.Catalog.Brands; using Grand.Business.Core.Interfaces.Catalog.Categories; using Grand.Business.Core.Interfaces.Catalog.Collections; using Grand.Domain.Admin; using Grand.Infrastructure; -using Grand.Web.Common.DataSource; -using Grand.Web.Common.Helpers; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.Common.Filters; +using Grand.Web.Store.Extensions; using Microsoft.AspNetCore.Mvc; namespace Grand.Web.Store.Controllers; -public class SearchController : BaseStoreController +// Reduced to a thin subclass of BaseSearchController (ARCH-001) for the Category/Collection/Brand +// picker methods. Store is the only host that scopes these pickers by store - overrides +// PickerStoreId to the current store manager's StaffStoreId, exactly replicating the original +// per-method storeId argument. Restates the attribute set that used to arrive transitively via +// BaseStoreController - see Admin's SearchController for why. +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaStore)] +[AuthorizeStore] +[AuthorizeMenu] +public class SearchController( + ICategoryService categoryService, + IBrandService brandService, + ICollectionService collectionService, + AdminSearchSettings adminSearchSettings, + IContextAccessor contextAccessor) + : BaseSearchController(categoryService, brandService, collectionService, adminSearchSettings) { - private readonly AdminSearchSettings _adminSearchSettings; - private readonly IBrandService _brandService; - private readonly ICategoryService _categoryService; - private readonly ICollectionService _collectionService; - private readonly IContextAccessor _contextAccessor; - - public SearchController(ICategoryService categoryService, - IBrandService brandService, ICollectionService collectionService, - AdminSearchSettings adminSearchSettings, IContextAccessor contextAccessor) - { - _categoryService = categoryService; - _brandService = brandService; - _collectionService = collectionService; - _adminSearchSettings = adminSearchSettings; - _contextAccessor = contextAccessor; - } - - [HttpGet] - public async Task Category(string categoryId, DataSourceRequestFilter model) - { - var categories = await _categoryService.GetAllCategories( - parentId: null, - categoryName: model.GetNameFilterValue(), - storeId: _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId, - pageIndex: 0, - pageSize: _adminSearchSettings.CategorySizeLimit, - showHidden: false - ); - - var gridModel = await DataSourceResultHelper.GetSearchResult(categoryId, categories, async category => await _categoryService.GetFormattedBreadCrumb(category)); - return Json(gridModel); - } - - [HttpGet] - public async Task Collection(string collectionId, DataSourceRequestFilter model) - { - var collections = await _collectionService.GetAllCollections( - collectionName: model.GetNameFilterValue(), - storeId: _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId, - pageIndex: 0, - pageSize: _adminSearchSettings.CollectionSizeLimit, - showHidden: false - ); - - var gridModel = await DataSourceResultHelper.GetSearchResult(collectionId, collections, collection => Task.FromResult(collection.Name)); - return Json(gridModel); - } - - [HttpGet] - public async Task Brand(string brandId, DataSourceRequestFilter model) - { - var brands = await _brandService.GetAllBrands( - model.GetNameFilterValue(), - storeId: _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId, - pageSize: _adminSearchSettings.BrandSizeLimit); - - var gridModel = await DataSourceResultHelper.GetSearchResult(brandId, brands, brand => Task.FromResult(brand.Name)); - return Json(gridModel); - } -} \ No newline at end of file + protected override string PickerStoreId => contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; +} diff --git a/src/Web/Grand.Web.Vendor/Controllers/SearchController.cs b/src/Web/Grand.Web.Vendor/Controllers/SearchController.cs index f79d52362c..34c55b2fa2 100644 --- a/src/Web/Grand.Web.Vendor/Controllers/SearchController.cs +++ b/src/Web/Grand.Web.Vendor/Controllers/SearchController.cs @@ -1,73 +1,28 @@ -using Grand.Business.Core.Interfaces.Catalog.Brands; +using Grand.Business.Core.Interfaces.Catalog.Brands; using Grand.Business.Core.Interfaces.Catalog.Categories; using Grand.Business.Core.Interfaces.Catalog.Collections; using Grand.Domain.Admin; -using Grand.Web.Common.DataSource; -using Grand.Web.Common.Helpers; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.Common.Filters; +using Grand.Web.Vendor.Extensions; using Microsoft.AspNetCore.Mvc; namespace Grand.Web.Vendor.Controllers; -public class SearchController : BaseVendorController +// Reduced to a thin subclass of BaseSearchController (ARCH-001) for the Category/Collection/Brand +// picker methods. Vendor's original code hardcoded storeId: "" for all 3 (no store filter, same as +// Admin) - the base's default PickerStoreId already matches, no override needed. Restates the +// attribute set that used to arrive transitively via BaseVendorController - see Admin's +// SearchController for why. +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaVendor)] +[AuthorizeVendor] +[AuthorizeMenu] +public class SearchController( + ICategoryService categoryService, + IBrandService brandService, + ICollectionService collectionService, + AdminSearchSettings adminSearchSettings) + : BaseSearchController(categoryService, brandService, collectionService, adminSearchSettings) { - private readonly AdminSearchSettings _adminSearchSettings; - private readonly IBrandService _brandService; - private readonly ICategoryService _categoryService; - private readonly ICollectionService _collectionService; - - public SearchController(ICategoryService categoryService, - IBrandService brandService, ICollectionService collectionService, - AdminSearchSettings adminSearchSettings) - { - _categoryService = categoryService; - _brandService = brandService; - _collectionService = collectionService; - _adminSearchSettings = adminSearchSettings; - } - - [HttpGet] - public async Task Category(string categoryId, DataSourceRequestFilter model) - { - var categories = await _categoryService.GetAllCategories( - parentId: null, - categoryName: model.GetNameFilterValue(), - storeId: "", - pageIndex: 0, - pageSize: _adminSearchSettings.CategorySizeLimit, - showHidden: false - ); - - var gridModel = await DataSourceResultHelper.GetSearchResult(categoryId, categories, async category => await _categoryService.GetFormattedBreadCrumb(category)); - return Json(gridModel); - } - - [HttpGet] - public async Task Collection(string collectionId, DataSourceRequestFilter model) - { - var collections = await _collectionService.GetAllCollections( - collectionName: model.GetNameFilterValue(), - storeId: "", - pageIndex: 0, - pageSize: _adminSearchSettings.CollectionSizeLimit, - showHidden: false - ); - - var gridModel = await DataSourceResultHelper.GetSearchResult(collectionId, collections, collection => Task.FromResult(collection.Name)); - return Json(gridModel); - } - - [HttpGet] - public async Task Brand(string brandId, DataSourceRequestFilter model) - { - var brands = await _brandService.GetAllBrands( - brandName: model.GetNameFilterValue(), - storeId: "", - pageIndex: 0, - pageSize: _adminSearchSettings.BrandSizeLimit, - showHidden: false - ); - - var gridModel = await DataSourceResultHelper.GetSearchResult(brandId, brands, brand => Task.FromResult(brand.Name)); - return Json(gridModel); - } -} \ No newline at end of file +}