From fc57d34fc0b7d2883c801e9ddf8038eb007c25b0 Mon Sep 17 00:00:00 2001 From: "o.kravchuk" Date: Thu, 13 Aug 2026 10:33:02 +0300 Subject: [PATCH 1/2] magento/magento2#32805: Fix catalog rule negative conditions for missing values Negative catalog price rule conditions (is not, does not contain, is not one of) now match products with missing or empty attribute values. Multiselect gains an "is undefined" operator; inverse "is defined" is expressed via a FALSE combine. --- .../EavAttributeCondition.php | 69 ++- .../ConditionsToSearchCriteriaMapper.php | 4 +- .../Model/Rule/Condition/Product.php | 23 +- ...logRuleIsUndefinedConditionActionGroup.xml | 34 ++ ...logRuleMultiselectConditionActionGroup.xml | 40 ++ ...talogRuleMultiselectDoesNotContainTest.xml | 124 ++++ ...yCatalogRuleMultiselectIsUndefinedTest.xml | 99 ++++ ...talogRuleNegativeCategoryConditionTest.xml | 113 ++++ .../ConditionsToSearchCriteriaMapperTest.php | 2 + .../Unit/Model/Rule/Condition/ProductTest.php | 29 +- .../Model/Condition/AbstractCondition.php | 30 +- .../Rule/Model/Condition/Sql/Builder.php | 19 +- .../Model/Condition/AbstractConditionTest.php | 11 +- .../NegativeConditionRulePriceTest.php | 407 +++++++++++++ .../NegativeEavConditionsToCollectionTest.php | 339 +++++++++++ .../Rule/DefinedUndefinedConditionsTest.php | 280 +++++++++ ...tchingProductIdsNegativeConditionsTest.php | 556 ++++++++++++++++++ .../NegativeMultiselectConditionsTest.php | 196 ++++++ .../NegativeProductAttributeConditionTest.php | 189 ++++++ 19 files changed, 2536 insertions(+), 28 deletions(-) create mode 100644 app/code/Magento/CatalogRule/Test/Mftf/ActionGroup/AdminFillCatalogRuleIsUndefinedConditionActionGroup.xml create mode 100644 app/code/Magento/CatalogRule/Test/Mftf/ActionGroup/AdminFillCatalogRuleMultiselectConditionActionGroup.xml create mode 100644 app/code/Magento/CatalogRule/Test/Mftf/Test/AdminApplyCatalogRuleMultiselectDoesNotContainTest.xml create mode 100644 app/code/Magento/CatalogRule/Test/Mftf/Test/AdminApplyCatalogRuleMultiselectIsUndefinedTest.xml create mode 100644 app/code/Magento/CatalogRule/Test/Mftf/Test/AdminApplyCatalogRuleNegativeCategoryConditionTest.xml create mode 100644 dev/tests/integration/testsuite/Magento/CatalogRule/Model/Indexer/NegativeConditionRulePriceTest.php create mode 100644 dev/tests/integration/testsuite/Magento/CatalogRule/Model/ResourceModel/Product/NegativeEavConditionsToCollectionTest.php create mode 100644 dev/tests/integration/testsuite/Magento/CatalogRule/Model/Rule/DefinedUndefinedConditionsTest.php create mode 100644 dev/tests/integration/testsuite/Magento/CatalogRule/Model/Rule/MatchingProductIdsNegativeConditionsTest.php create mode 100644 dev/tests/integration/testsuite/Magento/CatalogWidget/Block/Product/NegativeMultiselectConditionsTest.php create mode 100644 dev/tests/integration/testsuite/Magento/SalesRule/Model/Rule/Condition/NegativeProductAttributeConditionTest.php 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..7675c0d7c736a --- /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..1282377007aba --- /dev/null +++ b/app/code/Magento/CatalogRule/Test/Mftf/Test/AdminApplyCatalogRuleMultiselectDoesNotContainTest.xml @@ -0,0 +1,124 @@ + + + + + + + + + + <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> + <actionGroup ref="AdminCatalogPriceRuleFillActionsActionGroup" stepKey="fillActions"> + <argument name="discountAmount" value="50"/> + </actionGroup> + <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..bc11748c1a740 --- /dev/null +++ b/app/code/Magento/CatalogRule/Test/Mftf/Test/AdminApplyCatalogRuleMultiselectIsUndefinedTest.xml @@ -0,0 +1,99 @@ +<?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> + <actionGroup ref="AdminCatalogPriceRuleFillActionsActionGroup" stepKey="fillActions"> + <argument name="discountAmount" value="50"/> + </actionGroup> + <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); + } +} From d5572c3d21ce93d8d501802ab7346a0257aafc70 Mon Sep 17 00:00:00 2001 From: "o.kravchuk" <o.kravchuk@vconnect.dk> Date: Thu, 13 Aug 2026 11:27:39 +0300 Subject: [PATCH 2/2] magento/magento2#32805: Fix multiselect catalog rule MFTF after browser run Drop apply-button click for multiselect condition values and open Actions via the fieldset selector used by core catalog rule MFTFs. --- ...FillCatalogRuleMultiselectConditionActionGroup.xml | 4 ++-- ...nApplyCatalogRuleMultiselectDoesNotContainTest.xml | 11 ++++++++--- ...dminApplyCatalogRuleMultiselectIsUndefinedTest.xml | 11 ++++++++--- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/app/code/Magento/CatalogRule/Test/Mftf/ActionGroup/AdminFillCatalogRuleMultiselectConditionActionGroup.xml b/app/code/Magento/CatalogRule/Test/Mftf/ActionGroup/AdminFillCatalogRuleMultiselectConditionActionGroup.xml index 7675c0d7c736a..97a22a1984879 100644 --- a/app/code/Magento/CatalogRule/Test/Mftf/ActionGroup/AdminFillCatalogRuleMultiselectConditionActionGroup.xml +++ b/app/code/Magento/CatalogRule/Test/Mftf/ActionGroup/AdminFillCatalogRuleMultiselectConditionActionGroup.xml @@ -33,8 +33,8 @@ <conditionalClick selector="{{AdminNewCatalogPriceRuleConditions.condition('...')}}" dependentSelector="{{AdminNewCatalogPriceRuleConditions.activeOperatorSelect}}" visible="true" stepKey="closeOperatorSelect"/> <click selector="{{AdminNewCatalogPriceRuleConditions.condition('...')}}" stepKey="clickValueEllipsis"/> <waitForElementVisible selector="{{AdminNewCatalogPriceRuleConditions.activeValueInput}}" stepKey="waitForValueInput"/> + <!-- Multiselect/select value chooser auto-applies on option select (no rule-param-apply control). --> <selectOption selector="{{AdminNewCatalogPriceRuleConditions.activeValueInput}}" userInput="{{conditionValue}}" stepKey="selectConditionValue"/> - <click selector="{{AdminNewCatalogPriceRuleConditions.activeConditionApplyButton}}" stepKey="clickApply"/> - <waitForElementNotVisible selector="{{AdminNewCatalogPriceRuleConditions.activeConditionApplyButton}}" stepKey="waitForApplyButtonInvisibility"/> + <waitForPageLoad stepKey="waitAfterSelectConditionValue"/> </actionGroup> </actionGroups> diff --git a/app/code/Magento/CatalogRule/Test/Mftf/Test/AdminApplyCatalogRuleMultiselectDoesNotContainTest.xml b/app/code/Magento/CatalogRule/Test/Mftf/Test/AdminApplyCatalogRuleMultiselectDoesNotContainTest.xml index 1282377007aba..4980bb74e44f2 100644 --- a/app/code/Magento/CatalogRule/Test/Mftf/Test/AdminApplyCatalogRuleMultiselectDoesNotContainTest.xml +++ b/app/code/Magento/CatalogRule/Test/Mftf/Test/AdminApplyCatalogRuleMultiselectDoesNotContainTest.xml @@ -89,9 +89,14 @@ <argument name="conditionOperator" value="does not contain"/> <argument name="conditionValue" value="$createOptionExcluded.option[store_labels][0][label]$"/> </actionGroup> - <actionGroup ref="AdminCatalogPriceRuleFillActionsActionGroup" stepKey="fillActions"> - <argument name="discountAmount" value="50"/> - </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"/> diff --git a/app/code/Magento/CatalogRule/Test/Mftf/Test/AdminApplyCatalogRuleMultiselectIsUndefinedTest.xml b/app/code/Magento/CatalogRule/Test/Mftf/Test/AdminApplyCatalogRuleMultiselectIsUndefinedTest.xml index bc11748c1a740..3c4595e187911 100644 --- a/app/code/Magento/CatalogRule/Test/Mftf/Test/AdminApplyCatalogRuleMultiselectIsUndefinedTest.xml +++ b/app/code/Magento/CatalogRule/Test/Mftf/Test/AdminApplyCatalogRuleMultiselectIsUndefinedTest.xml @@ -72,9 +72,14 @@ <argument name="condition" value="$createMultiselectAttribute.attribute[frontend_labels][0][label]$"/> <argument name="defaultOperatorLabel" value="contains"/> </actionGroup> - <actionGroup ref="AdminCatalogPriceRuleFillActionsActionGroup" stepKey="fillActions"> - <argument name="discountAmount" value="50"/> - </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"/>