@@ -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