diff --git a/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ConditionBuilder/EavAttributeCondition.php b/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ConditionBuilder/EavAttributeCondition.php index 3edfb986001bf..399643e83cc90 100644 --- a/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ConditionBuilder/EavAttributeCondition.php +++ b/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ConditionBuilder/EavAttributeCondition.php @@ -53,31 +53,52 @@ public function build(Filter $filter): string { $attribute = $this->getAttributeByCode($filter->getField()); $tableAlias = 'ca_' . $attribute->getAttributeCode(); + $entityIdField = $attribute->getEntityIdField(); + $isNegative = $this->isNegativeConditionType($filter->getConditionType()); $conditionType = $this->mapConditionType($filter->getConditionType()); + if ($isNegative) { + $conditionType = $this->mapToPositiveConditionType($conditionType); + } $conditionValue = $this->mapConditionValue($conditionType, $filter->getValue()); // NOTE: store scope was ignored intentionally to perform search across all stores - if ($conditionType == 'is_null') { + if ($conditionType === 'is_null') { $entityResourceModel = $attribute->getEntity(); $attributeSelect = $this->resourceConnection->getConnection() ->select() ->from( [Collection::MAIN_TABLE_ALIAS => $entityResourceModel->getEntityTable()], - Collection::MAIN_TABLE_ALIAS . '.' . $attribute->getEntityIdField() + Collection::MAIN_TABLE_ALIAS . '.' . $entityIdField )->joinLeft( [$tableAlias => $attribute->getBackendTable()], - $tableAlias . '.' . $attribute->getEntityIdField() . '=' . Collection::MAIN_TABLE_ALIAS . - '.' . $attribute->getEntityIdField() . ' AND ' . $tableAlias . '.' . + $tableAlias . '.' . $entityIdField . '=' . Collection::MAIN_TABLE_ALIAS . + '.' . $entityIdField . ' AND ' . $tableAlias . '.' . $attribute->getIdFieldName() . '=' . $attribute->getAttributeId(), '' - )->where($tableAlias . '.value is null'); + )->where( + $tableAlias . '.value IS NULL OR ' . $tableAlias . '.value = ?', + '' + ); + } elseif ($conditionType === 'notnull') { + $attributeSelect = $this->resourceConnection->getConnection() + ->select() + ->from( + [$tableAlias => $attribute->getBackendTable()], + $tableAlias . '.' . $entityIdField + )->where( + $this->resourceConnection->getConnection()->prepareSqlCondition( + $tableAlias . '.' . $attribute->getIdFieldName(), + ['eq' => $attribute->getAttributeId()] + ) + )->where($tableAlias . '.value IS NOT NULL') + ->where($tableAlias . '.value != ?', ''); } else { $attributeSelect = $this->resourceConnection->getConnection() ->select() ->from( [$tableAlias => $attribute->getBackendTable()], - $tableAlias . '.' . $attribute->getEntityIdField() + $tableAlias . '.' . $entityIdField )->where( $this->resourceConnection->getConnection()->prepareSqlCondition( $tableAlias . '.' . $attribute->getIdFieldName(), @@ -91,12 +112,14 @@ public function build(Filter $filter): string ); } + $outerConditionType = $isNegative ? 'nin' : 'in'; + return $this->resourceConnection ->getConnection() ->prepareSqlCondition( - Collection::MAIN_TABLE_ALIAS . '.' . $attribute->getEntityIdField(), + Collection::MAIN_TABLE_ALIAS . '.' . $entityIdField, [ - 'in' => $attributeSelect + $outerConditionType => $attributeSelect ] ); } @@ -129,6 +152,34 @@ private function mapConditionType(string $conditionType): string return isset($conditionsMap[$conditionType]) ? $conditionsMap[$conditionType] : $conditionType; } + /** + * Whether the filter condition type is a negative comparison. + * + * @param string $conditionType + * @return bool + */ + private function isNegativeConditionType(string $conditionType): bool + { + return in_array($conditionType, ['neq', 'nin', 'nlike'], true); + } + + /** + * Map a (possibly already-mapped) negative condition type to its positive counterpart. + * + * @param string $conditionType + * @return string + */ + private function mapToPositiveConditionType(string $conditionType): string + { + $conditionsMap = [ + 'neq' => 'eq', + 'nin' => 'in', + 'nlike' => 'like', + ]; + + return $conditionsMap[$conditionType] ?? $conditionType; + } + /** * Wraps value with '%' if condition type is 'like' or 'not like' * @@ -140,7 +191,7 @@ private function mapConditionValue(string $conditionType, string $conditionValue { $conditionsMap = ['like', 'nlike']; - if (in_array($conditionType, $conditionsMap)) { + if (in_array($conditionType, $conditionsMap, true)) { $conditionValue = '%' . $conditionValue . '%'; } diff --git a/app/code/Magento/CatalogRule/Model/Rule/Condition/ConditionsToSearchCriteriaMapper.php b/app/code/Magento/CatalogRule/Model/Rule/Condition/ConditionsToSearchCriteriaMapper.php index 29534978f1e2c..7bc5bed7e2494 100644 --- a/app/code/Magento/CatalogRule/Model/Rule/Condition/ConditionsToSearchCriteriaMapper.php +++ b/app/code/Magento/CatalogRule/Model/Rule/Condition/ConditionsToSearchCriteriaMapper.php @@ -224,6 +224,8 @@ private function reverseSqlOperatorInFilter(Filter $filter) 'nlike' => 'like', 'in' => 'nin', 'nin' => 'in', + 'is_null' => 'notnull', + 'notnull' => 'is_null', ]; if (!array_key_exists($filter->getConditionType(), $operatorsMap)) { @@ -299,7 +301,7 @@ private function mapRuleOperatorToSQLCondition(string $ruleOperator): string '!{}' => 'nlike', // does not contains '()' => 'in', // is one of '!()' => 'nin', // is not one of - '<=>' => 'is_null' + '<=>' => 'is_null', ]; if (!array_key_exists($ruleOperator, $operatorsMap)) { diff --git a/app/code/Magento/CatalogRule/Model/Rule/Condition/Product.php b/app/code/Magento/CatalogRule/Model/Rule/Condition/Product.php index 77153a516d272..223415ac1fbba 100644 --- a/app/code/Magento/CatalogRule/Model/Rule/Condition/Product.php +++ b/app/code/Magento/CatalogRule/Model/Rule/Condition/Product.php @@ -31,13 +31,13 @@ public function validate(\Magento\Framework\Model\AbstractModel $model) $this->_setAttributeValue($model); $attrValue = $model->getData($attrCode); - if ($attrValue === null) { - if ($this->getOperator() === '<=>') { - $this->_restoreOldAttrValue($model, $oldAttrValue); - return true; - } + if ($this->isAttributeValueUndefined($attrValue)) { + // Missing/empty values match "is undefined" and negative operators (is not / does not contain / …). + // "Is defined" is expressed as a FALSE combine over "is undefined", not a separate operator. + $operator = $this->getOperator(); + $matchesMissingValue = $operator === '<=>' || $this->isNegativeOperator($operator); $this->_restoreOldAttrValue($model, $oldAttrValue); - return false; + return $matchesMissingValue; } $result = $this->validateAttribute($attrValue); @@ -131,4 +131,15 @@ protected function _prepareMultiselectValue($value, \Magento\Framework\Model\Abs return $value; } + + /** + * Whether operator is a negative comparison that should match missing attribute values. + * + * @param string|null $operator + * @return bool + */ + private function isNegativeOperator(?string $operator): bool + { + return in_array($operator, ['!=', '!{}', '!()'], true); + } } diff --git a/app/code/Magento/CatalogRule/Test/Mftf/ActionGroup/AdminFillCatalogRuleIsUndefinedConditionActionGroup.xml b/app/code/Magento/CatalogRule/Test/Mftf/ActionGroup/AdminFillCatalogRuleIsUndefinedConditionActionGroup.xml new file mode 100644 index 0000000000000..09af9b9be7f1a --- /dev/null +++ b/app/code/Magento/CatalogRule/Test/Mftf/ActionGroup/AdminFillCatalogRuleIsUndefinedConditionActionGroup.xml @@ -0,0 +1,34 @@ + + + + + + + + Add an "is undefined" product attribute condition on Catalog Price Rule Conditions tab. + Use defaultOperatorLabel "contains" for multiselect and "is" for select/boolean attributes. + + + + + + + + + + + + + + + + + + + diff --git a/app/code/Magento/CatalogRule/Test/Mftf/ActionGroup/AdminFillCatalogRuleMultiselectConditionActionGroup.xml b/app/code/Magento/CatalogRule/Test/Mftf/ActionGroup/AdminFillCatalogRuleMultiselectConditionActionGroup.xml new file mode 100644 index 0000000000000..97a22a1984879 --- /dev/null +++ b/app/code/Magento/CatalogRule/Test/Mftf/ActionGroup/AdminFillCatalogRuleMultiselectConditionActionGroup.xml @@ -0,0 +1,40 @@ + + + + + + + + Add a multiselect product attribute condition on Catalog Price Rule Conditions tab. + Default operator label is "contains"; select another operator and option value as needed. + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/code/Magento/CatalogRule/Test/Mftf/Test/AdminApplyCatalogRuleMultiselectDoesNotContainTest.xml b/app/code/Magento/CatalogRule/Test/Mftf/Test/AdminApplyCatalogRuleMultiselectDoesNotContainTest.xml new file mode 100644 index 0000000000000..4980bb74e44f2 --- /dev/null +++ b/app/code/Magento/CatalogRule/Test/Mftf/Test/AdminApplyCatalogRuleMultiselectDoesNotContainTest.xml @@ -0,0 +1,129 @@ + + + + + + + + + + <description value="Products without multiselect value or with a different option must receive the discount; products with the excluded option must not."/> + <severity value="CRITICAL"/> + <group value="CatalogRule"/> + </annotations> + <before> + <createData entity="multipleSelectProductAttribute" stepKey="createMultiselectAttribute"/> + <createData entity="productAttributeOption1" stepKey="createOptionExcluded"> + <requiredEntity createDataKey="createMultiselectAttribute"/> + </createData> + <createData entity="productAttributeOption3" stepKey="createOptionOther"> + <requiredEntity createDataKey="createMultiselectAttribute"/> + </createData> + <createData entity="AddToDefaultSet" stepKey="addAttributeToDefaultSet"> + <requiredEntity createDataKey="createMultiselectAttribute"/> + </createData> + + <createData entity="ApiCategory" stepKey="createCategory"/> + <createData entity="ApiSimpleProduct" stepKey="createProductWithoutValue"> + <field key="price">100.00</field> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="ApiSimpleProduct" stepKey="createProductWithExcludedOption"> + <field key="price">100.00</field> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="ApiSimpleProduct" stepKey="createProductWithOtherOption"> + <field key="price">100.00</field> + <requiredEntity createDataKey="createCategory"/> + </createData> + + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + <actionGroup ref="AdminProductPageOpenByIdActionGroup" stepKey="openProductWithExcludedOption"> + <argument name="productId" value="$createProductWithExcludedOption.id$"/> + </actionGroup> + <selectOption selector="{{AdminProductAttributesSection.attributeDropdownByCode($createMultiselectAttribute.attribute[attribute_code]$)}}" + userInput="$createOptionExcluded.option[store_labels][0][label]$" + stepKey="setExcludedOptionOnProduct"/> + <actionGroup ref="SaveProductFormActionGroup" stepKey="saveProductWithExcludedOption"/> + + <actionGroup ref="AdminProductPageOpenByIdActionGroup" stepKey="openProductWithOtherOption"> + <argument name="productId" value="$createProductWithOtherOption.id$"/> + </actionGroup> + <selectOption selector="{{AdminProductAttributesSection.attributeDropdownByCode($createMultiselectAttribute.attribute[attribute_code]$)}}" + userInput="$createOptionOther.option[store_labels][0][label]$" + stepKey="setOtherOptionOnProduct"/> + <actionGroup ref="SaveProductFormActionGroup" stepKey="saveProductWithOtherOption"/> + + <actionGroup ref="AdminCatalogPriceRuleDeleteAllActionGroup" stepKey="deleteAllCatalogPriceRules"/> + <actionGroup ref="CliIndexerReindexActionGroup" stepKey="reindexBefore"> + <argument name="indices" value=""/> + </actionGroup> + </before> + <after> + <deleteData createDataKey="createProductWithoutValue" stepKey="deleteProductWithoutValue"/> + <deleteData createDataKey="createProductWithExcludedOption" stepKey="deleteProductWithExcludedOption"/> + <deleteData createDataKey="createProductWithOtherOption" stepKey="deleteProductWithOtherOption"/> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + <deleteData createDataKey="createMultiselectAttribute" stepKey="deleteMultiselectAttribute"/> + + <actionGroup ref="AdminOpenCatalogPriceRulePageActionGroup" stepKey="goToPriceRulePage"/> + <actionGroup ref="AdminCatalogPriceRuleDeleteAllActionGroup" stepKey="deleteAllRules"/> + <actionGroup ref="CliIndexerReindexActionGroup" stepKey="reindexAfter"> + <argument name="indices" value=""/> + </actionGroup> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> + </after> + + <actionGroup ref="AdminOpenNewCatalogPriceRuleFormPageActionGroup" stepKey="openNewCatalogPriceRuleForm"/> + <actionGroup ref="AdminCatalogPriceRuleFillMainInfoActionGroup" stepKey="fillMainInfo"> + <argument name="groups" value="'NOT LOGGED IN'"/> + </actionGroup> + <actionGroup ref="AdminFillCatalogRuleMultiselectConditionActionGroup" stepKey="fillDoesNotContainCondition"> + <argument name="condition" value="$createMultiselectAttribute.attribute[frontend_labels][0][label]$"/> + <argument name="conditionOperator" value="does not contain"/> + <argument name="conditionValue" value="$createOptionExcluded.option[store_labels][0][label]$"/> + </actionGroup> + <scrollTo selector="{{AdminNewCatalogPriceRule.actionsTab}}" stepKey="scrollToActions"/> + <click selector="{{AdminNewCatalogPriceRule.actionsTab}}" stepKey="openActions"/> + <waitForElementVisible selector="{{AdminNewCatalogPriceRuleActions.discountAmount}}" stepKey="waitForActionsFields"/> + <selectOption selector="{{AdminNewCatalogPriceRuleActions.apply}}" userInput="{{_defaultCatalogRule.simple_action}}" stepKey="discountType"/> + <fillField selector="{{AdminNewCatalogPriceRuleActions.discountAmount}}" userInput="50" stepKey="fillDiscountValue"/> + <selectOption selector="{{AdminNewCatalogPriceRuleActions.disregardRules}}" userInput="Yes" stepKey="discardSubsequentRules"/> + <scrollToTopOfPage stepKey="scrollToTop"/> + <waitForPageLoad stepKey="waitForActions"/> + <actionGroup ref="AdminCatalogPriceRuleSaveAndApplyActionGroup" stepKey="saveAndApplyRule"/> + <actionGroup ref="CliIndexerReindexActionGroup" stepKey="reindexRules"> + <argument name="indices" value="catalogrule_rule catalogrule_product catalog_product_price"/> + </actionGroup> + + <actionGroup ref="OpenStoreFrontProductPageActionGroup" stepKey="openProductWithoutValuePage"> + <argument name="productUrlKey" value="$createProductWithoutValue.custom_attributes[url_key]$"/> + </actionGroup> + <actionGroup ref="AssertStorefrontProductPricesActionGroup" stepKey="assertProductWithoutValueDiscounted"> + <argument name="productPrice" value="$100.00"/> + <argument name="productFinalPrice" value="$50.00"/> + </actionGroup> + + <actionGroup ref="OpenStoreFrontProductPageActionGroup" stepKey="openProductWithOtherOptionPage"> + <argument name="productUrlKey" value="$createProductWithOtherOption.custom_attributes[url_key]$"/> + </actionGroup> + <actionGroup ref="AssertStorefrontProductPricesActionGroup" stepKey="assertProductWithOtherOptionDiscounted"> + <argument name="productPrice" value="$100.00"/> + <argument name="productFinalPrice" value="$50.00"/> + </actionGroup> + + <actionGroup ref="OpenStoreFrontProductPageActionGroup" stepKey="openProductWithExcludedOptionPage"> + <argument name="productUrlKey" value="$createProductWithExcludedOption.custom_attributes[url_key]$"/> + </actionGroup> + <actionGroup ref="AssertStorefrontProductPricesActionGroup" stepKey="assertProductWithExcludedOptionFullPrice"> + <argument name="productPrice" value="$100.00"/> + <argument name="productFinalPrice" value="$100.00"/> + </actionGroup> + </test> +</tests> diff --git a/app/code/Magento/CatalogRule/Test/Mftf/Test/AdminApplyCatalogRuleMultiselectIsUndefinedTest.xml b/app/code/Magento/CatalogRule/Test/Mftf/Test/AdminApplyCatalogRuleMultiselectIsUndefinedTest.xml new file mode 100644 index 0000000000000..3c4595e187911 --- /dev/null +++ b/app/code/Magento/CatalogRule/Test/Mftf/Test/AdminApplyCatalogRuleMultiselectIsUndefinedTest.xml @@ -0,0 +1,104 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- +/** + * Copyright 2026 Adobe + * All Rights Reserved. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminApplyCatalogRuleMultiselectIsUndefinedTest"> + <annotations> + <features value="CatalogRule"/> + <stories value="Apply catalog price rule with multiselect is undefined condition"/> + <title value="Catalog rule multiselect is undefined discounts only products without attribute value"/> + <description value="Products without multiselect value must receive the discount; products with any selected option must not."/> + <severity value="CRITICAL"/> + <group value="CatalogRule"/> + </annotations> + <before> + <createData entity="multipleSelectProductAttribute" stepKey="createMultiselectAttribute"/> + <createData entity="productAttributeOption1" stepKey="createOption"> + <requiredEntity createDataKey="createMultiselectAttribute"/> + </createData> + <createData entity="AddToDefaultSet" stepKey="addAttributeToDefaultSet"> + <requiredEntity createDataKey="createMultiselectAttribute"/> + </createData> + + <createData entity="ApiCategory" stepKey="createCategory"/> + <createData entity="ApiSimpleProduct" stepKey="createProductWithoutValue"> + <field key="price">100.00</field> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="ApiSimpleProduct" stepKey="createProductWithValue"> + <field key="price">100.00</field> + <requiredEntity createDataKey="createCategory"/> + </createData> + + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + <actionGroup ref="AdminProductPageOpenByIdActionGroup" stepKey="openProductWithValue"> + <argument name="productId" value="$createProductWithValue.id$"/> + </actionGroup> + <selectOption selector="{{AdminProductAttributesSection.attributeDropdownByCode($createMultiselectAttribute.attribute[attribute_code]$)}}" + userInput="$createOption.option[store_labels][0][label]$" + stepKey="setOptionOnProduct"/> + <actionGroup ref="SaveProductFormActionGroup" stepKey="saveProductWithValue"/> + + <actionGroup ref="AdminCatalogPriceRuleDeleteAllActionGroup" stepKey="deleteAllCatalogPriceRules"/> + <actionGroup ref="CliIndexerReindexActionGroup" stepKey="reindexBefore"> + <argument name="indices" value=""/> + </actionGroup> + </before> + <after> + <deleteData createDataKey="createProductWithoutValue" stepKey="deleteProductWithoutValue"/> + <deleteData createDataKey="createProductWithValue" stepKey="deleteProductWithValue"/> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + <deleteData createDataKey="createMultiselectAttribute" stepKey="deleteMultiselectAttribute"/> + + <actionGroup ref="AdminOpenCatalogPriceRulePageActionGroup" stepKey="goToPriceRulePage"/> + <actionGroup ref="AdminCatalogPriceRuleDeleteAllActionGroup" stepKey="deleteAllRules"/> + <actionGroup ref="CliIndexerReindexActionGroup" stepKey="reindexAfter"> + <argument name="indices" value=""/> + </actionGroup> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> + </after> + + <actionGroup ref="AdminOpenNewCatalogPriceRuleFormPageActionGroup" stepKey="openNewCatalogPriceRuleForm"/> + <actionGroup ref="AdminCatalogPriceRuleFillMainInfoActionGroup" stepKey="fillMainInfo"> + <argument name="groups" value="'NOT LOGGED IN'"/> + </actionGroup> + <actionGroup ref="AdminFillCatalogRuleIsUndefinedConditionActionGroup" stepKey="fillIsUndefinedCondition"> + <argument name="condition" value="$createMultiselectAttribute.attribute[frontend_labels][0][label]$"/> + <argument name="defaultOperatorLabel" value="contains"/> + </actionGroup> + <scrollTo selector="{{AdminNewCatalogPriceRule.actionsTab}}" stepKey="scrollToActions"/> + <click selector="{{AdminNewCatalogPriceRule.actionsTab}}" stepKey="openActions"/> + <waitForElementVisible selector="{{AdminNewCatalogPriceRuleActions.discountAmount}}" stepKey="waitForActionsFields"/> + <selectOption selector="{{AdminNewCatalogPriceRuleActions.apply}}" userInput="{{_defaultCatalogRule.simple_action}}" stepKey="discountType"/> + <fillField selector="{{AdminNewCatalogPriceRuleActions.discountAmount}}" userInput="50" stepKey="fillDiscountValue"/> + <selectOption selector="{{AdminNewCatalogPriceRuleActions.disregardRules}}" userInput="Yes" stepKey="discardSubsequentRules"/> + <scrollToTopOfPage stepKey="scrollToTop"/> + <waitForPageLoad stepKey="waitForActions"/> + <actionGroup ref="AdminCatalogPriceRuleSaveAndApplyActionGroup" stepKey="saveAndApplyRule"/> + <actionGroup ref="CliIndexerReindexActionGroup" stepKey="reindexRules"> + <argument name="indices" value="catalogrule_rule catalogrule_product catalog_product_price"/> + </actionGroup> + + <actionGroup ref="OpenStoreFrontProductPageActionGroup" stepKey="openProductWithoutValuePage"> + <argument name="productUrlKey" value="$createProductWithoutValue.custom_attributes[url_key]$"/> + </actionGroup> + <actionGroup ref="AssertStorefrontProductPricesActionGroup" stepKey="assertProductWithoutValueDiscounted"> + <argument name="productPrice" value="$100.00"/> + <argument name="productFinalPrice" value="$50.00"/> + </actionGroup> + + <actionGroup ref="OpenStoreFrontProductPageActionGroup" stepKey="openProductWithValuePage"> + <argument name="productUrlKey" value="$createProductWithValue.custom_attributes[url_key]$"/> + </actionGroup> + <actionGroup ref="AssertStorefrontProductPricesActionGroup" stepKey="assertProductWithValueFullPrice"> + <argument name="productPrice" value="$100.00"/> + <argument name="productFinalPrice" value="$100.00"/> + </actionGroup> + </test> +</tests> diff --git a/app/code/Magento/CatalogRule/Test/Mftf/Test/AdminApplyCatalogRuleNegativeCategoryConditionTest.xml b/app/code/Magento/CatalogRule/Test/Mftf/Test/AdminApplyCatalogRuleNegativeCategoryConditionTest.xml new file mode 100644 index 0000000000000..6481806121a18 --- /dev/null +++ b/app/code/Magento/CatalogRule/Test/Mftf/Test/AdminApplyCatalogRuleNegativeCategoryConditionTest.xml @@ -0,0 +1,113 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- +/** + * Copyright 2026 Adobe + * All Rights Reserved. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminApplyCatalogRuleNegativeCategoryConditionTest"> + <annotations> + <features value="CatalogRule"/> + <stories value="Apply catalog price rule with negative condition"/> + <title value="Catalog rule Category is not must discount products outside the excluded category"/> + <description value="Products not assigned to the excluded category must receive the catalog rule discount."/> + <severity value="CRITICAL"/> + <testCaseId value="AC-12547"/> + <group value="CatalogRule"/> + </annotations> + <before> + <createData entity="ApiCategory" stepKey="createExcludedCategory"/> + <createData entity="ApiSimpleProduct" stepKey="createProductInExcludedCategory"> + <requiredEntity createDataKey="createExcludedCategory"/> + </createData> + <createData entity="ApiCategory" stepKey="createOtherCategory"/> + <createData entity="ApiSimpleProduct" stepKey="createProductInOtherCategory"> + <requiredEntity createDataKey="createOtherCategory"/> + </createData> + <actionGroup ref="CliIndexerReindexActionGroup" stepKey="reindexBefore"> + <argument name="indices" value=""/> + </actionGroup> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + </before> + <after> + <deleteData createDataKey="createExcludedCategory" stepKey="deleteExcludedCategory"/> + <deleteData createDataKey="createProductInExcludedCategory" stepKey="deleteProductInExcludedCategory"/> + <deleteData createDataKey="createOtherCategory" stepKey="deleteOtherCategory"/> + <deleteData createDataKey="createProductInOtherCategory" stepKey="deleteProductInOtherCategory"/> + + <!-- Best-effort cleanup; rule may not exist if the test failed mid-create --> + <actionGroup ref="AdminOpenCatalogPriceRulePageActionGroup" stepKey="goToPriceRulePage"/> + <actionGroup ref="AdminCatalogPriceRuleDeleteAllActionGroup" stepKey="deleteAllRules"/> + <actionGroup ref="CliIndexerReindexActionGroup" stepKey="reindexAfter"> + <argument name="indices" value=""/> + </actionGroup> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> + </after> + + <!-- Create catalog price rule with Category is not excludedCategory --> + <actionGroup ref="AdminOpenCatalogPriceRulePageActionGroup" stepKey="openCatalogRulePage"/> + <click selector="{{AdminGridMainControls.add}}" stepKey="addNewRule"/> + <waitForPageLoad stepKey="waitForRulePage"/> + <fillField selector="{{AdminNewCatalogPriceRule.ruleName}}" userInput="{{_defaultCatalogRule.name}}" stepKey="fillName"/> + <fillField selector="{{AdminNewCatalogPriceRule.description}}" userInput="{{_defaultCatalogRule.description}}" stepKey="fillDescription"/> + <click stepKey="selectActive" selector="{{AdminCategoryBasicFieldSection.enableCategoryLabel}}"/> + <selectOption selector="{{AdminNewCatalogPriceRule.websites}}" userInput="{{_defaultCatalogRule.website_ids[0]}}" stepKey="selectSite"/> + <click selector="{{AdminNewCatalogPriceRule.fromDateButton}}" stepKey="clickFromCalender"/> + <click selector="{{AdminNewCatalogPriceRule.todayDate}}" stepKey="clickFromToday"/> + <click selector="{{AdminNewCatalogPriceRule.toDateButton}}" stepKey="clickToCalender"/> + <click selector="{{AdminNewCatalogPriceRule.todayDate}}" stepKey="clickToToday"/> + + <click selector="{{AdminNewCatalogPriceRule.conditionsTab}}" stepKey="openConditions"/> + <waitForPageLoad stepKey="waitForConditionsTab"/> + <waitForElementVisible selector="{{AdminNewCatalogPriceRuleConditions.newCondition}}" stepKey="waitForAddConditionButton"/> + <click selector="{{AdminNewCatalogPriceRuleConditions.newCondition}}" stepKey="clickNewRule"/> + <waitForElementVisible selector="{{AdminNewCatalogPriceRuleConditions.conditionSelect('1')}}" stepKey="waitForConditionSelect"/> + <selectOption selector="{{AdminNewCatalogPriceRuleConditions.conditionSelect('1')}}" userInput="Category" stepKey="selectCategory"/> + <waitForPageLoad stepKey="waitForOperator"/> + + <!-- Change operator from default "is" to "is not" --> + <waitForElementVisible selector="{{AdminNewCatalogPriceRuleConditions.condition('is')}}" stepKey="waitForIsOperator"/> + <click selector="{{AdminNewCatalogPriceRuleConditions.condition('is')}}" stepKey="clickOperatorIs"/> + <waitForElementVisible selector="{{AdminNewCatalogPriceRuleConditions.activeOperatorSelect}}" stepKey="waitForOperatorSelect"/> + <selectOption selector="{{AdminNewCatalogPriceRuleConditions.activeOperatorSelect}}" userInput="is not" stepKey="selectIsNotOperator"/> + <conditionalClick selector="{{AdminNewCatalogPriceRuleConditions.condition('...')}}" dependentSelector="{{AdminNewCatalogPriceRuleConditions.activeOperatorSelect}}" visible="true" stepKey="closeOperatorSelect"/> + + <waitForElementVisible selector="{{AdminNewCatalogPriceRuleConditions.targetEllipsis('1')}}" stepKey="waitForEllipsis"/> + <click selector="{{AdminNewCatalogPriceRuleConditions.targetEllipsis('1')}}" stepKey="clickEllipsis"/> + <waitForElementVisible selector="{{AdminNewCatalogPriceRuleConditions.targetInput('1', '1')}}" stepKey="waitForInput"/> + <fillField selector="{{AdminNewCatalogPriceRuleConditions.targetInput('1', '1')}}" userInput="$$createExcludedCategory.id$$" stepKey="fillExcludedCategoryId"/> + <click selector="{{AdminNewCatalogPriceRuleConditions.applyButton('1', '1')}}" stepKey="clickApplyCondition"/> + <waitForPageLoad stepKey="waitAfterConditionApply"/> + + <click selector="{{AdminNewCatalogPriceRule.actionsTabTitle}}" stepKey="openActions"/> + <waitForElementVisible selector="{{AdminNewCatalogPriceRuleActions.discountAmount}}" stepKey="waitForActionsFields"/> + <selectOption selector="{{AdminNewCatalogPriceRuleActions.apply}}" userInput="{{_defaultCatalogRule.simple_action}}" stepKey="discountType"/> + <fillField selector="{{AdminNewCatalogPriceRuleActions.discountAmount}}" userInput="50" stepKey="fillDiscountValue"/> + <selectOption selector="{{AdminNewCatalogPriceRuleActions.disregardRules}}" userInput="Yes" stepKey="discardSubsequentRules"/> + <scrollToTopOfPage stepKey="scrollToTop"/> + <waitForPageLoad stepKey="waitForActions"/> + <actionGroup ref="SelectNotLoggedInCustomerGroupActionGroup" stepKey="selectNotLoggedInCustomerGroup"/> + + <click selector="{{AdminNewCatalogPriceRule.saveAndApply}}" stepKey="saveAndApply"/> + <waitForPageLoad stepKey="waitAfterSaveAndApply"/> + <actionGroup ref="CliIndexerReindexActionGroup" stepKey="reindexRules"> + <argument name="indices" value="catalogrule_rule catalogrule_product catalog_product_price"/> + </actionGroup> + <comment userInput="Cache flush replaced with comment for BC with core CatalogRule MFTF patterns" stepKey="flushCache"/> + + <!-- Product outside excluded category must be discounted --> + <amOnPage url="$$createOtherCategory.custom_attributes[url_key]$$.html" stepKey="openOtherCategory"/> + <see selector="{{StorefrontCategoryProductSection.ProductInfoByNumber('1')}}" userInput="$$createProductInOtherCategory.name$$" stepKey="seeOtherProduct"/> + <see selector="{{StorefrontCategoryProductSection.ProductInfoByNumber('1')}}" userInput="$61.50" stepKey="seeOtherProductDiscountedPrice"/> + <see selector="{{StorefrontCategoryProductSection.ProductInfoByNumber('1')}}" userInput="Regular Price $123.00" stepKey="seeOtherProductRegularPrice"/> + + <!-- Product in excluded category must not be discounted --> + <amOnPage url="$$createExcludedCategory.custom_attributes[url_key]$$.html" stepKey="openExcludedCategory"/> + <see selector="{{StorefrontCategoryProductSection.ProductInfoByNumber('1')}}" userInput="$$createProductInExcludedCategory.name$$" stepKey="seeExcludedProduct"/> + <see selector="{{StorefrontCategoryProductSection.ProductInfoByNumber('1')}}" userInput="$123.00" stepKey="seeExcludedProductFullPrice"/> + <dontSee selector="{{StorefrontCategoryProductSection.ProductInfoByNumber('1')}}" userInput="$61.50" stepKey="dontSeeExcludedProductDiscount"/> + </test> +</tests> diff --git a/app/code/Magento/CatalogRule/Test/Unit/Model/Rule/Condition/ConditionsToSearchCriteriaMapperTest.php b/app/code/Magento/CatalogRule/Test/Unit/Model/Rule/Condition/ConditionsToSearchCriteriaMapperTest.php index 5f6f4a83c7c30..dd8f7d09c44e1 100644 --- a/app/code/Magento/CatalogRule/Test/Unit/Model/Rule/Condition/ConditionsToSearchCriteriaMapperTest.php +++ b/app/code/Magento/CatalogRule/Test/Unit/Model/Rule/Condition/ConditionsToSearchCriteriaMapperTest.php @@ -782,6 +782,8 @@ public function testAllSqlOperatorReversals() 'nlike' => 'like', 'in' => 'nin', 'nin' => 'in', + 'is_null' => 'notnull', + 'notnull' => 'is_null', ]; foreach ($reversalMappings as $original => $reversed) { diff --git a/app/code/Magento/CatalogRule/Test/Unit/Model/Rule/Condition/ProductTest.php b/app/code/Magento/CatalogRule/Test/Unit/Model/Rule/Condition/ProductTest.php index 585dd328d173d..ba8150d725fa0 100644 --- a/app/code/Magento/CatalogRule/Test/Unit/Model/Rule/Condition/ProductTest.php +++ b/app/code/Magento/CatalogRule/Test/Unit/Model/Rule/Condition/ProductTest.php @@ -199,13 +199,18 @@ public function testValidateWithDatetimeValue($attributeValue, $parsedValue, $ne } /** + * Missing attribute values must match negative operators and "is undefined". + * + * @param string $operator + * @param bool $expected * @return void */ - public function testValidateWithNoValue(): void + #[DataProvider('validateWithNoValueDataProvider')] + public function testValidateWithNoValue(string $operator, bool $expected): void { $this->product->setData('attribute', 'color'); $this->product->setData('value_parsed', '1'); - $this->product->setData('operator', '!='); + $this->product->setData('operator', $operator); $this->productModel->expects($this->atLeastOnce()) ->method('getData') @@ -217,7 +222,25 @@ public function testValidateWithNoValue(): void $this->productModel->expects($this->any()) ->method('getStoreId') ->willReturn('1'); - $this->assertFalse($this->product->validate($this->productModel)); + $this->assertSame($expected, $this->product->validate($this->productModel)); + } + + /** + * @return array<string, array{string, bool}> + */ + public static function validateWithNoValueDataProvider(): array + { + return [ + 'is not' => ['!=', true], + 'does not contain' => ['!{}', true], + 'is not one of' => ['!()', true], + 'is undefined' => ['<=>', true], + 'is' => ['==', false], + 'contains' => ['{}', false], + 'is one of' => ['()', false], + 'greater than' => ['>', false], + 'less than' => ['<', false], + ]; } /** diff --git a/app/code/Magento/Rule/Model/Condition/AbstractCondition.php b/app/code/Magento/Rule/Model/Condition/AbstractCondition.php index e7645dad5b4b9..8a5375e6997fe 100644 --- a/app/code/Magento/Rule/Model/Condition/AbstractCondition.php +++ b/app/code/Magento/Rule/Model/Condition/AbstractCondition.php @@ -108,7 +108,8 @@ public function getDefaultOperatorInputByType() 'date' => ['==', '>=', '<='], 'select' => ['==', '!=', '<=>'], 'boolean' => ['==', '!=', '<=>'], - 'multiselect' => ['{}', '!{}', '()', '!()'], + // Multiselect gains "is undefined" so empty values can be targeted (or inverted via a FALSE combine). + 'multiselect' => ['{}', '!{}', '()', '!()', '<=>'], 'grid' => ['()', '!()'], ]; $this->_arrayInputTypes = ['multiselect', 'grid']; @@ -786,6 +787,11 @@ public function validateAttribute($validatedValue) */ $option = $this->getOperatorForValidate(); + // "is undefined" does not use a condition value and must not depend on array-operator typing. + if ($option === '<=>') { + return $this->isAttributeValueUndefined($validatedValue); + } + // if operator requires array and it is not, or on opposite, return false if ($this->isArrayOperatorType() xor is_array($value)) { return false; @@ -837,8 +843,10 @@ public function validateAttribute($validatedValue) } } } elseif (is_array($value)) { + // Empty product values do not "contain" the needle. For !{} the final if (!is_array($validatedValue) || empty($validatedValue)) { - return false; + $result = false; + break; } $result = array_intersect($value, $validatedValue); $result = !empty($result); @@ -879,6 +887,24 @@ public function validateAttribute($validatedValue) return $result; } + /** + * Whether a product attribute value is considered undefined/empty for rule matching. + * + * @param mixed $validatedValue + * @return bool + */ + protected function isAttributeValueUndefined($validatedValue): bool + { + if ($validatedValue === null || $validatedValue === '') { + return true; + } + if (is_array($validatedValue) && $validatedValue === []) { + return true; + } + + return false; + } + /** * Case and type insensitive comparison of values * diff --git a/app/code/Magento/Rule/Model/Condition/Sql/Builder.php b/app/code/Magento/Rule/Model/Condition/Sql/Builder.php index a45d955dae98e..4935b38fd0c16 100644 --- a/app/code/Magento/Rule/Model/Condition/Sql/Builder.php +++ b/app/code/Magento/Rule/Model/Condition/Sql/Builder.php @@ -30,7 +30,7 @@ class Builder */ protected $_conditionOperatorMap = [ '==' => ':field = ?', - '!=' => ':field <> ?', + '!=' => '(:field IS NULL OR :field = \'\' OR :field <> ?)', '>=' => ':field >= ?', '>=' => ':field >= ?', '>' => ':field > ?', @@ -40,9 +40,10 @@ class Builder '<' => ':field < ?', '<' => ':field < ?', '{}' => ':field IN (?)', - '!{}' => ':field NOT IN (?)', + '!{}' => '(:field IS NULL OR :field = \'\' OR :field NOT IN (?))', '()' => ':field IN (?)', - '!()' => ':field NOT IN (?)', + '!()' => '(:field IS NULL OR :field = \'\' OR :field NOT IN (?))', + '<=>' => '(:field IS NULL OR :field = \'\')', ]; /** @@ -50,7 +51,7 @@ class Builder */ private $stringConditionOperatorMap = [ '{}' => ':field LIKE ?', - '!{}' => ':field NOT LIKE ?', + '!{}' => '(:field IS NULL OR :field = \'\' OR :field NOT LIKE ?)', ]; /** @@ -168,6 +169,7 @@ protected function _getMappedSqlCondition( //operator 'contains {}' is mapped to 'IN()' query that cannot work with substrings // adding mapping to 'LIKE %%' + $bindValue = $condition->getBindArgumentValue(); if ($condition->getInputType() === 'string' && in_array($conditionOperator, array_keys($this->stringConditionOperatorMap), true) ) { @@ -176,15 +178,20 @@ protected function _getMappedSqlCondition( (string)$this->_connection->quoteIdentifier($argument), $this->stringConditionOperatorMap[$conditionOperator] ); - $bindValue = $condition->getBindArgumentValue(); $expression = $value . $this->_connection->quoteInto($sql, "%$bindValue%"); + } elseif ($conditionOperator === '<=>') { + $sql = str_replace( + ':field', + (string)$this->_connection->quoteIdentifier($argument), + $this->_conditionOperatorMap[$conditionOperator] + ); + $expression = $value . $sql; } else { $sql = str_replace( ':field', (string)$this->_connection->quoteIdentifier($argument), $this->_conditionOperatorMap[$conditionOperator] ); - $bindValue = $condition->getBindArgumentValue(); $expression = $value . $this->_connection->quoteInto($sql, $bindValue); } // values for multiselect attributes can be saved in comma-separated format diff --git a/app/code/Magento/Rule/Test/Unit/Model/Condition/AbstractConditionTest.php b/app/code/Magento/Rule/Test/Unit/Model/Condition/AbstractConditionTest.php index 213629af73b5c..5fe1749eed6a5 100644 --- a/app/code/Magento/Rule/Test/Unit/Model/Condition/AbstractConditionTest.php +++ b/app/code/Magento/Rule/Test/Unit/Model/Condition/AbstractConditionTest.php @@ -99,6 +99,8 @@ public static function validateAttributeDataProvider() [1, '>=', 0, false], [0, '<', [1], false], + // Without grid/multiselect input type, !{} + array needle hits isArrayOperatorType mismatch. + // Empty product values with grid input type are covered in validateAttributeArrayInputTypeDataProvider. [[1], '!{}', [], false], [[1], '!{}', [1], false], [[1], '!{}', [0], false], @@ -186,10 +188,17 @@ public static function validateAttributeArrayInputTypeDataProvider() [1, '{}', 1, false, 'grid'], [1, '!{}', [1, 2, 3], false, 'grid'], [1, '!{}', [], false, 'grid'], - [[1], '!{}', [], false, 'grid'], + [[1], '!{}', [], true, 'grid'], [[1], '{}', null, false, 'grid'], [null, '{}', null, true, 'input'], [null, '!{}', null, false, 'input'], + + // is undefined (condition needle is ignored) + [null, '<=>', null, true, 'select'], + [null, '<=>', '', true, 'select'], + [null, '<=>', 1, false, 'select'], + [null, '<=>', [], true, 'multiselect'], + [null, '<=>', [1], false, 'multiselect'], [null, '{}', [1], false, 'input'], [[1, 2, 3], '()', 1, true, 'select'], diff --git a/dev/tests/integration/testsuite/Magento/CatalogRule/Model/Indexer/NegativeConditionRulePriceTest.php b/dev/tests/integration/testsuite/Magento/CatalogRule/Model/Indexer/NegativeConditionRulePriceTest.php new file mode 100644 index 0000000000000..19253c6d603fc --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/CatalogRule/Model/Indexer/NegativeConditionRulePriceTest.php @@ -0,0 +1,407 @@ +<?php +/** + * Copyright 2026 Adobe + * All Rights Reserved. + */ +declare(strict_types=1); + +namespace Magento\CatalogRule\Model\Indexer; + +use Magento\Catalog\Model\ResourceModel\Eav\Attribute; +use Magento\Catalog\Setup\CategorySetup; +use Magento\Catalog\Test\Fixture\Category as CategoryFixture; +use Magento\Catalog\Test\Fixture\MultiselectAttribute as MultiselectAttributeFixture; +use Magento\Catalog\Test\Fixture\Product as ProductFixture; +use Magento\Catalog\Test\Fixture\SelectAttribute as SelectAttributeFixture; +use Magento\CatalogRule\Model\Indexer\IndexBuilder; +use Magento\CatalogRule\Model\ResourceModel\Rule as RuleResource; +use Magento\CatalogRule\Test\Fixture\Rule as CatalogRuleFixture; +use Magento\Eav\Model\Entity\Attribute\Backend\ArrayBackend; +use Magento\Framework\ObjectManagerInterface; +use Magento\TestFramework\Fixture\AppArea; +use Magento\TestFramework\Fixture\DataFixture; +use Magento\TestFramework\Fixture\DataFixtureStorage; +use Magento\TestFramework\Fixture\DataFixtureStorageManager; +use Magento\TestFramework\Fixture\DbIsolation; +use Magento\TestFramework\Helper\Bootstrap; +use PHPUnit\Framework\TestCase; + +/** + * Negative catalog rule conditions: reindex and rule price for products without values. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ +#[ + AppArea('adminhtml'), + DbIsolation(true) +] +class NegativeConditionRulePriceTest extends TestCase +{ + private const WEBSITE_ID = 1; + private const CUSTOMER_GROUP_ID = 1; + private const PRODUCT_PRICE = 100.0; + private const DISCOUNT_PERCENT = 50; + private const EXPECTED_RULE_PRICE = 50.0; + + /** + * @var ObjectManagerInterface + */ + private ObjectManagerInterface $objectManager; + + /** + * @var DataFixtureStorage + */ + private DataFixtureStorage $fixtures; + + /** + * @var RuleResource + */ + private RuleResource $resourceRule; + + /** + * @var IndexBuilder + */ + private IndexBuilder $indexBuilder; + + protected function setUp(): void + { + $this->objectManager = Bootstrap::getObjectManager(); + $this->fixtures = DataFixtureStorageManager::getStorage(); + $this->resourceRule = $this->objectManager->get(RuleResource::class); + $this->indexBuilder = $this->objectManager->get(IndexBuilder::class); + + $this->objectManager->get(Product\ProductRuleProcessor::class) + ->getIndexer() + ->setScheduled(false); + $this->objectManager->get(Rule\RuleProductProcessor::class) + ->getIndexer() + ->setScheduled(false); + } + + /** + * Multiselect "does not contain": empty product gets discount; product with option does not. + */ + #[ + DataFixture( + MultiselectAttributeFixture::class, + [ + 'entity_type_id' => CategorySetup::CATALOG_PRODUCT_ENTITY_TYPE_ID, + 'source_model' => null, + 'backend_model' => ArrayBackend::class, + 'is_used_for_promo_rules' => true, + 'attribute_model' => Attribute::class, + 'options' => ['option_a', 'option_b'], + ], + 'attr' + ), + DataFixture( + ProductFixture::class, + [ + 'sku' => 'e2e-ms-empty', + 'price' => self::PRODUCT_PRICE, + ], + 'product_empty' + ), + DataFixture( + ProductFixture::class, + [ + 'sku' => 'e2e-ms-has-a', + 'price' => self::PRODUCT_PRICE, + 'custom_attributes' => [ + [ + 'attribute_code' => '$attr.attribute_code$', + 'value' => '$attr.option_a$', + ], + ], + ], + 'product_excluded' + ), + DataFixture( + CatalogRuleFixture::class, + [ + 'name' => 'E2E multiselect does not contain option_a', + 'is_active' => 1, + 'website_ids' => [self::WEBSITE_ID], + 'customer_group_ids' => [self::CUSTOMER_GROUP_ID], + 'simple_action' => 'by_percent', + 'discount_amount' => self::DISCOUNT_PERCENT, + 'stop_rules_processing' => false, + 'conditions' => [ + [ + 'attribute' => '$attr.attribute_code$', + 'operator' => '!{}', + 'value' => '$attr.option_a$', + ], + ], + ], + 'rule' + ) + ] + public function testMultiselectDoesNotContainAppliesRulePriceToEmptyProduct(): void + { + $this->assertNegativeConditionRulePrices(); + } + + /** + * Select "is not": empty product gets discount; product with excluded option does not. + */ + #[ + DataFixture( + SelectAttributeFixture::class, + [ + 'is_used_for_promo_rules' => true, + 'options' => ['option_a', 'option_b'], + ], + 'attr' + ), + DataFixture( + ProductFixture::class, + [ + 'sku' => 'e2e-sel-empty', + 'price' => self::PRODUCT_PRICE, + ], + 'product_empty' + ), + DataFixture( + ProductFixture::class, + [ + 'sku' => 'e2e-sel-has-a', + 'price' => self::PRODUCT_PRICE, + 'custom_attributes' => [ + [ + 'attribute_code' => '$attr.attribute_code$', + 'value' => '$attr.option_a$', + ], + ], + ], + 'product_excluded' + ), + DataFixture( + CatalogRuleFixture::class, + [ + 'name' => 'E2E select is not option_a', + 'is_active' => 1, + 'website_ids' => [self::WEBSITE_ID], + 'customer_group_ids' => [self::CUSTOMER_GROUP_ID], + 'simple_action' => 'by_percent', + 'discount_amount' => self::DISCOUNT_PERCENT, + 'stop_rules_processing' => false, + 'conditions' => [ + [ + 'attribute' => '$attr.attribute_code$', + 'operator' => '!=', + 'value' => '$attr.option_a$', + ], + ], + ], + 'rule' + ) + ] + public function testSelectIsNotAppliesRulePriceToEmptyProduct(): void + { + $this->assertNegativeConditionRulePrices(); + } + + /** + * Category "is not": product without that category gets discount; product in it does not. + */ + #[ + DataFixture(CategoryFixture::class, as: 'category'), + DataFixture( + ProductFixture::class, + [ + 'sku' => 'e2e-cat-empty', + 'price' => self::PRODUCT_PRICE, + ], + 'product_empty' + ), + DataFixture( + ProductFixture::class, + [ + 'sku' => 'e2e-cat-in', + 'price' => self::PRODUCT_PRICE, + 'category_ids' => ['$category.id$'], + ], + 'product_excluded' + ), + DataFixture( + CatalogRuleFixture::class, + [ + 'name' => 'E2E category is not fixture category', + 'is_active' => 1, + 'website_ids' => [self::WEBSITE_ID], + 'customer_group_ids' => [self::CUSTOMER_GROUP_ID], + 'simple_action' => 'by_percent', + 'discount_amount' => self::DISCOUNT_PERCENT, + 'stop_rules_processing' => false, + 'conditions' => [ + [ + 'attribute' => 'category_ids', + 'operator' => '!=', + 'value' => '$category.id$', + ], + ], + ], + 'rule' + ) + ] + public function testCategoryIsNotAppliesRulePriceToProductWithoutCategory(): void + { + $this->assertNegativeConditionRulePrices(); + } + + #[ + DataFixture( + MultiselectAttributeFixture::class, + [ + 'entity_type_id' => CategorySetup::CATALOG_PRODUCT_ENTITY_TYPE_ID, + 'source_model' => null, + 'backend_model' => ArrayBackend::class, + 'is_used_for_promo_rules' => true, + 'attribute_model' => Attribute::class, + 'options' => ['option_a', 'option_b'], + ], + 'attr' + ), + DataFixture( + ProductFixture::class, + [ + 'sku' => 'e2e-ms-undef-empty', + 'price' => self::PRODUCT_PRICE, + ], + 'product_empty' + ), + DataFixture( + ProductFixture::class, + [ + 'sku' => 'e2e-ms-undef-has', + 'price' => self::PRODUCT_PRICE, + 'custom_attributes' => [ + [ + 'attribute_code' => '$attr.attribute_code$', + 'value' => '$attr.option_a$', + ], + ], + ], + 'product_excluded' + ), + DataFixture( + CatalogRuleFixture::class, + [ + 'name' => 'E2E multiselect is undefined', + 'is_active' => 1, + 'website_ids' => [self::WEBSITE_ID], + 'customer_group_ids' => [self::CUSTOMER_GROUP_ID], + 'simple_action' => 'by_percent', + 'discount_amount' => self::DISCOUNT_PERCENT, + 'stop_rules_processing' => false, + 'conditions' => [ + [ + 'attribute' => '$attr.attribute_code$', + 'operator' => '<=>', + 'value' => '', + ], + ], + ], + 'rule' + ) + ] + public function testMultiselectIsUndefinedAppliesRulePriceToEmptyProduct(): void + { + $this->assertNegativeConditionRulePrices(); + } + + #[ + DataFixture( + MultiselectAttributeFixture::class, + [ + 'entity_type_id' => CategorySetup::CATALOG_PRODUCT_ENTITY_TYPE_ID, + 'source_model' => null, + 'backend_model' => ArrayBackend::class, + 'is_used_for_promo_rules' => true, + 'attribute_model' => Attribute::class, + 'options' => ['option_a', 'option_b'], + ], + 'attr' + ), + DataFixture( + ProductFixture::class, + [ + 'sku' => 'e2e-ms-nin-empty', + 'price' => self::PRODUCT_PRICE, + ], + 'product_empty' + ), + DataFixture( + ProductFixture::class, + [ + 'sku' => 'e2e-ms-nin-has-a', + 'price' => self::PRODUCT_PRICE, + 'custom_attributes' => [ + [ + 'attribute_code' => '$attr.attribute_code$', + 'value' => '$attr.option_a$', + ], + ], + ], + 'product_excluded' + ), + DataFixture( + CatalogRuleFixture::class, + [ + 'name' => 'E2E multiselect is not one of option_a', + 'is_active' => 1, + 'website_ids' => [self::WEBSITE_ID], + 'customer_group_ids' => [self::CUSTOMER_GROUP_ID], + 'simple_action' => 'by_percent', + 'discount_amount' => self::DISCOUNT_PERCENT, + 'stop_rules_processing' => false, + 'conditions' => [ + [ + 'attribute' => '$attr.attribute_code$', + 'operator' => '!()', + 'value' => '$attr.option_a$', + ], + ], + ], + 'rule' + ) + ] + public function testMultiselectIsNotOneOfAppliesRulePriceToEmptyProduct(): void + { + $this->assertNegativeConditionRulePrices(); + } + + private function assertNegativeConditionRulePrices(): void + { + $emptyProductId = (int)$this->fixtures->get('product_empty')->getId(); + $excludedProductId = (int)$this->fixtures->get('product_excluded')->getId(); + + $this->indexBuilder->reindexByIds([$emptyProductId, $excludedProductId]); + + $date = new \DateTime(); + $emptyRulePrice = $this->resourceRule->getRulePrice( + $date, + self::WEBSITE_ID, + self::CUSTOMER_GROUP_ID, + $emptyProductId + ); + $excludedRulePrice = $this->resourceRule->getRulePrice( + $date, + self::WEBSITE_ID, + self::CUSTOMER_GROUP_ID, + $excludedProductId + ); + + $this->assertEqualsWithDelta( + self::EXPECTED_RULE_PRICE, + (float)$emptyRulePrice, + 0.0001, + 'Product without the excluded value must receive the catalog rule price after reindex' + ); + $this->assertFalse( + $excludedRulePrice, + 'Product with the excluded value must not receive a catalog rule price' + ); + } +} diff --git a/dev/tests/integration/testsuite/Magento/CatalogRule/Model/ResourceModel/Product/NegativeEavConditionsToCollectionTest.php b/dev/tests/integration/testsuite/Magento/CatalogRule/Model/ResourceModel/Product/NegativeEavConditionsToCollectionTest.php new file mode 100644 index 0000000000000..a8384c3eb0dc3 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/CatalogRule/Model/ResourceModel/Product/NegativeEavConditionsToCollectionTest.php @@ -0,0 +1,339 @@ +<?php +/** + * Copyright 2026 Adobe + * All Rights Reserved. + */ +declare(strict_types=1); + +namespace Magento\CatalogRule\Model\ResourceModel\Product; + +use Magento\Catalog\Model\Product; +use Magento\Catalog\Model\ResourceModel\Eav\Attribute; +use Magento\Catalog\Model\ResourceModel\Product\CollectionFactory as ProductCollectionFactory; +use Magento\Catalog\Setup\CategorySetup; +use Magento\Catalog\Test\Fixture\MultiselectAttribute as MultiselectAttributeFixture; +use Magento\Catalog\Test\Fixture\Product as ProductFixture; +use Magento\Catalog\Test\Fixture\SelectAttribute as SelectAttributeFixture; +use Magento\CatalogRule\Model\ResourceModel\Product\ConditionsToCollectionApplier; +use Magento\CatalogRule\Model\Rule\Condition\Combine; +use Magento\CatalogRule\Model\Rule\Condition\CombineFactory; +use Magento\CatalogRule\Model\Rule\Condition\Product as ProductCondition; +use Magento\CatalogRule\Model\Rule\Condition\ProductFactory as ProductConditionFactory; +use Magento\Eav\Model\Entity\Attribute\Backend\ArrayBackend; +use Magento\Framework\ObjectManagerInterface; +use Magento\TestFramework\Fixture\AppArea; +use Magento\TestFramework\Fixture\DataFixture; +use Magento\TestFramework\Fixture\DataFixtureStorage; +use Magento\TestFramework\Fixture\DataFixtureStorageManager; +use Magento\TestFramework\Fixture\DbIsolation; +use Magento\TestFramework\Helper\Bootstrap; +use PHPUnit\Framework\TestCase; + +/** + * SQL-layer catalog rule filters for empty EAV attribute values (EavAttributeCondition path). + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ +#[ + AppArea('adminhtml'), + DbIsolation(true) +] +class NegativeEavConditionsToCollectionTest extends TestCase +{ + /** + * @var ObjectManagerInterface + */ + private ObjectManagerInterface $objectManager; + + /** + * @var DataFixtureStorage + */ + private DataFixtureStorage $fixtures; + + /** + * @var ConditionsToCollectionApplier + */ + private ConditionsToCollectionApplier $conditionsToCollectionApplier; + + /** + * @var ProductCollectionFactory + */ + private ProductCollectionFactory $productCollectionFactory; + + /** + * @var CombineFactory + */ + private CombineFactory $combineConditionFactory; + + /** + * @var ProductConditionFactory + */ + private ProductConditionFactory $productConditionFactory; + + protected function setUp(): void + { + $this->objectManager = Bootstrap::getObjectManager(); + $this->fixtures = DataFixtureStorageManager::getStorage(); + $this->conditionsToCollectionApplier = $this->objectManager->get(ConditionsToCollectionApplier::class); + $this->productCollectionFactory = $this->objectManager->get(ProductCollectionFactory::class); + $this->combineConditionFactory = $this->objectManager->get(CombineFactory::class); + $this->productConditionFactory = $this->objectManager->get(ProductConditionFactory::class); + } + + #[ + DataFixture( + MultiselectAttributeFixture::class, + [ + 'entity_type_id' => CategorySetup::CATALOG_PRODUCT_ENTITY_TYPE_ID, + 'source_model' => null, + 'backend_model' => ArrayBackend::class, + 'is_used_for_promo_rules' => true, + 'attribute_model' => Attribute::class, + 'options' => ['option_a', 'option_b'], + ], + 'attr' + ), + DataFixture(ProductFixture::class, ['sku' => 'sql-ms-empty'], 'product_empty'), + DataFixture( + ProductFixture::class, + [ + 'sku' => 'sql-ms-a', + 'custom_attributes' => [ + [ + 'attribute_code' => '$attr.attribute_code$', + 'value' => '$attr.option_a$', + ], + ], + ], + 'product_a' + ), + DataFixture( + ProductFixture::class, + [ + 'sku' => 'sql-ms-b', + 'custom_attributes' => [ + [ + 'attribute_code' => '$attr.attribute_code$', + 'value' => '$attr.option_b$', + ], + ], + ], + 'product_b' + ) + ] + public function testMultiselectDoesNotContainIncludesEmptyProductInCollection(): void + { + $skus = $this->getFilteredSkus( + (string)$this->fixtures->get('attr')->getAttributeCode(), + '!{}', + (string)$this->fixtures->get('attr')->getData('option_a'), + ['sql-ms-empty', 'sql-ms-a', 'sql-ms-b'] + ); + + $this->assertContains('sql-ms-empty', $skus); + $this->assertContains('sql-ms-b', $skus); + $this->assertNotContains('sql-ms-a', $skus); + } + + #[ + DataFixture( + MultiselectAttributeFixture::class, + [ + 'entity_type_id' => CategorySetup::CATALOG_PRODUCT_ENTITY_TYPE_ID, + 'source_model' => null, + 'backend_model' => ArrayBackend::class, + 'is_used_for_promo_rules' => true, + 'attribute_model' => Attribute::class, + 'options' => ['option_a', 'option_b'], + ], + 'attr' + ), + DataFixture(ProductFixture::class, ['sku' => 'sql-ms-empty'], 'product_empty'), + DataFixture( + ProductFixture::class, + [ + 'sku' => 'sql-ms-a', + 'custom_attributes' => [ + [ + 'attribute_code' => '$attr.attribute_code$', + 'value' => '$attr.option_a$', + ], + ], + ], + 'product_a' + ) + ] + public function testMultiselectIsUndefinedIncludesOnlyEmptyProductInCollection(): void + { + $skus = $this->getFilteredSkus( + (string)$this->fixtures->get('attr')->getAttributeCode(), + '<=>', + '', + ['sql-ms-empty', 'sql-ms-a'] + ); + + $this->assertContains('sql-ms-empty', $skus); + $this->assertNotContains('sql-ms-a', $skus); + } + + #[ + DataFixture( + SelectAttributeFixture::class, + [ + 'is_used_for_promo_rules' => true, + 'options' => ['option_a', 'option_b'], + ], + 'attr' + ), + DataFixture(ProductFixture::class, ['sku' => 'sql-sel-empty'], 'product_empty'), + DataFixture( + ProductFixture::class, + [ + 'sku' => 'sql-sel-a', + 'custom_attributes' => [ + [ + 'attribute_code' => '$attr.attribute_code$', + 'value' => '$attr.option_a$', + ], + ], + ], + 'product_a' + ), + DataFixture( + ProductFixture::class, + [ + 'sku' => 'sql-sel-b', + 'custom_attributes' => [ + [ + 'attribute_code' => '$attr.attribute_code$', + 'value' => '$attr.option_b$', + ], + ], + ], + 'product_b' + ) + ] + public function testSelectIsNotIncludesEmptyProductInCollection(): void + { + $skus = $this->getFilteredSkus( + (string)$this->fixtures->get('attr')->getAttributeCode(), + '!=', + (string)$this->fixtures->get('attr')->getData('option_a'), + ['sql-sel-empty', 'sql-sel-a', 'sql-sel-b'] + ); + + $this->assertContains('sql-sel-empty', $skus); + $this->assertContains('sql-sel-b', $skus); + $this->assertNotContains('sql-sel-a', $skus); + } + + #[ + DataFixture( + MultiselectAttributeFixture::class, + [ + 'entity_type_id' => CategorySetup::CATALOG_PRODUCT_ENTITY_TYPE_ID, + 'source_model' => null, + 'backend_model' => ArrayBackend::class, + 'is_used_for_promo_rules' => true, + 'attribute_model' => Attribute::class, + 'options' => ['option_a', 'option_b'], + ], + 'attr' + ), + DataFixture(ProductFixture::class, ['sku' => 'sql-false-empty'], 'product_empty'), + DataFixture( + ProductFixture::class, + [ + 'sku' => 'sql-false-a', + 'custom_attributes' => [ + [ + 'attribute_code' => '$attr.attribute_code$', + 'value' => '$attr.option_a$', + ], + ], + ], + 'product_a' + ) + ] + public function testFalseIsUndefinedExcludesEmptyProductInCollection(): void + { + $attributeCode = (string)$this->fixtures->get('attr')->getAttributeCode(); + + /** @var ProductCondition $undefinedCondition */ + $undefinedCondition = $this->productConditionFactory->create(); + $undefinedCondition->setType(ProductCondition::class); + $undefinedCondition->setAttribute($attributeCode); + $undefinedCondition->setOperator('<=>'); + $undefinedCondition->setValue(''); + + /** @var Combine $falseCombine */ + $falseCombine = $this->combineConditionFactory->create(); + $falseCombine->setType(Combine::class); + $falseCombine->setAggregator('all'); + $falseCombine->setValue(0); + $falseCombine->setConditions([$undefinedCondition]); + + /** @var Combine $root */ + $root = $this->combineConditionFactory->create(); + $root->setType(Combine::class); + $root->setAggregator('all'); + $root->setValue(1); + $root->setConditions([$falseCombine]); + + $skus = $this->filterSkusByCondition($root, ['sql-false-empty', 'sql-false-a']); + + $this->assertNotContains('sql-false-empty', $skus); + $this->assertContains('sql-false-a', $skus); + } + + /** + * @param string $attributeCode + * @param string $operator + * @param string $value + * @param string[] $candidateSkus + * @return string[] + */ + private function getFilteredSkus( + string $attributeCode, + string $operator, + string $value, + array $candidateSkus + ): array { + /** @var ProductCondition $condition */ + $condition = $this->productConditionFactory->create(); + $condition->setType(ProductCondition::class); + $condition->setAttribute($attributeCode); + $condition->setOperator($operator); + $condition->setValue($value); + + /** @var Combine $combine */ + $combine = $this->combineConditionFactory->create(); + $combine->setType(Combine::class); + $combine->setAggregator('all'); + $combine->setValue(1); + $combine->setConditions([$condition]); + + return $this->filterSkusByCondition($combine, $candidateSkus); + } + + /** + * @param Combine $condition + * @param string[] $candidateSkus + * @return string[] + */ + private function filterSkusByCondition(Combine $condition, array $candidateSkus): array + { + $collection = $this->productCollectionFactory->create(); + $collection->addAttributeToSelect('sku'); + $collection->addFieldToFilter('sku', ['in' => $candidateSkus]); + + $filtered = $this->conditionsToCollectionApplier->applyConditionsToCollection($condition, $collection); + + return array_map( + static function (Product $product): string { + return (string)$product->getSku(); + }, + array_values($filtered->getItems()) + ); + } +} diff --git a/dev/tests/integration/testsuite/Magento/CatalogRule/Model/Rule/DefinedUndefinedConditionsTest.php b/dev/tests/integration/testsuite/Magento/CatalogRule/Model/Rule/DefinedUndefinedConditionsTest.php new file mode 100644 index 0000000000000..b6b43c7117bea --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/CatalogRule/Model/Rule/DefinedUndefinedConditionsTest.php @@ -0,0 +1,280 @@ +<?php +/** + * Copyright 2026 Adobe + * All Rights Reserved. + */ +declare(strict_types=1); + +namespace Magento\CatalogRule\Model\Rule; + +use Magento\Catalog\Model\ResourceModel\Eav\Attribute; +use Magento\Catalog\Setup\CategorySetup; +use Magento\Catalog\Test\Fixture\MultiselectAttribute as MultiselectAttributeFixture; +use Magento\Catalog\Test\Fixture\Product as ProductFixture; +use Magento\CatalogRule\Model\Rule; +use Magento\CatalogRule\Model\Rule\Condition\Combine; +use Magento\CatalogRule\Model\Rule\Condition\Product as ProductCondition; +use Magento\CatalogRule\Test\Fixture\Rule as CatalogRuleFixture; +use Magento\Eav\Model\Entity\Attribute\Backend\ArrayBackend; +use Magento\Framework\ObjectManagerInterface; +use Magento\TestFramework\Fixture\AppArea; +use Magento\TestFramework\Fixture\DataFixture; +use Magento\TestFramework\Fixture\DataFixtureStorage; +use Magento\TestFramework\Fixture\DataFixtureStorageManager; +use Magento\TestFramework\Fixture\DbIsolation; +use Magento\TestFramework\Helper\Bootstrap; +use PHPUnit\Framework\TestCase; + +/** + * Multiselect "is undefined" for catalog rules — mitigation for empty-value matching under negatives. + * + * "Is defined" is not a separate operator: use a FALSE combine over "is undefined". + */ +#[ + AppArea('adminhtml'), + DbIsolation(true) +] +class DefinedUndefinedConditionsTest extends TestCase +{ + /** + * @var ObjectManagerInterface + */ + private ObjectManagerInterface $objectManager; + + /** + * @var DataFixtureStorage + */ + private DataFixtureStorage $fixtures; + + protected function setUp(): void + { + $this->objectManager = Bootstrap::getObjectManager(); + $this->fixtures = DataFixtureStorageManager::getStorage(); + } + + /** + * Multiselect "is undefined" matches products without a value. + */ + #[ + DataFixture( + MultiselectAttributeFixture::class, + [ + 'entity_type_id' => CategorySetup::CATALOG_PRODUCT_ENTITY_TYPE_ID, + 'source_model' => null, + 'backend_model' => ArrayBackend::class, + 'is_used_for_promo_rules' => true, + 'attribute_model' => Attribute::class, + 'options' => ['option_a', 'option_b'], + ], + 'attr' + ), + DataFixture(ProductFixture::class, ['sku' => 'undef-ms-empty'], 'product_empty'), + DataFixture( + ProductFixture::class, + [ + 'sku' => 'undef-ms-has', + 'custom_attributes' => [ + [ + 'attribute_code' => '$attr.attribute_code$', + 'value' => '$attr.option_a$', + ], + ], + ], + 'product_defined' + ), + DataFixture( + CatalogRuleFixture::class, + [ + 'name' => 'Multiselect is undefined', + 'is_active' => 1, + 'website_ids' => [1], + 'customer_group_ids' => [0, 1], + 'simple_action' => 'by_percent', + 'discount_amount' => 10, + 'conditions' => [ + [ + 'attribute' => '$attr.attribute_code$', + 'operator' => '<=>', + 'value' => '', + ], + ], + ], + 'rule' + ) + ] + public function testMultiselectIsUndefinedMatchesEmptyProductOnly(): void + { + $matchingIds = $this->getMatchingProductIds('rule'); + $this->assertProductMatches($matchingIds, 'product_empty'); + $this->assertProductDoesNotMatch($matchingIds, 'product_defined'); + } + + #[ + DataFixture( + MultiselectAttributeFixture::class, + [ + 'entity_type_id' => CategorySetup::CATALOG_PRODUCT_ENTITY_TYPE_ID, + 'source_model' => null, + 'backend_model' => ArrayBackend::class, + 'is_used_for_promo_rules' => true, + 'attribute_model' => Attribute::class, + 'options' => ['option_a', 'option_b'], + ], + 'attr' + ), + DataFixture(ProductFixture::class, ['sku' => 'combo-ms-empty'], 'product_empty'), + DataFixture( + ProductFixture::class, + [ + 'sku' => 'combo-ms-a', + 'custom_attributes' => [ + [ + 'attribute_code' => '$attr.attribute_code$', + 'value' => '$attr.option_a$', + ], + ], + ], + 'product_a' + ), + DataFixture( + ProductFixture::class, + [ + 'sku' => 'combo-ms-b', + 'custom_attributes' => [ + [ + 'attribute_code' => '$attr.attribute_code$', + 'value' => '$attr.option_b$', + ], + ], + ], + 'product_b' + ) + ] + public function testIsNotWithFalseIsUndefinedExcludesEmptyProducts(): void + { + $attr = $this->fixtures->get('attr'); + $attributeCode = $attr->getAttributeCode(); + $optionA = $attr->getData('option_a'); + + /** @var Rule $rule */ + $rule = $this->objectManager->get(\Magento\CatalogRule\Model\RuleFactory::class)->create(); + $rule->loadPost([ + 'name' => 'Is not A and not undefined', + 'is_active' => '1', + 'stop_rules_processing' => 0, + 'website_ids' => [1], + 'customer_group_ids' => [0, 1], + 'discount_amount' => 10, + 'simple_action' => 'by_percent', + 'from_date' => '', + 'to_date' => '', + 'sort_order' => 0, + 'conditions' => [ + '1' => [ + 'type' => Combine::class, + 'aggregator' => 'all', + 'value' => '1', + 'new_child' => '', + ], + '1--1' => [ + 'type' => ProductCondition::class, + 'attribute' => $attributeCode, + 'operator' => '!()', + 'value' => $optionA, + ], + '1--2' => [ + 'type' => Combine::class, + 'aggregator' => 'all', + 'value' => '0', + 'new_child' => '', + ], + '1--2--1' => [ + 'type' => ProductCondition::class, + 'attribute' => $attributeCode, + 'operator' => '<=>', + 'value' => '', + ], + ], + ]); + $this->objectManager->get(\Magento\CatalogRule\Api\CatalogRuleRepositoryInterface::class)->save($rule); + + $productIds = [ + (int)$this->fixtures->get('product_empty')->getId(), + (int)$this->fixtures->get('product_a')->getId(), + (int)$this->fixtures->get('product_b')->getId(), + ]; + $rule->setProductsFilter($productIds); + $matchingIds = $rule->getMatchingProductIds(); + + $this->assertProductDoesNotMatch( + $matchingIds, + 'product_empty', + 'Empty products must be excluded when is undefined is inverted via a FALSE combine' + ); + $this->assertProductDoesNotMatch($matchingIds, 'product_a'); + $this->assertProductMatches($matchingIds, 'product_b'); + } + + /** + * @param string $ruleFixtureName + * @return array<int, array<int, bool>> + */ + private function getMatchingProductIds(string $ruleFixtureName): array + { + $ruleData = $this->fixtures->get($ruleFixtureName); + /** @var Rule $rule */ + $rule = $this->objectManager->create(Rule::class); + $rule->load($ruleData->getId()); + $this->assertNotEmpty($rule->getId()); + + $productIds = []; + foreach (['product_empty', 'product_defined', 'product_a', 'product_b'] as $name) { + $product = $this->fixtures->get($name); + if ($product !== null) { + $productIds[] = (int)$product->getId(); + } + } + $rule->setProductsFilter($productIds); + + return $rule->getMatchingProductIds(); + } + + /** + * @param array<int, array<int, bool>> $matchingIds + * @param string $productFixtureName + * @param string $message + */ + private function assertProductMatches( + array $matchingIds, + string $productFixtureName, + string $message = '' + ): void { + $productId = (int)$this->fixtures->get($productFixtureName)->getId(); + $this->assertArrayHasKey($productId, $matchingIds, $message ?: "Product {$productFixtureName} should match"); + $this->assertNotEmpty( + array_filter($matchingIds[$productId]), + $message ?: "Product {$productFixtureName} should match for a website" + ); + } + + /** + * @param array<int, array<int, bool>> $matchingIds + * @param string $productFixtureName + * @param string $message + */ + private function assertProductDoesNotMatch( + array $matchingIds, + string $productFixtureName, + string $message = '' + ): void { + $productId = (int)$this->fixtures->get($productFixtureName)->getId(); + if (!isset($matchingIds[$productId])) { + $this->assertTrue(true); + return; + } + $this->assertEmpty( + array_filter($matchingIds[$productId]), + $message ?: "Product {$productFixtureName} should not match" + ); + } +} diff --git a/dev/tests/integration/testsuite/Magento/CatalogRule/Model/Rule/MatchingProductIdsNegativeConditionsTest.php b/dev/tests/integration/testsuite/Magento/CatalogRule/Model/Rule/MatchingProductIdsNegativeConditionsTest.php new file mode 100644 index 0000000000000..c81c643e8b385 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/CatalogRule/Model/Rule/MatchingProductIdsNegativeConditionsTest.php @@ -0,0 +1,556 @@ +<?php +/** + * Copyright 2026 Adobe + * All Rights Reserved. + */ +declare(strict_types=1); + +namespace Magento\CatalogRule\Model\Rule; + +use Magento\Catalog\Model\ResourceModel\Eav\Attribute; +use Magento\Catalog\Setup\CategorySetup; +use Magento\Catalog\Test\Fixture\Category as CategoryFixture; +use Magento\Catalog\Test\Fixture\MultiselectAttribute as MultiselectAttributeFixture; +use Magento\Catalog\Test\Fixture\Product as ProductFixture; +use Magento\Catalog\Test\Fixture\SelectAttribute as SelectAttributeFixture; +use Magento\CatalogRule\Model\Rule; +use Magento\CatalogRule\Test\Fixture\Rule as CatalogRuleFixture; +use Magento\Eav\Model\Entity\Attribute\Backend\ArrayBackend; +use Magento\Framework\ObjectManagerInterface; +use Magento\TestFramework\Fixture\AppArea; +use Magento\TestFramework\Fixture\DataFixture; +use Magento\TestFramework\Fixture\DataFixtureStorage; +use Magento\TestFramework\Fixture\DataFixtureStorageManager; +use Magento\TestFramework\Fixture\DbIsolation; +use Magento\TestFramework\Helper\Bootstrap; +use PHPUnit\Framework\TestCase; + +/** + * Catalog price rule negative conditions for products with no attribute/category value. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ +#[ + AppArea('adminhtml'), + DbIsolation(true) +] +class MatchingProductIdsNegativeConditionsTest extends TestCase +{ + /** + * @var ObjectManagerInterface + */ + private ObjectManagerInterface $objectManager; + + /** + * @var DataFixtureStorage + */ + private DataFixtureStorage $fixtures; + + protected function setUp(): void + { + $this->objectManager = Bootstrap::getObjectManager(); + $this->fixtures = DataFixtureStorageManager::getStorage(); + } + + /** + * Multiselect "does not contain" must include products that never had the attribute set. + */ + #[ + DataFixture( + MultiselectAttributeFixture::class, + [ + 'entity_type_id' => CategorySetup::CATALOG_PRODUCT_ENTITY_TYPE_ID, + 'source_model' => null, + 'backend_model' => ArrayBackend::class, + 'is_used_for_promo_rules' => true, + 'attribute_model' => Attribute::class, + 'options' => ['option_a', 'option_b'], + ], + 'attr' + ), + DataFixture( + ProductFixture::class, + [ + 'sku' => 'ms-no-value', + 'custom_attributes' => [ + // attribute intentionally unset — no EAV row + ], + ], + 'product_no_value' + ), + DataFixture( + ProductFixture::class, + [ + 'sku' => 'ms-has-a', + 'custom_attributes' => [ + [ + 'attribute_code' => '$attr.attribute_code$', + 'value' => '$attr.option_a$', + ], + ], + ], + 'product_has_a' + ), + DataFixture( + ProductFixture::class, + [ + 'sku' => 'ms-has-b', + 'custom_attributes' => [ + [ + 'attribute_code' => '$attr.attribute_code$', + 'value' => '$attr.option_b$', + ], + ], + ], + 'product_has_b' + ), + DataFixture( + CatalogRuleFixture::class, + [ + 'name' => 'Multiselect does not contain option_a', + 'is_active' => 1, + 'website_ids' => [1], + 'customer_group_ids' => [0, 1], + 'simple_action' => 'by_percent', + 'discount_amount' => 10, + 'conditions' => [ + [ + 'attribute' => '$attr.attribute_code$', + 'operator' => '!{}', + 'value' => '$attr.option_a$', + ], + ], + ], + 'rule' + ) + ] + public function testMultiselectDoesNotContainIncludesProductsWithoutValue(): void + { + $matchingIds = $this->getMatchingProductIds('rule'); + + $this->assertProductMatches( + $matchingIds, + 'product_no_value', + 'Product without multiselect value must match "does not contain"' + ); + $this->assertProductMatches( + $matchingIds, + 'product_has_b', + 'Product with a different multiselect option must match "does not contain"' + ); + $this->assertProductDoesNotMatch( + $matchingIds, + 'product_has_a', + 'Product with the excluded multiselect option must not match "does not contain"' + ); + } + + /** + * Multiselect "is not one of" must include products that never had the attribute set. + */ + #[ + DataFixture( + MultiselectAttributeFixture::class, + [ + 'entity_type_id' => CategorySetup::CATALOG_PRODUCT_ENTITY_TYPE_ID, + 'source_model' => null, + 'backend_model' => ArrayBackend::class, + 'is_used_for_promo_rules' => true, + 'attribute_model' => Attribute::class, + 'options' => ['option_a', 'option_b'], + ], + 'attr' + ), + DataFixture( + ProductFixture::class, + ['sku' => 'ms2-no-value'], + 'product_no_value' + ), + DataFixture( + ProductFixture::class, + [ + 'sku' => 'ms2-has-a', + 'custom_attributes' => [ + [ + 'attribute_code' => '$attr.attribute_code$', + 'value' => '$attr.option_a$', + ], + ], + ], + 'product_has_a' + ), + DataFixture( + ProductFixture::class, + [ + 'sku' => 'ms2-has-b', + 'custom_attributes' => [ + [ + 'attribute_code' => '$attr.attribute_code$', + 'value' => '$attr.option_b$', + ], + ], + ], + 'product_has_b' + ), + DataFixture( + CatalogRuleFixture::class, + [ + 'name' => 'Multiselect is not one of option_a', + 'is_active' => 1, + 'website_ids' => [1], + 'customer_group_ids' => [0, 1], + 'simple_action' => 'by_percent', + 'discount_amount' => 10, + 'conditions' => [ + [ + 'attribute' => '$attr.attribute_code$', + 'operator' => '!()', + 'value' => '$attr.option_a$', + ], + ], + ], + 'rule' + ) + ] + public function testMultiselectIsNotOneOfIncludesProductsWithoutValue(): void + { + $matchingIds = $this->getMatchingProductIds('rule'); + + $this->assertProductMatches( + $matchingIds, + 'product_no_value', + 'Product without multiselect value must match "is not one of"' + ); + $this->assertProductMatches( + $matchingIds, + 'product_has_b', + 'Product with a different multiselect option must match "is not one of"' + ); + $this->assertProductDoesNotMatch( + $matchingIds, + 'product_has_a', + 'Product with the excluded multiselect option must not match "is not one of"' + ); + } + + /** + * Dropdown "is not" must include products that never had the attribute set. + */ + #[ + DataFixture( + SelectAttributeFixture::class, + [ + 'is_used_for_promo_rules' => true, + 'options' => ['option_a', 'option_b'], + ], + 'attr' + ), + DataFixture( + ProductFixture::class, + ['sku' => 'sel-no-value'], + 'product_no_value' + ), + DataFixture( + ProductFixture::class, + [ + 'sku' => 'sel-has-a', + 'custom_attributes' => [ + [ + 'attribute_code' => '$attr.attribute_code$', + 'value' => '$attr.option_a$', + ], + ], + ], + 'product_has_a' + ), + DataFixture( + ProductFixture::class, + [ + 'sku' => 'sel-has-b', + 'custom_attributes' => [ + [ + 'attribute_code' => '$attr.attribute_code$', + 'value' => '$attr.option_b$', + ], + ], + ], + 'product_has_b' + ), + DataFixture( + CatalogRuleFixture::class, + [ + 'name' => 'Select is not option_a', + 'is_active' => 1, + 'website_ids' => [1], + 'customer_group_ids' => [0, 1], + 'simple_action' => 'by_percent', + 'discount_amount' => 10, + 'conditions' => [ + [ + 'attribute' => '$attr.attribute_code$', + 'operator' => '!=', + 'value' => '$attr.option_a$', + ], + ], + ], + 'rule' + ) + ] + public function testSelectIsNotIncludesProductsWithoutValue(): void + { + $matchingIds = $this->getMatchingProductIds('rule'); + + $this->assertProductMatches( + $matchingIds, + 'product_no_value', + 'Product without select value must match "is not"' + ); + $this->assertProductMatches( + $matchingIds, + 'product_has_b', + 'Product with a different select option must match "is not"' + ); + $this->assertProductDoesNotMatch( + $matchingIds, + 'product_has_a', + 'Product with the excluded select option must not match "is not"' + ); + } + + /** + * Category "is not one of" must include products that are not assigned to that category, + * including products with no category assignment at all. + */ + #[ + DataFixture(CategoryFixture::class, as: 'category'), + DataFixture( + ProductFixture::class, + ['sku' => 'cat-no-category'], + 'product_no_category' + ), + DataFixture( + ProductFixture::class, + [ + 'sku' => 'cat-in-category', + 'category_ids' => ['$category.id$'], + ], + 'product_in_category' + ), + DataFixture( + CatalogRuleFixture::class, + [ + 'name' => 'Category is not one of fixture category', + 'is_active' => 1, + 'website_ids' => [1], + 'customer_group_ids' => [0, 1], + 'simple_action' => 'by_percent', + 'discount_amount' => 10, + 'conditions' => [ + [ + 'attribute' => 'category_ids', + 'operator' => '!()', + 'value' => '$category.id$', + ], + ], + ], + 'rule' + ) + ] + public function testCategoryIsNotOneOfIncludesProductsWithoutCategory(): void + { + $matchingIds = $this->getMatchingProductIds('rule'); + + $this->assertProductMatches( + $matchingIds, + 'product_no_category', + 'Product without category assignment must match "category is not one of"' + ); + $this->assertProductDoesNotMatch( + $matchingIds, + 'product_in_category', + 'Product assigned to the excluded category must not match' + ); + } + + /** + * Category "is not" must include products that are not assigned to that category, + * including products with no category assignment at all. + */ + #[ + DataFixture(CategoryFixture::class, as: 'category'), + DataFixture( + ProductFixture::class, + ['sku' => 'cat2-no-category'], + 'product_no_category' + ), + DataFixture( + ProductFixture::class, + [ + 'sku' => 'cat2-in-category', + 'category_ids' => ['$category.id$'], + ], + 'product_in_category' + ), + DataFixture( + CatalogRuleFixture::class, + [ + 'name' => 'Category is not fixture category', + 'is_active' => 1, + 'website_ids' => [1], + 'customer_group_ids' => [0, 1], + 'simple_action' => 'by_percent', + 'discount_amount' => 10, + 'conditions' => [ + [ + 'attribute' => 'category_ids', + 'operator' => '!=', + 'value' => '$category.id$', + ], + ], + ], + 'rule' + ) + ] + public function testCategoryIsNotIncludesProductsWithoutCategory(): void + { + $matchingIds = $this->getMatchingProductIds('rule'); + + $this->assertProductMatches( + $matchingIds, + 'product_no_category', + 'Product without category assignment must match "category is not"' + ); + $this->assertProductDoesNotMatch( + $matchingIds, + 'product_in_category', + 'Product assigned to the excluded category must not match' + ); + } + + /** + * special_price "is not" / absence: product with no special_price must match a negative + * comparison that excludes a concrete special_price value (issue comment scenario). + */ + #[ + DataFixture( + ProductFixture::class, + [ + 'sku' => 'sp-no-special', + 'price' => 100, + // special_price left unset + ], + 'product_no_special' + ), + DataFixture( + ProductFixture::class, + [ + 'sku' => 'sp-has-special', + 'price' => 100, + 'special_price' => 25, + ], + 'product_has_special' + ), + DataFixture( + CatalogRuleFixture::class, + [ + 'name' => 'special_price is not 25', + 'is_active' => 1, + 'website_ids' => [1], + 'customer_group_ids' => [0, 1], + 'simple_action' => 'by_percent', + 'discount_amount' => 10, + 'conditions' => [ + [ + 'attribute' => 'special_price', + 'operator' => '!=', + 'value' => '25', + ], + ], + ], + 'rule' + ) + ] + public function testSpecialPriceIsNotIncludesProductsWithoutSpecialPrice(): void + { + $matchingIds = $this->getMatchingProductIds('rule'); + + $this->assertProductMatches( + $matchingIds, + 'product_no_special', + 'Product without special_price must match "special_price is not 25"' + ); + $this->assertProductDoesNotMatch( + $matchingIds, + 'product_has_special', + 'Product with special_price = 25 must not match "special_price is not 25"' + ); + } + + /** + * Load rule and return matching product IDs keyed by product ID. + * + * @param string $ruleFixtureName + * @return array<int, array<int, bool>> + */ + private function getMatchingProductIds(string $ruleFixtureName): array + { + $ruleData = $this->fixtures->get($ruleFixtureName); + /** @var Rule $rule */ + $rule = $this->objectManager->create(Rule::class); + $rule->load($ruleData->getId()); + $this->assertNotEmpty($rule->getId(), 'Catalog rule fixture must be saved'); + + // Restrict matching to fixtures under test so unrelated catalog products do not affect assertions. + $productIds = []; + foreach (['product_no_value', 'product_has_a', 'product_has_b', 'product_no_category', + 'product_in_category', 'product_no_special', 'product_has_special'] as $name) { + $product = $this->fixtures->get($name); + if ($product !== null) { + $productIds[] = (int)$product->getId(); + } + } + $rule->setProductsFilter($productIds); + + return $rule->getMatchingProductIds(); + } + + /** + * @param array<int, array<int, bool>> $matchingIds + * @param string $productFixtureName + * @param string $message + */ + private function assertProductMatches(array $matchingIds, string $productFixtureName, string $message): void + { + $productId = (int)$this->fixtures->get($productFixtureName)->getId(); + $this->assertArrayHasKey( + $productId, + $matchingIds, + $message . sprintf(' (product id %d missing from matching set)', $productId) + ); + $this->assertNotEmpty( + array_filter($matchingIds[$productId]), + $message . sprintf(' (product id %d present but not matched for any website)', $productId) + ); + } + + /** + * @param array<int, array<int, bool>> $matchingIds + * @param string $productFixtureName + * @param string $message + */ + private function assertProductDoesNotMatch( + array $matchingIds, + string $productFixtureName, + string $message + ): void { + $productId = (int)$this->fixtures->get($productFixtureName)->getId(); + if (!isset($matchingIds[$productId])) { + $this->assertTrue(true); + return; + } + $this->assertEmpty( + array_filter($matchingIds[$productId]), + $message . sprintf(' (product id %d matched for websites: %s)', $productId, json_encode($matchingIds[$productId])) + ); + } +} diff --git a/dev/tests/integration/testsuite/Magento/CatalogWidget/Block/Product/NegativeMultiselectConditionsTest.php b/dev/tests/integration/testsuite/Magento/CatalogWidget/Block/Product/NegativeMultiselectConditionsTest.php new file mode 100644 index 0000000000000..5244a53595da5 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/CatalogWidget/Block/Product/NegativeMultiselectConditionsTest.php @@ -0,0 +1,196 @@ +<?php +/** + * Copyright 2026 Adobe + * All Rights Reserved. + */ +declare(strict_types=1); + +namespace Magento\CatalogWidget\Block\Product; + +use Magento\Catalog\Model\ResourceModel\Eav\Attribute; +use Magento\Catalog\Setup\CategorySetup; +use Magento\Catalog\Test\Fixture\MultiselectAttribute as MultiselectAttributeFixture; +use Magento\Catalog\Test\Fixture\Product as ProductFixture; +use Magento\CatalogWidget\Block\Product\ProductsList; +use Magento\CatalogWidget\Model\Rule\Condition\Combine; +use Magento\CatalogWidget\Model\Rule\Condition\Product as WidgetProductCondition; +use Magento\Eav\Model\Entity\Attribute\Backend\ArrayBackend; +use Magento\Framework\ObjectManagerInterface; +use Magento\TestFramework\Fixture\DataFixture; +use Magento\TestFramework\Fixture\DataFixtureStorage; +use Magento\TestFramework\Fixture\DataFixtureStorageManager; +use Magento\TestFramework\Fixture\DbIsolation; +use Magento\TestFramework\Helper\Bootstrap; +use PHPUnit\Framework\TestCase; + +/** + * Catalog product list widget filters for empty multiselect values (Sql Builder path). + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ +#[ + DbIsolation(false) +] +class NegativeMultiselectConditionsTest extends TestCase +{ + /** + * @var ObjectManagerInterface + */ + private ObjectManagerInterface $objectManager; + + /** + * @var DataFixtureStorage + */ + private DataFixtureStorage $fixtures; + + /** + * @var ProductsList + */ + private ProductsList $block; + + protected function setUp(): void + { + $this->objectManager = Bootstrap::getObjectManager(); + $this->fixtures = DataFixtureStorageManager::getStorage(); + $this->block = $this->objectManager->create(ProductsList::class); + } + + #[ + DataFixture( + MultiselectAttributeFixture::class, + [ + 'entity_type_id' => CategorySetup::CATALOG_PRODUCT_ENTITY_TYPE_ID, + 'source_model' => null, + 'backend_model' => ArrayBackend::class, + 'is_used_for_promo_rules' => true, + 'attribute_model' => Attribute::class, + 'options' => ['option_a', 'option_b'], + ], + 'attr' + ), + DataFixture(ProductFixture::class, ['sku' => 'widget-ms-empty'], 'product_empty'), + DataFixture( + ProductFixture::class, + [ + 'sku' => 'widget-ms-a', + 'custom_attributes' => [ + [ + 'attribute_code' => '$attr.attribute_code$', + 'value' => '$attr.option_a$', + ], + ], + ], + 'product_a' + ), + DataFixture( + ProductFixture::class, + [ + 'sku' => 'widget-ms-b', + 'custom_attributes' => [ + [ + 'attribute_code' => '$attr.attribute_code$', + 'value' => '$attr.option_b$', + ], + ], + ], + 'product_b' + ) + ] + public function testDoesNotContainIncludesProductsWithoutMultiselectValue(): void + { + $attributeCode = (string)$this->fixtures->get('attr')->getAttributeCode(); + $optionA = (string)$this->fixtures->get('attr')->getData('option_a'); + + $skus = $this->getCollectionSkus([ + '1' => [ + 'type' => Combine::class, + 'aggregator' => 'all', + 'value' => '1', + 'new_child' => '', + ], + '1--1' => [ + 'type' => WidgetProductCondition::class, + 'attribute' => $attributeCode, + 'operator' => '!{}', + 'value' => $optionA, + ], + ]); + + $this->assertContains('widget-ms-empty', $skus); + $this->assertContains('widget-ms-b', $skus); + $this->assertNotContains('widget-ms-a', $skus); + } + + #[ + DataFixture( + MultiselectAttributeFixture::class, + [ + 'entity_type_id' => CategorySetup::CATALOG_PRODUCT_ENTITY_TYPE_ID, + 'source_model' => null, + 'backend_model' => ArrayBackend::class, + 'is_used_for_promo_rules' => true, + 'attribute_model' => Attribute::class, + 'options' => ['option_a', 'option_b'], + ], + 'attr' + ), + DataFixture(ProductFixture::class, ['sku' => 'widget-undef-empty'], 'product_empty'), + DataFixture( + ProductFixture::class, + [ + 'sku' => 'widget-undef-a', + 'custom_attributes' => [ + [ + 'attribute_code' => '$attr.attribute_code$', + 'value' => '$attr.option_a$', + ], + ], + ], + 'product_a' + ) + ] + public function testIsUndefinedIncludesOnlyProductsWithoutMultiselectValue(): void + { + $attributeCode = (string)$this->fixtures->get('attr')->getAttributeCode(); + + $skus = $this->getCollectionSkus([ + '1' => [ + 'type' => Combine::class, + 'aggregator' => 'all', + 'value' => '1', + 'new_child' => '', + ], + '1--1' => [ + 'type' => WidgetProductCondition::class, + 'attribute' => $attributeCode, + 'operator' => '<=>', + 'value' => '', + ], + ]); + + $this->assertContains('widget-undef-empty', $skus); + $this->assertNotContains('widget-undef-a', $skus); + } + + /** + * @param array<string, array<string, mixed>> $conditions + * @return string[] + */ + private function getCollectionSkus(array $conditions): array + { + $candidateIds = []; + foreach (['product_empty', 'product_a', 'product_b'] as $fixtureName) { + $product = $this->fixtures->get($fixtureName); + if ($product !== null) { + $candidateIds[] = (int)$product->getId(); + } + } + + $this->block->setConditions($conditions); + $collection = $this->block->createCollection(); + $collection->addFieldToFilter('entity_id', ['in' => $candidateIds]); + $collection->load(); + + return $collection->getColumnValues('sku'); + } +} diff --git a/dev/tests/integration/testsuite/Magento/SalesRule/Model/Rule/Condition/NegativeProductAttributeConditionTest.php b/dev/tests/integration/testsuite/Magento/SalesRule/Model/Rule/Condition/NegativeProductAttributeConditionTest.php new file mode 100644 index 0000000000000..e111840634886 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/SalesRule/Model/Rule/Condition/NegativeProductAttributeConditionTest.php @@ -0,0 +1,189 @@ +<?php +/** + * Copyright 2026 Adobe + * All Rights Reserved. + */ +declare(strict_types=1); + +namespace Magento\SalesRule\Model\Rule\Condition; + +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Catalog\Model\ResourceModel\Eav\Attribute; +use Magento\Catalog\Setup\CategorySetup; +use Magento\Catalog\Test\Fixture\MultiselectAttribute as MultiselectAttributeFixture; +use Magento\Catalog\Test\Fixture\Product as ProductFixture; +use Magento\Catalog\Test\Fixture\SelectAttribute as SelectAttributeFixture; +use Magento\Eav\Model\Entity\Attribute\Backend\ArrayBackend; +use Magento\Framework\ObjectManagerInterface; +use Magento\Quote\Model\Quote\Item as QuoteItem; +use Magento\SalesRule\Model\Rule\Condition\Product as SalesRuleProductCondition; +use Magento\TestFramework\Fixture\AppArea; +use Magento\TestFramework\Fixture\DataFixture; +use Magento\TestFramework\Fixture\DataFixtureStorage; +use Magento\TestFramework\Fixture\DataFixtureStorageManager; +use Magento\TestFramework\Fixture\DbIsolation; +use Magento\TestFramework\Helper\Bootstrap; +use PHPUnit\Framework\TestCase; + +/** + * Sales rule product attribute negative conditions for empty attribute values. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ +#[ + AppArea('frontend'), + DbIsolation(true) +] +class NegativeProductAttributeConditionTest extends TestCase +{ + /** + * @var ObjectManagerInterface + */ + private ObjectManagerInterface $objectManager; + + /** + * @var DataFixtureStorage + */ + private DataFixtureStorage $fixtures; + + /** + * @var ProductRepositoryInterface + */ + private ProductRepositoryInterface $productRepository; + + protected function setUp(): void + { + $this->objectManager = Bootstrap::getObjectManager(); + $this->fixtures = DataFixtureStorageManager::getStorage(); + $this->productRepository = $this->objectManager->get(ProductRepositoryInterface::class); + } + + #[ + DataFixture( + MultiselectAttributeFixture::class, + [ + 'entity_type_id' => CategorySetup::CATALOG_PRODUCT_ENTITY_TYPE_ID, + 'source_model' => null, + 'backend_model' => ArrayBackend::class, + 'is_used_for_promo_rules' => true, + 'attribute_model' => Attribute::class, + 'options' => ['option_a', 'option_b'], + ], + 'attr' + ), + DataFixture( + ProductFixture::class, + [ + 'sku' => 'sr-ms-empty', + 'price' => 100, + ], + 'product_empty' + ), + DataFixture( + ProductFixture::class, + [ + 'sku' => 'sr-ms-a', + 'price' => 100, + 'custom_attributes' => [ + [ + 'attribute_code' => '$attr.attribute_code$', + 'value' => '$attr.option_a$', + ], + ], + ], + 'product_a' + ) + ] + public function testMultiselectDoesNotContainAppliesToProductWithoutValue(): void + { + $attributeCode = (string)$this->fixtures->get('attr')->getAttributeCode(); + $optionA = (string)$this->fixtures->get('attr')->getData('option_a'); + + $this->assertTrue( + $this->validateProductCondition('product_empty', $attributeCode, '!{}', $optionA), + 'Product without multiselect value must match "does not contain"' + ); + $this->assertFalse( + $this->validateProductCondition('product_a', $attributeCode, '!{}', $optionA), + 'Product with excluded multiselect option must not match "does not contain"' + ); + } + + #[ + DataFixture( + SelectAttributeFixture::class, + [ + 'is_used_for_promo_rules' => true, + 'options' => ['option_a', 'option_b'], + ], + 'attr' + ), + DataFixture( + ProductFixture::class, + [ + 'sku' => 'sr-sel-empty', + 'price' => 100, + ], + 'product_empty' + ), + DataFixture( + ProductFixture::class, + [ + 'sku' => 'sr-sel-a', + 'price' => 100, + 'custom_attributes' => [ + [ + 'attribute_code' => '$attr.attribute_code$', + 'value' => '$attr.option_a$', + ], + ], + ], + 'product_a' + ) + ] + public function testSelectIsNotAppliesToProductWithoutValue(): void + { + $attributeCode = (string)$this->fixtures->get('attr')->getAttributeCode(); + $optionA = (string)$this->fixtures->get('attr')->getData('option_a'); + + $this->assertTrue( + $this->validateProductCondition('product_empty', $attributeCode, '!=', $optionA), + 'Product without select value must match "is not"' + ); + $this->assertFalse( + $this->validateProductCondition('product_a', $attributeCode, '!=', $optionA), + 'Product with excluded select option must not match "is not"' + ); + } + + /** + * @param string $productFixtureName + * @param string $attributeCode + * @param string $operator + * @param string $value + * @return bool + */ + private function validateProductCondition( + string $productFixtureName, + string $attributeCode, + string $operator, + string $value + ): bool { + $productId = (int)$this->fixtures->get($productFixtureName)->getId(); + $product = $this->productRepository->getById($productId, false, null, true); + $product->load($productId); + + /** @var QuoteItem $quoteItem */ + $quoteItem = $this->objectManager->create(QuoteItem::class); + $quoteItem->setProduct($product); + $quoteItem->setQty(1); + + /** @var SalesRuleProductCondition $condition */ + $condition = $this->objectManager->create(SalesRuleProductCondition::class); + $condition->setAttribute($attributeCode); + $condition->setOperator($operator); + $condition->setValue($value); + + return (bool)$condition->validate($quoteItem); + } +}