Skip to content

Commit de5be1a

Browse files
committed
Merge remote-tracking branch 'origin/AC-10659' into spartans_pr_29102025
2 parents 8b1c5a0 + 0974362 commit de5be1a

File tree

2 files changed

+294
-19
lines changed

2 files changed

+294
-19
lines changed

app/code/Magento/Catalog/Block/Adminhtml/Category/Tab/Product.php

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use Magento\Catalog\Model\Product\Attribute\Source\Status;
1818
use Magento\Catalog\Model\Product\Visibility;
1919
use Magento\Catalog\Model\ProductFactory;
20+
use Magento\Catalog\Model\ResourceModel\Product\CollectionFactory as ProductCollectionFactory;
2021
use Magento\Framework\App\ObjectManager;
2122
use Magento\Framework\Registry;
2223

@@ -42,6 +43,11 @@ class Product extends Extended
4243
*/
4344
private $visibility;
4445

46+
/**
47+
* @var ProductCollectionFactory|mixed
48+
*/
49+
private ProductCollectionFactory $productCollectionFactory;
50+
4551
/**
4652
* @param Context $context
4753
* @param Data $backendHelper
@@ -50,6 +56,7 @@ class Product extends Extended
5056
* @param array $data
5157
* @param Visibility|null $visibility
5258
* @param Status|null $status
59+
* @param ProductCollectionFactory|null $productCollectionFactory
5360
*/
5461
public function __construct(
5562
Context $context,
@@ -58,12 +65,16 @@ public function __construct(
5865
Registry $coreRegistry,
5966
array $data = [],
6067
?Visibility $visibility = null,
61-
?Status $status = null
68+
?Status $status = null,
69+
?ProductCollectionFactory $productCollectionFactory = null
6270
) {
6371
$this->_productFactory = $productFactory;
6472
$this->_coreRegistry = $coreRegistry;
6573
$this->visibility = $visibility ?: ObjectManager::getInstance()->get(Visibility::class);
6674
$this->status = $status ?: ObjectManager::getInstance()->get(Status::class);
75+
$this->productCollectionFactory = $productCollectionFactory ?: ObjectManager::getInstance()->get(
76+
ProductCollectionFactory::class
77+
);
6778
parent::__construct($context, $backendHelper, $data);
6879
}
6980

@@ -125,31 +136,30 @@ protected function _prepareCollection()
125136
if ($this->getCategory()->getId()) {
126137
$this->setDefaultFilter(['in_category' => 1]);
127138
}
128-
129-
$collection = $this->_productFactory->create()->getCollection()->addAttributeToSelect(
130-
'name'
131-
)->addAttributeToSelect(
132-
'sku'
133-
)->addAttributeToSelect(
134-
'visibility'
135-
)->addAttributeToSelect(
136-
'status'
137-
)->addAttributeToSelect(
138-
'price'
139-
)->joinField(
139+
$collection = $this->productCollectionFactory->create();
140+
$storeId = (int)$this->getRequest()->getParam('store', 0);
141+
if ($storeId > 0) {
142+
$collection->addStoreFilter($storeId);
143+
}
144+
$collection->addAttributeToSelect(
145+
[
146+
'name',
147+
'sku',
148+
'visibility',
149+
'status',
150+
'price'
151+
],
152+
'left'
153+
);
154+
$collection->joinField(
140155
'position',
141156
'catalog_category_product',
142157
'position',
143158
'product_id=entity_id',
144159
'category_id=' . (int)$this->getRequest()->getParam('id', 0),
145160
'left'
146161
);
147-
$storeId = (int)$this->getRequest()->getParam('store', 0);
148-
$collection->setStoreId($storeId);
149-
if ($storeId > 0) {
150-
$collection->addStoreFilter($storeId);
151-
}
152-
162+
$collection->getSelect()->group('e.entity_id');
153163
$this->setCollection($collection);
154164

155165
if ($this->getCategory()->getProductsReadonly()) {
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
<?php
2+
/**
3+
* Copyright 2025 Adobe
4+
* All Rights Reserved.
5+
*/
6+
declare(strict_types=1);
7+
8+
namespace Magento\Catalog\Test\Unit\Block\Adminhtml\Category\Tab;
9+
10+
use Magento\Backend\Helper\Data as BackendHelper;
11+
use Magento\Backend\Model\Session as BackendSession;
12+
use Magento\Catalog\Block\Adminhtml\Category\Tab\Product as CategoryTabProductBlock;
13+
use Magento\Catalog\Model\Product\Attribute\Source\Status;
14+
use Magento\Catalog\Model\Product\Visibility;
15+
use Magento\Catalog\Model\ResourceModel\Product\Collection as ProductCollection;
16+
use Magento\Catalog\Model\ResourceModel\Product\CollectionFactory as ProductCollectionFactory;
17+
use Magento\Framework\App\Request\Http as HttpRequest;
18+
use Magento\Framework\DB\Select;
19+
use PHPUnit\Framework\TestCase;
20+
use PHPUnit\Framework\MockObject\MockObject;
21+
use Magento\Framework\Math\Random;
22+
23+
/**
24+
* Class for product collection tests
25+
*
26+
* @SuppressWarnings(PHPMD.CouplingBetweenObjects)
27+
*/
28+
class ProductBlockPrepareCollectionTest extends TestCase
29+
{
30+
/**
31+
* @var BackendHelper|MockObject
32+
*/
33+
private $backendHelperMock;
34+
35+
/**
36+
* @var BackendSession|MockObject
37+
*/
38+
private $backendSessionMock;
39+
40+
/**
41+
* @var (HttpRequest&MockObject)|MockObject
42+
*/
43+
private $requestMock;
44+
45+
/**
46+
* @var ProductCollectionFactory|MockObject
47+
*/
48+
private $collectionFactoryMock;
49+
50+
/**
51+
* @var ProductCollection|MockObject
52+
*/
53+
private $collectionMock;
54+
55+
/**
56+
* @var Select|MockObject
57+
*/
58+
private $selectMock;
59+
60+
/**
61+
* @var Visibility|MockObject
62+
*/
63+
private $visibilityMock;
64+
65+
/**
66+
* @var Status|MockObject
67+
*/
68+
private $statusMock;
69+
70+
/**
71+
* @var MockObject
72+
*/
73+
private $mathRandomMock;
74+
75+
protected function setUp(): void
76+
{
77+
$this->backendHelperMock = $this->createMock(BackendHelper::class);
78+
$this->backendSessionMock = $this->createMock(BackendSession::class);
79+
80+
// Use Http request so getPost() exists
81+
$this->requestMock = $this->getMockBuilder(HttpRequest::class)
82+
->disableOriginalConstructor()
83+
->onlyMethods(['getParam', 'getPost', 'has'])
84+
->getMock();
85+
86+
$this->collectionFactoryMock = $this->createMock(ProductCollectionFactory::class);
87+
$this->collectionMock = $this->createMock(ProductCollection::class);
88+
$this->selectMock = $this->createMock(Select::class);
89+
90+
$this->visibilityMock = $this->createMock(Visibility::class);
91+
$this->statusMock = $this->createMock(Status::class);
92+
93+
// mathRandom for getId() inside Grid::getParam()
94+
$this->mathRandomMock = $this->getMockBuilder(Random::class)
95+
->disableOriginalConstructor()
96+
->onlyMethods(['getUniqueHash'])
97+
->getMock();
98+
$this->mathRandomMock->method('getUniqueHash')->willReturn('id_test');
99+
100+
$this->collectionFactoryMock->method('create')->willReturn($this->collectionMock);
101+
102+
// Fluent + no-op DB calls
103+
$this->collectionMock->method('addAttributeToSelect')->willReturnSelf();
104+
$this->collectionMock->method('addStoreFilter')->willReturnSelf();
105+
$this->collectionMock->method('joinField')->willReturnSelf();
106+
$this->collectionMock->method('getSelect')->willReturn($this->selectMock);
107+
$this->collectionMock->method('load')->willReturnSelf();
108+
$this->selectMock->method('group')->willReturnSelf();
109+
110+
$this->requestMock->method('has')->willReturn(false);
111+
}
112+
113+
private function buildBlock(array $methodsToMock, object $categoryStub): CategoryTabProductBlock
114+
{
115+
$block = $this->getMockBuilder(CategoryTabProductBlock::class)
116+
->disableOriginalConstructor()
117+
->onlyMethods($methodsToMock)
118+
->getMock();
119+
120+
// private Product::$productCollectionFactory
121+
$declProduct = new \ReflectionClass(CategoryTabProductBlock::class);
122+
$propPcf = $declProduct->getProperty('productCollectionFactory');
123+
$propPcf->setAccessible(true);
124+
$propPcf->setValue($block, $this->collectionFactoryMock);
125+
126+
// Grid protected deps
127+
$declGrid = new \ReflectionClass(\Magento\Backend\Block\Widget\Grid::class);
128+
129+
$bhProp = $declGrid->getProperty('_backendHelper');
130+
$bhProp->setAccessible(true);
131+
$bhProp->setValue($block, $this->backendHelperMock);
132+
133+
$bsProp = $declGrid->getProperty('_backendSession');
134+
$bsProp->setAccessible(true);
135+
$bsProp->setValue($block, $this->backendSessionMock);
136+
137+
// AbstractBlock::_request
138+
$declAbs = new \ReflectionClass(\Magento\Framework\View\Element\AbstractBlock::class);
139+
$reqProp = $declAbs->getProperty('_request');
140+
$reqProp->setAccessible(true);
141+
$reqProp->setValue($block, $this->requestMock);
142+
143+
// Backend\Block\Template::$mathRandom for getId() calls in Grid::getParam()
144+
$declTpl = new \ReflectionClass(\Magento\Backend\Block\Template::class);
145+
$mrProp = $declTpl->getProperty('mathRandom');
146+
$mrProp->setAccessible(true);
147+
$mrProp->setValue($block, $this->mathRandomMock);
148+
149+
// Avoid Grid column lookups
150+
if (in_array('getColumn', $methodsToMock, true)) {
151+
$block->method('getColumn')->willReturn(null);
152+
}
153+
// Provide category
154+
if (in_array('getCategory', $methodsToMock, true)) {
155+
$block->method('getCategory')->willReturn($categoryStub);
156+
}
157+
158+
return $block;
159+
}
160+
161+
public function testPrepareCollectionWithCategoryIdAndNoStore(): void
162+
{
163+
$categoryStub = new class {
164+
public function getId()
165+
{
166+
return 42;
167+
}
168+
public function getProductsReadonly()
169+
{
170+
return false;
171+
}
172+
};
173+
174+
$this->requestMock->method('getParam')
175+
->willReturnMap([
176+
['store', 0, 0],
177+
['id', 0, 42],
178+
]);
179+
180+
$this->collectionMock->expects($this->once())
181+
->method('addAttributeToSelect')
182+
->with(
183+
$this->callback(fn($attrs) =>
184+
is_array($attrs) && in_array('name', $attrs, true) && in_array('price', $attrs, true)),
185+
'left'
186+
)
187+
->willReturnSelf();
188+
189+
$this->collectionMock->expects($this->once())
190+
->method('joinField')
191+
->with(
192+
'position',
193+
'catalog_category_product',
194+
'position',
195+
'product_id=entity_id',
196+
'category_id=42',
197+
'left'
198+
)
199+
->willReturnSelf();
200+
201+
$block = $this->buildBlock(['getCategory', 'getColumn', 'setDefaultFilter'], $categoryStub);
202+
$block->expects($this->once())->method('setDefaultFilter')->with(['in_category' => 1])->willReturnSelf();
203+
204+
$this->invokePrepareCollection($block);
205+
206+
$this->assertSame($this->collectionMock, $block->getCollection());
207+
}
208+
209+
public function testPrepareCollectionWithStoreAndReadonly(): void
210+
{
211+
$categoryStub = new class {
212+
public function getId()
213+
{
214+
return null;
215+
}
216+
public function getProductsReadonly()
217+
{
218+
return true;
219+
}
220+
public function getProductsPosition()
221+
{
222+
return [5 => 10, 7 => 20];
223+
}
224+
};
225+
226+
$this->requestMock->method('getParam')
227+
->willReturnMap([
228+
['store', 0, 3],
229+
['id', 0, 99],
230+
]);
231+
$this->requestMock->method('getPost')->willReturn(null);
232+
233+
$this->collectionMock->expects($this->once())->method('addStoreFilter')->with(3)->willReturnSelf();
234+
235+
$this->collectionMock->expects($this->once())
236+
->method('joinField')
237+
->with(
238+
'position',
239+
'catalog_category_product',
240+
'position',
241+
'product_id=entity_id',
242+
'category_id=99',
243+
'left'
244+
)
245+
->willReturnSelf();
246+
247+
$this->collectionMock->expects($this->once())
248+
->method('addFieldToFilter')
249+
->with('entity_id', ['in' => [5, 7]])
250+
->willReturnSelf();
251+
252+
$block = $this->buildBlock(['getCategory', 'getColumn'], $categoryStub);
253+
254+
$this->invokePrepareCollection($block);
255+
256+
$this->assertSame($this->collectionMock, $block->getCollection());
257+
}
258+
259+
private function invokePrepareCollection(object $block): void
260+
{
261+
$m = (new \ReflectionClass($block))->getMethod('_prepareCollection');
262+
$m->setAccessible(true);
263+
$m->invoke($block);
264+
}
265+
}

0 commit comments

Comments
 (0)