Skip to content

Commit 2778590

Browse files
committed
Merge remote-tracking branch 'origin/AC-6997' into spartans_pr_10122025
2 parents 7ab19aa + 90491a6 commit 2778590

File tree

2 files changed

+290
-3
lines changed

2 files changed

+290
-3
lines changed

app/code/Magento/SalesRule/Model/Rule/Condition/Product.php

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,12 +170,14 @@ public function validate(\Magento\Framework\Model\AbstractModel $model)
170170
$product = $this->productRepository->getById($model->getProductId());
171171
}
172172

173+
// For composite products (e.g., configurable), the parent item usually holds the price.
174+
$priceContainerItem = $model->getParentItem() ?: $model;
173175
$product->setQuoteItemQty(
174-
$model->getQty()
176+
$priceContainerItem->getQty()
175177
)->setQuoteItemPrice(
176-
$model->getPrice() // possible bug: need to use $model->getBasePrice()
178+
$priceContainerItem->getPrice()
177179
)->setQuoteItemRowTotal(
178-
$model->getBaseRowTotal()
180+
$priceContainerItem->getBaseRowTotal()
179181
);
180182

181183
$attrCode = $this->getAttribute();

app/code/Magento/SalesRule/Test/Unit/Model/Rule/Condition/ProductTest.php

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,4 +439,289 @@ public function testValidateWhenAttributeValueIsMissingInTheProduct(): void
439439
$this->model->setAttribute($attributeCode);
440440
$this->model->validate($item);
441441
}
442+
443+
/**
444+
* Ensure price comes from parent item for configurables.
445+
*/
446+
public function testQuoteItemPriceUsesParentItemPriceWhenPresent(): void
447+
{
448+
$parentUnitPrice = 100.0;
449+
$childUnitPrice = 0.0;
450+
451+
$attr = $this->createPartialMock(Product::class, ['getAttribute']);
452+
$attr->method('getAttribute')->willReturn(
453+
new DataObject(
454+
['frontend_input' => 'text', 'backend_type' => 'varchar']
455+
)
456+
);
457+
458+
$product = $this->getMockBuilder(\Magento\Catalog\Model\Product::class)
459+
->disableOriginalConstructor()
460+
->onlyMethods(['getResource', 'hasData', 'getData'])
461+
->addMethods(['setQuoteItemQty', 'setQuoteItemPrice', 'setQuoteItemRowTotal'])
462+
->getMock();
463+
$product->method('getResource')->willReturn($attr);
464+
$product->method('hasData')->willReturn(true);
465+
$product->method('getData')->with('quote_item_price')->willReturn($parentUnitPrice);
466+
$product->method('setQuoteItemQty')->willReturnSelf();
467+
$product->expects($this->once())
468+
->method('setQuoteItemPrice')
469+
->with($this->equalTo($parentUnitPrice))
470+
->willReturnSelf();
471+
$product->method('setQuoteItemRowTotal')->willReturnSelf();
472+
473+
$parentItem = $this->getMockBuilder(AbstractItem::class)
474+
->disableOriginalConstructor()
475+
->onlyMethods(['getQty', 'getPrice', 'getParentItem', 'getProduct'])
476+
->addMethods(['getBaseRowTotal'])
477+
->getMockForAbstractClass();
478+
$parentItem->method('getQty')->willReturn(1);
479+
$parentItem->method('getPrice')->willReturn($parentUnitPrice);
480+
$parentItem->method('getBaseRowTotal')->willReturn($parentUnitPrice);
481+
$parentItem->method('getParentItem')->willReturn(null);
482+
$parentItem->method('getProduct')->willReturn($product);
483+
484+
$childItem = $this->getMockBuilder(AbstractItem::class)
485+
->disableOriginalConstructor()
486+
->onlyMethods(['getQty', 'getPrice', 'getParentItem', 'getProduct'])
487+
->addMethods(['getBaseRowTotal'])
488+
->getMockForAbstractClass();
489+
$childItem->method('getQty')->willReturn(1);
490+
$childItem->method('getPrice')->willReturn($childUnitPrice);
491+
$childItem->method('getBaseRowTotal')->willReturn($childUnitPrice);
492+
$childItem->method('getParentItem')->willReturn($parentItem);
493+
$childItem->method('getProduct')->willReturn($product);
494+
495+
$this->model->setAttribute('quote_item_price');
496+
$this->model->setData('operator', '<');
497+
$this->model->setValue(50);
498+
499+
$this->assertFalse(
500+
$this->model->validate($childItem),
501+
'Coupon should not apply when parent price is 100 and condition is < 50'
502+
);
503+
}
504+
505+
/**
506+
* Ensure price comes from the item itself when no parent exists.
507+
*/
508+
public function testQuoteItemPriceUsesOwnItemPriceWhenNoParent(): void
509+
{
510+
$unitPrice = 100.0;
511+
512+
$attr = $this->createPartialMock(Product::class, ['getAttribute']);
513+
$attr->method('getAttribute')->willReturn(
514+
new DataObject(['frontend_input' => 'text', 'backend_type' => 'varchar'])
515+
);
516+
517+
$product = $this->getMockBuilder(\Magento\Catalog\Model\Product::class)
518+
->disableOriginalConstructor()
519+
->onlyMethods(['getResource', 'hasData', 'getData'])
520+
->addMethods(['setQuoteItemQty', 'setQuoteItemPrice', 'setQuoteItemRowTotal'])
521+
->getMock();
522+
$product->method('getResource')->willReturn($attr);
523+
$product->method('hasData')->willReturn(true);
524+
$product->method('getData')->with('quote_item_price')->willReturn($unitPrice);
525+
$product->method('setQuoteItemQty')->willReturnSelf();
526+
$product->expects($this->once())
527+
->method('setQuoteItemPrice')
528+
->with($this->equalTo($unitPrice))
529+
->willReturnSelf();
530+
$product->method('setQuoteItemRowTotal')->willReturnSelf();
531+
532+
$item = $this->getMockBuilder(AbstractItem::class)
533+
->disableOriginalConstructor()
534+
->onlyMethods(['getQty', 'getPrice', 'getParentItem', 'getProduct'])
535+
->addMethods(['getBaseRowTotal'])
536+
->getMockForAbstractClass();
537+
$item->method('getQty')->willReturn(1);
538+
$item->method('getPrice')->willReturn($unitPrice);
539+
$item->method('getBaseRowTotal')->willReturn($unitPrice);
540+
$item->method('getParentItem')->willReturn(null);
541+
$item->method('getProduct')->willReturn($product);
542+
543+
$this->model->setAttribute('quote_item_price');
544+
$this->model->setData('operator', '<');
545+
$this->model->setValue(50);
546+
547+
$this->assertFalse(
548+
$this->model->validate($item),
549+
'Coupon should not apply when price is 100 and condition is < 50'
550+
);
551+
}
552+
553+
/**
554+
* Validates setAttribute parsing of scope and related getters.
555+
*/
556+
public function testSetAttributeParsesScopeAndGetters(): void
557+
{
558+
$this->model->setAttribute('parent::quote_item_qty');
559+
$this->assertSame('quote_item_qty', $this->model->getAttribute());
560+
$this->assertSame('parent', $this->model->getAttributeScope());
561+
}
562+
563+
/**
564+
* Ensures getAttributeName resolves label correctly when scope is set.
565+
*/
566+
public function testGetAttributeNameReturnsSpecialLabelWithScope(): void
567+
{
568+
// load options so special attributes are available
569+
$this->model->loadAttributeOptions();
570+
$this->model->setAttribute('parent::quote_item_qty');
571+
$this->assertSame('Quantity in cart', (string)$this->model->getAttributeName());
572+
}
573+
574+
/**
575+
* Ensures attribute_scope is preserved via asArray/loadArray.
576+
*/
577+
public function testAsArrayAndLoadArrayIncludeAttributeScope(): void
578+
{
579+
$this->model->setAttribute('children::category_ids');
580+
$array = $this->model->asArray();
581+
$this->assertArrayHasKey('attribute_scope', $array);
582+
583+
$this->model->loadArray([
584+
'type' => SalesRuleProduct::class,
585+
'attribute_scope' => 'parent'
586+
]);
587+
$this->assertSame('parent', $this->model->getAttributeScope());
588+
}
589+
590+
/**
591+
* Confirms special attributes are available after loadAttributeOptions.
592+
*/
593+
public function testLoadAttributeOptionsAddsSpecialAttributes(): void
594+
{
595+
$this->model->loadAttributeOptions();
596+
$options = $this->model->getAttributeOption();
597+
$this->assertArrayHasKey('quote_item_price', $options);
598+
$this->assertArrayHasKey('parent::quote_item_qty', $options);
599+
$this->assertArrayHasKey('quote_item_row_total', $options);
600+
}
601+
602+
/**
603+
* Ensures missing attribute is set/unset around validation.
604+
*/
605+
public function testValidateSetsAndUnsetsMissingAttributeOnProduct(): void
606+
{
607+
$attrCode = 'nonexistent_attr';
608+
$this->model->setAttribute($attrCode);
609+
$this->model->setData('operator', '==');
610+
$this->model->setValue('x');
611+
612+
$eavAttr = new DataObject(['frontend_input' => 'text', 'backend_type' => 'varchar']);
613+
$resource = $this->createPartialMock(Product::class, ['getAttribute']);
614+
$resource->method('getAttribute')->with($attrCode)->willReturn($eavAttr);
615+
616+
$product = $this->getMockBuilder(\Magento\Catalog\Model\Product::class)
617+
->disableOriginalConstructor()
618+
->onlyMethods(['getResource', 'hasData', 'getData', 'setData', 'unsetData'])
619+
->addMethods(['setQuoteItemQty', 'setQuoteItemPrice', 'setQuoteItemRowTotal'])
620+
->getMock();
621+
$product->method('getResource')->willReturn($resource);
622+
$product->method('hasData')->with($attrCode)->willReturnOnConsecutiveCalls(false, true);
623+
$product->method('getData')->with($attrCode)->willReturn(null);
624+
$product->expects($this->once())->method('setData')->with($attrCode, null)->willReturnSelf();
625+
$product->expects($this->once())->method('unsetData')->with($attrCode)->willReturnSelf();
626+
$product->method('setQuoteItemQty')->willReturnSelf();
627+
$product->method('setQuoteItemPrice')->willReturnSelf();
628+
$product->method('setQuoteItemRowTotal')->willReturnSelf();
629+
630+
$item = $this->getMockBuilder(AbstractItem::class)
631+
->disableOriginalConstructor()
632+
->onlyMethods(['getQty', 'getPrice', 'getParentItem', 'getProduct'])
633+
->addMethods(['getBaseRowTotal', 'getProductId'])
634+
->getMockForAbstractClass();
635+
$item->method('getQty')->willReturn(1);
636+
$item->method('getPrice')->willReturn(10.0);
637+
$item->method('getBaseRowTotal')->willReturn(10.0);
638+
$item->method('getParentItem')->willReturn(null);
639+
$item->method('getProduct')->willReturn($product);
640+
$item->method('getProductId')->willReturn(1);
641+
642+
// We only assert that no exceptions occur and our expectations on product are met.
643+
$this->model->validate($item);
644+
}
645+
646+
/**
647+
* Ensures hidden scope field is appended to attribute element HTML.
648+
*/
649+
public function testGetAttributeElementHtmlAppendsHiddenScopeField(): void
650+
{
651+
// Ensure scope is set to "parent" so it should be passed as hidden field value
652+
$this->model->setAttribute('parent::quote_item_qty');
653+
654+
$elementHidden = $this->getMockBuilder(\Magento\Framework\Data\Form\Element\AbstractElement::class)
655+
->disableOriginalConstructor()
656+
->onlyMethods(['getHtml'])
657+
->getMockForAbstractClass();
658+
$elementHidden->method('getHtml')->willReturn('HIDDEN_HTML');
659+
$elementSelect = $this->getMockBuilder(\Magento\Framework\Data\Form\Element\AbstractElement::class)
660+
->disableOriginalConstructor()
661+
->onlyMethods(['getHtml'])
662+
->getMockForAbstractClass();
663+
$elementSelect->method('getHtml')->willReturn('ATTR_HTML');
664+
665+
$capturedConfig = null;
666+
$form = $this->getMockBuilder(\Magento\Framework\Data\Form::class)
667+
->disableOriginalConstructor()
668+
->onlyMethods(['addField'])
669+
->getMock();
670+
$form->method('addField')
671+
->willReturnCallback(function ($id, $type, $cfg) use (&$capturedConfig, $elementHidden, $elementSelect) {
672+
if (strpos((string)$id, '__attribute_scope') !== false && $type === 'hidden') {
673+
$capturedConfig = $cfg;
674+
return $elementHidden;
675+
}
676+
return $elementSelect;
677+
});
678+
679+
$rule = $this->getMockBuilder(\Magento\SalesRule\Model\Rule::class)
680+
->disableOriginalConstructor()
681+
->onlyMethods(['getForm'])
682+
->getMock();
683+
$rule->method('getForm')->willReturn($form);
684+
$this->model->setRule($rule);
685+
$this->model->setFormName('form-name');
686+
// Inject a layout so getBlockSingleton() calls succeed
687+
$layout = $this->getMockBuilder(\Magento\Framework\View\LayoutInterface::class)
688+
->disableOriginalConstructor()
689+
->getMockForAbstractClass();
690+
$editable = $this->getMockBuilder(\Magento\Rule\Block\Editable::class)
691+
->disableOriginalConstructor()
692+
->getMock();
693+
$layout->method('getBlockSingleton')->willReturn($editable);
694+
$ref = new \ReflectionProperty(\Magento\Rule\Model\Condition\AbstractCondition::class, '_layout');
695+
$ref->setAccessible(true);
696+
$ref->setValue($this->model, $layout);
697+
$html = $this->model->getAttributeElementHtml();
698+
699+
$this->assertStringContainsString('HIDDEN_HTML', $html);
700+
$this->assertIsArray($capturedConfig);
701+
$this->assertArrayHasKey('value', $capturedConfig);
702+
$this->assertSame('parent', $capturedConfig['value']);
703+
$this->assertArrayHasKey('no_span', $capturedConfig);
704+
$this->assertTrue($capturedConfig['no_span']);
705+
$this->assertArrayHasKey('class', $capturedConfig);
706+
$this->assertSame('hidden', $capturedConfig['class']);
707+
}
708+
709+
/**
710+
* Ensures getAttribute strips the scope delimiter.
711+
*/
712+
public function testGetAttributeStripsScopeDelimiter(): void
713+
{
714+
// Simulate legacy/raw storage where attribute includes scope delimiter
715+
$this->model->setData('attribute', 'parent::category_ids');
716+
$this->assertSame('category_ids', $this->model->getAttribute());
717+
}
718+
719+
/**
720+
* Ensures getAttribute returns value unchanged without delimiter.
721+
*/
722+
public function testGetAttributeWithoutDelimiterReturnsAsIs(): void
723+
{
724+
$this->model->setData('attribute', 'sku');
725+
$this->assertSame('sku', $this->model->getAttribute());
726+
}
442727
}

0 commit comments

Comments
 (0)