Repository: saleor/saleor

Issue:
ProductVariants with duplicated AttributeValue
Mutation `ProductVariantCreate` allows creating `ProductVariant` with duplicated `AttributeValue`. It's shouldn't be avable to create product variant with the same values in attributes. 


Discussion:

Resolving patch:
diff --git a/saleor/graphql/product/bulk_mutations/products.py b/saleor/graphql/product/bulk_mutations/products.py
--- a/saleor/graphql/product/bulk_mutations/products.py
+++ b/saleor/graphql/product/bulk_mutations/products.py
@@ -22,6 +22,7 @@
     ProductVariantInput,
 )
 from ..types import ProductVariant
+from ..utils import get_used_variants_attribute_values
 
 
 class CategoryBulkDelete(ModelBulkDeleteMutation):
@@ -193,30 +194,41 @@ def create_variants(cls, info, cleaned_inputs, product, errors):
         return instances
 
     @classmethod
-    def clean_variants(cls, info, variants, product_type, errors):
+    def validate_duplicated_sku(cls, sku, index, sku_list, errors):
+        if sku in sku_list:
+            errors["sku"].append(
+                ValidationError(
+                    "Duplicated SKU.", ProductErrorCode.UNIQUE, params={"index": index}
+                )
+            )
+        sku_list.append(sku)
+
+    @classmethod
+    def clean_variants(cls, info, variants, product, errors):
         cleaned_inputs = []
         sku_list = []
+        used_attribute_values = get_used_variants_attribute_values(product)
         for index, variant_data in enumerate(variants):
+            try:
+                ProductVariantCreate.validate_duplicated_attribute_values(
+                    variant_data.attributes, used_attribute_values
+                )
+            except ValidationError as exc:
+                errors["attributes"].append(
+                    ValidationError(exc.message, exc.code, params={"index": index})
+                )
+
             cleaned_input = None
             try:
-                variant_data["product_type"] = product_type
+                variant_data["product_type"] = product.product_type
                 cleaned_input = cls.clean_input(info, None, variant_data)
             except ValidationError as exc:
                 cls.add_indexes_to_errors(index, exc, errors)
             cleaned_inputs.append(cleaned_input if cleaned_input else None)
 
-            # Find duplicated sku in variants
             if not variant_data.sku:
                 continue
-            if variant_data.sku in sku_list:
-                errors["sku"].append(
-                    ValidationError(
-                        "Duplicated SKU.",
-                        ProductErrorCode.UNIQUE,
-                        params={"index": index},
-                    )
-                )
-            sku_list.append(variant_data.sku)
+            cls.validate_duplicated_sku(variant_data.sku, index, sku_list, errors)
         return cleaned_inputs
 
     @classmethod
@@ -233,9 +245,7 @@ def perform_mutation(cls, root, info, **data):
         product = cls.get_node_or_error(info, data["product_id"], models.Product)
         errors = defaultdict(list)
 
-        cleaned_inputs = cls.clean_variants(
-            info, data["variants"], product.product_type, errors
-        )
+        cleaned_inputs = cls.clean_variants(info, data["variants"], product, errors)
         instances = cls.create_variants(info, cleaned_inputs, product, errors)
         if errors:
             raise ValidationError(errors)
diff --git a/saleor/graphql/product/mutations/products.py b/saleor/graphql/product/mutations/products.py
--- a/saleor/graphql/product/mutations/products.py
+++ b/saleor/graphql/product/mutations/products.py
@@ -1,3 +1,4 @@
+from collections import defaultdict
 from typing import Iterable, List, Tuple, Union
 
 import graphene
@@ -49,6 +50,7 @@
     ProductVariant,
 )
 from ..utils import (
+    get_used_variants_attribute_values,
     validate_attribute_input_for_product,
     validate_attribute_input_for_variant,
 )
@@ -451,7 +453,6 @@ class Meta:
 
 class AttributeValueInput(InputObjectType):
     id = graphene.ID(description="ID of the selected attribute.")
-    slug = graphene.String(description="Slug of the selected attribute.")
     values = graphene.List(
         graphene.String,
         required=True,
@@ -1041,6 +1042,19 @@ def clean_attributes(
         )
         return attributes
 
+    @classmethod
+    def validate_duplicated_attribute_values(cls, attributes, used_attribute_values):
+        attribute_values = defaultdict(list)
+        for attribute in attributes:
+            attribute_values[attribute.id].extend(attribute.values)
+        if attribute_values in used_attribute_values:
+            raise ValidationError(
+                "Duplicated attribute values for product variant.",
+                ProductErrorCode.UNIQUE,
+            )
+        else:
+            used_attribute_values.append(attribute_values)
+
     @classmethod
     def clean_input(cls, info, instance: models.ProductVariant, data: dict):
         cleaned_input = super().clean_input(info, instance, data)
@@ -1061,12 +1075,21 @@ def clean_input(cls, info, instance: models.ProductVariant, data: dict):
                 # If the variant is getting updated,
                 # simply retrieve the associated product type
                 product_type = instance.product.product_type
+                used_attribute_values = get_used_variants_attribute_values(
+                    instance.product
+                )
             else:
                 # If the variant is getting created, no product type is associated yet,
                 # retrieve it from the required "product" input field
                 product_type = cleaned_input["product"].product_type
+                used_attribute_values = get_used_variants_attribute_values(
+                    cleaned_input["product"]
+                )
 
             try:
+                cls.validate_duplicated_attribute_values(
+                    attributes, used_attribute_values
+                )
                 cleaned_input["attributes"] = cls.clean_attributes(
                     attributes, product_type
                 )
diff --git a/saleor/graphql/product/utils.py b/saleor/graphql/product/utils.py
--- a/saleor/graphql/product/utils.py
+++ b/saleor/graphql/product/utils.py
@@ -1,5 +1,7 @@
+from collections import defaultdict
 from typing import List
 
+import graphene
 from django.core.exceptions import ValidationError
 
 from ...product import AttributeInputType, models
@@ -45,3 +47,36 @@ def validate_attribute_input_for_variant(instance: models.Attribute, values: Lis
         raise ValidationError(
             "Attribute values cannot be blank", code=ProductErrorCode.REQUIRED
         )
+
+
+def get_used_variants_attribute_values(product):
+    """Create list of attributes values for all existing `ProductVariants` for product.
+
+    Sample result is:
+    [
+        {
+            "attribute_1_global_id": ["ValueAttr1_1"],
+            "attribute_2_global_id": ["ValueAttr2_1"]
+        },
+        ...
+        {
+            "attribute_1_global_id": ["ValueAttr1_2"],
+            "attribute_2_global_id": ["ValueAttr2_2"]
+        }
+    ]
+    """
+    variants = (
+        product.variants.prefetch_related("attributes__values")
+        .prefetch_related("attributes__assignment")
+        .all()
+    )
+    used_attribute_values = []
+    for variant in variants:
+        attribute_values = defaultdict(list)
+        for assigned_variant_attribute in variant.attributes.all():
+            attribute = assigned_variant_attribute.attribute
+            attribute_id = graphene.Node.to_global_id("Attribute", attribute.id)
+            for variant in assigned_variant_attribute.values.all():
+                attribute_values[attribute_id].append(variant.slug)
+        used_attribute_values.append(attribute_values)
+    return used_attribute_values


Test patch:
diff --git a/tests/api/benchmark/test_variant.py b/tests/api/benchmark/test_variant.py
--- a/tests/api/benchmark/test_variant.py
+++ b/tests/api/benchmark/test_variant.py
@@ -1,6 +1,9 @@
+from uuid import uuid4
+
+import graphene
 import pytest
-from graphene import Node
 
+from saleor.product.models import ProductVariant
 from tests.api.utils import get_graphql_content
 
 
@@ -61,8 +64,61 @@ def test_retrieve_variant_list(product_variant_list, api_client, count_queries):
 
     variables = {
         "ids": [
-            Node.to_global_id("ProductVariant", variant.pk)
+            graphene.Node.to_global_id("ProductVariant", variant.pk)
             for variant in product_variant_list
         ]
     }
     get_graphql_content(api_client.post_graphql(query, variables))
+
+
+@pytest.mark.django_db
+@pytest.mark.count_queries(autouse=False)
+def test_product_variant_bulk_create(
+    staff_api_client,
+    product_with_two_variants,
+    permission_manage_products,
+    color_attribute,
+    size_attribute,
+    count_queries,
+):
+    query = """
+    mutation ProductVariantBulkCreate(
+        $variants: [ProductVariantBulkCreateInput]!, $productId: ID!
+    ) {
+        productVariantBulkCreate(variants: $variants, product: $productId) {
+            bulkProductErrors {
+                field
+                message
+                code
+                index
+            }
+            productVariants{
+                id
+                sku
+            }
+            count
+        }
+    }
+    """
+    product = product_with_two_variants
+    product_variant_count = ProductVariant.objects.count()
+    product_id = graphene.Node.to_global_id("Product", product.pk)
+    color_attribute_id = graphene.Node.to_global_id("Attribute", color_attribute.id)
+    size_attribute_id = graphene.Node.to_global_id("Attribute", size_attribute.id)
+    variants = [
+        {
+            "sku": str(uuid4())[:12],
+            "attributes": [
+                {"id": color_attribute_id, "values": ["red"]},
+                {"id": size_attribute_id, "values": ["big"]},
+            ],
+        }
+    ]
+    variables = {"productId": product_id, "variants": variants}
+    staff_api_client.user.user_permissions.add(permission_manage_products)
+    response = staff_api_client.post_graphql(query, variables)
+    content = get_graphql_content(response)
+    data = content["data"]["productVariantBulkCreate"]
+    assert not data["bulkProductErrors"]
+    assert data["count"] == 1
+    assert product_variant_count + 1 == ProductVariant.objects.count()
diff --git a/tests/api/test_product.py b/tests/api/test_product.py
--- a/tests/api/test_product.py
+++ b/tests/api/test_product.py
@@ -1,7 +1,7 @@
 import json
 from datetime import datetime
 from decimal import Decimal
-from unittest.mock import Mock, patch
+from unittest.mock import ANY, Mock, patch
 
 import graphene
 import pytest
@@ -15,6 +15,7 @@
 from saleor.graphql.core.enums import ReportingPeriod
 from saleor.graphql.product.enums import StockAvailability
 from saleor.product import AttributeInputType
+from saleor.product.error_codes import ProductErrorCode
 from saleor.product.models import (
     Attribute,
     AttributeValue,
@@ -1236,9 +1237,10 @@ def test_update_product(
 SET_ATTRIBUTES_TO_PRODUCT_QUERY = """
     mutation updateProduct($productId: ID!, $attributes: [AttributeValueInput!]) {
       productUpdate(id: $productId, input: { attributes: $attributes }) {
-        errors {
+        productErrors {
           message
           field
+          code
         }
       }
     }
@@ -1270,11 +1272,8 @@ def test_update_product_can_only_assign_multiple_values_to_valid_input_types(
     data = get_graphql_content(
         staff_api_client.post_graphql(SET_ATTRIBUTES_TO_PRODUCT_QUERY, variables)
     )["data"]["productUpdate"]
-    assert data["errors"] == [
-        {
-            "field": "attributes",
-            "message": "A dropdown attribute must take only one value",
-        }
+    assert data["productErrors"] == [
+        {"field": "attributes", "code": ProductErrorCode.INVALID.name, "message": ANY}
     ]
 
     # Try to assign multiple values from a valid attribute
@@ -1282,7 +1281,7 @@ def test_update_product_can_only_assign_multiple_values_to_valid_input_types(
     data = get_graphql_content(
         staff_api_client.post_graphql(SET_ATTRIBUTES_TO_PRODUCT_QUERY, variables)
     )["data"]["productUpdate"]
-    assert not data["errors"]
+    assert not data["productErrors"]
 
 
 def test_update_product_with_existing_attribute_value(
@@ -1306,7 +1305,7 @@ def test_update_product_with_existing_attribute_value(
     data = get_graphql_content(
         staff_api_client.post_graphql(SET_ATTRIBUTES_TO_PRODUCT_QUERY, variables)
     )["data"]["productUpdate"]
-    assert not data["errors"]
+    assert not data["productErrors"]
 
     assert (
         color_attribute.values.count() == expected_attribute_values_count
@@ -1322,6 +1321,7 @@ def test_update_product_without_supplying_required_product_attribute(
     staff_api_client.user.user_permissions.add(permission_manage_products)
 
     product_type = product.product_type
+    color_attribute_id = graphene.Node.to_global_id("Attribute", color_attribute.id)
 
     # Create and assign a new attribute requiring a value to be always supplied
     required_attribute = Attribute.objects.create(
@@ -1332,43 +1332,25 @@ def test_update_product_without_supplying_required_product_attribute(
     # Try to assign multiple values from an attribute that does not support such things
     variables = {
         "productId": graphene.Node.to_global_id("Product", product.pk),
-        "attributes": [{"slug": color_attribute.slug, "values": ["Blue"]}],
+        "attributes": [{"id": color_attribute_id, "values": ["Blue"]}],
     }
 
     data = get_graphql_content(
         staff_api_client.post_graphql(SET_ATTRIBUTES_TO_PRODUCT_QUERY, variables)
     )["data"]["productUpdate"]
-    assert data["errors"] == [
-        {
-            "field": "attributes",
-            "message": (
-                "All attributes flagged as having a value required must be supplied."
-            ),
-        }
+    assert data["productErrors"] == [
+        {"field": "attributes", "code": ProductErrorCode.REQUIRED.name, "message": ANY}
     ]
 
 
-@pytest.mark.parametrize(
-    "attributes_input, expected_message",
-    (
-        (
-            [{"id": "QXR0cmlidXRlOjA=", "values": ["hello"]}],  # no such ID (id=0)
-            "Could not resolve to a node: ids=['QXR0cmlidXRlOjA='] and slugs=[]",
-        ),
-        (
-            [{"slug": "Oopsie.", "values": ["hello"]}],  # no such slug
-            "Could not resolve to a node: ids=[] and slugs=['Oopsie.']",
-        ),
-    ),
-)
 def test_update_product_with_non_existing_attribute(
-    staff_api_client,
-    product,
-    permission_manage_products,
-    color_attribute,
-    attributes_input,
-    expected_message,
+    staff_api_client, product, permission_manage_products, color_attribute
 ):
+    non_existent_attribute_pk = 0
+    invalid_attribute_id = graphene.Node.to_global_id(
+        "Attribute", non_existent_attribute_pk
+    )
+
     """Ensure assigning an existing value to a product doesn't create a new
     attribute value."""
 
@@ -1377,13 +1359,15 @@ def test_update_product_with_non_existing_attribute(
     # Try to assign multiple values from an attribute that does not support such things
     variables = {
         "productId": graphene.Node.to_global_id("Product", product.pk),
-        "attributes": attributes_input,
+        "attributes": [{"id": invalid_attribute_id, "values": ["hello"]}],
     }
 
     data = get_graphql_content(
         staff_api_client.post_graphql(SET_ATTRIBUTES_TO_PRODUCT_QUERY, variables)
     )["data"]["productUpdate"]
-    assert data["errors"] == [{"field": "attributes", "message": expected_message}]
+    assert data["productErrors"] == [
+        {"field": "attributes", "code": ProductErrorCode.NOT_FOUND.name, "message": ANY}
+    ]
 
 
 def test_update_product_with_no_attribute_slug_or_id(
@@ -1402,8 +1386,8 @@ def test_update_product_with_no_attribute_slug_or_id(
     data = get_graphql_content(
         staff_api_client.post_graphql(SET_ATTRIBUTES_TO_PRODUCT_QUERY, variables)
     )["data"]["productUpdate"]
-    assert data["errors"] == [
-        {"field": "attributes", "message": "You must whether supply an ID or a slug"}
+    assert data["productErrors"] == [
+        {"field": "attributes", "code": ProductErrorCode.REQUIRED.name, "message": ANY}
     ]
 
 
diff --git a/tests/api/test_product_minimal_variant_price.py b/tests/api/test_product_minimal_variant_price.py
--- a/tests/api/test_product_minimal_variant_price.py
+++ b/tests/api/test_product_minimal_variant_price.py
@@ -1,5 +1,6 @@
 from unittest.mock import patch
 
+import graphene
 import pytest
 from freezegun import freeze_time
 from graphql_relay import from_global_id, to_global_id
@@ -141,7 +142,7 @@ def test_product_variant_create_updates_minimal_variant_price(
                 productVariant {
                     name
                 }
-                errors {
+                productErrors {
                     message
                     field
                 }
@@ -151,13 +152,15 @@ def test_product_variant_create_updates_minimal_variant_price(
     product_id = to_global_id("Product", product.pk)
     sku = "1"
     price_override = "1.99"
-    variant_slug = product.product_type.variant_attributes.first().slug
+    variant_id = graphene.Node.to_global_id(
+        "Attribute", product.product_type.variant_attributes.first().pk
+    )
     variant_value = "test-value"
     variables = {
         "productId": product_id,
         "sku": sku,
         "priceOverride": price_override,
-        "attributes": [{"slug": variant_slug, "values": [variant_value]}],
+        "attributes": [{"id": variant_id, "values": [variant_value]}],
     }
     response = staff_api_client.post_graphql(
         query, variables, permissions=[permission_manage_products]
@@ -166,7 +169,7 @@ def test_product_variant_create_updates_minimal_variant_price(
 
     content = get_graphql_content(response)
     data = content["data"]["productVariantCreate"]
-    assert data["errors"] == []
+    assert data["productErrors"] == []
 
     mock_update_product_minimal_variant_price_task.delay.assert_called_once_with(
         product.pk
diff --git a/tests/api/test_variant.py b/tests/api/test_variant.py
--- a/tests/api/test_variant.py
+++ b/tests/api/test_variant.py
@@ -1,4 +1,3 @@
-from decimal import Decimal
 from unittest.mock import ANY
 from uuid import uuid4
 
@@ -7,7 +6,7 @@
 from graphene.utils.str_converters import to_camel_case
 
 from saleor.product.error_codes import ProductErrorCode
-from saleor.product.models import Attribute, ProductVariant
+from saleor.product.models import ProductVariant
 from tests.api.utils import get_graphql_content
 
 
@@ -86,7 +85,7 @@ def test_create_variant(
                         trackInventory: $trackInventory,
                         weight: $weight
                     }) {
-                    errors {
+                    productErrors {
                       field
                       message
                     }
@@ -128,6 +127,9 @@ def test_create_variant(
     quantity = 10
     weight = 10.22
     variant_slug = product_type.variant_attributes.first().slug
+    variant_id = graphene.Node.to_global_id(
+        "Attribute", product_type.variant_attributes.first().pk
+    )
     variant_value = "test-value"
 
     variables = {
@@ -137,14 +139,14 @@ def test_create_variant(
         "costPrice": cost_price,
         "priceOverride": price_override,
         "weight": weight,
-        "attributes": [{"slug": variant_slug, "values": [variant_value]}],
+        "attributes": [{"id": variant_id, "values": [variant_value]}],
         "trackInventory": True,
     }
     response = staff_api_client.post_graphql(
         query, variables, permissions=[permission_manage_products]
     )
     content = get_graphql_content(response)["data"]["productVariantCreate"]
-    assert not content["errors"]
+    assert not content["productErrors"]
     data = content["productVariant"]
     assert data["name"] == variant_value
     assert data["quantity"] == quantity
@@ -171,8 +173,9 @@ def test_create_product_variant_not_all_attributes(
                             sku: $sku,
                             attributes: $attributes
                         }) {
-                        errors {
+                        productErrors {
                             field
+                            code
                             message
                         }
                     }
@@ -181,23 +184,79 @@ def test_create_product_variant_not_all_attributes(
         """
     product_id = graphene.Node.to_global_id("Product", product.pk)
     sku = "1"
-    variant_slug = product_type.variant_attributes.first().slug
+    variant_id = graphene.Node.to_global_id(
+        "Attribute", product_type.variant_attributes.first().pk
+    )
     variant_value = "test-value"
     product_type.variant_attributes.add(color_attribute)
 
     variables = {
         "productId": product_id,
         "sku": sku,
-        "attributes": [{"slug": variant_slug, "values": [variant_value]}],
+        "attributes": [{"id": variant_id, "values": [variant_value]}],
     }
     response = staff_api_client.post_graphql(
         query, variables, permissions=[permission_manage_products]
     )
     content = get_graphql_content(response)
-    assert content["data"]["productVariantCreate"]["errors"]
-    assert content["data"]["productVariantCreate"]["errors"][0] == {
+    assert content["data"]["productVariantCreate"]["productErrors"]
+    assert content["data"]["productVariantCreate"]["productErrors"][0] == {
         "field": "attributes",
-        "message": "All attributes must take a value",
+        "code": ProductErrorCode.REQUIRED.name,
+        "message": ANY,
+    }
+    assert not product.variants.filter(sku=sku).exists()
+
+
+def test_create_product_variant_duplicated_attributes(
+    staff_api_client,
+    product_with_two_variants,
+    color_attribute,
+    size_attribute,
+    permission_manage_products,
+):
+    query = """
+        mutation createVariant (
+            $productId: ID!,
+            $sku: String!,
+            $attributes: [AttributeValueInput]!
+        ) {
+            productVariantCreate(
+                input: {
+                    product: $productId,
+                    sku: $sku,
+                    attributes: $attributes
+                }) {
+                productErrors {
+                    field
+                    code
+                    message
+                }
+            }
+        }
+    """
+    product = product_with_two_variants
+    product_id = graphene.Node.to_global_id("Product", product.pk)
+    color_attribute_id = graphene.Node.to_global_id("Attribute", color_attribute.id)
+    size_attribute_id = graphene.Node.to_global_id("Attribute", size_attribute.id)
+    sku = str(uuid4())[:12]
+    variables = {
+        "productId": product_id,
+        "sku": sku,
+        "attributes": [
+            {"id": color_attribute_id, "values": ["red"]},
+            {"id": size_attribute_id, "values": ["small"]},
+        ],
+    }
+    response = staff_api_client.post_graphql(
+        query, variables, permissions=[permission_manage_products]
+    )
+    content = get_graphql_content(response)
+    assert content["data"]["productVariantCreate"]["productErrors"]
+    assert content["data"]["productVariantCreate"]["productErrors"][0] == {
+        "field": "attributes",
+        "code": ProductErrorCode.UNIQUE.name,
+        "message": ANY,
     }
     assert not product.variants.filter(sku=sku).exists()
 
@@ -648,52 +707,6 @@ def test_fetch_unpublished_variant_anonymous_user(
 """
 
 
-def test_product_variant_bulk_create_by_attribute_slug(
-    staff_api_client, product, size_attribute, permission_manage_products
-):
-    product_variant_count = ProductVariant.objects.count()
-    attribute_value_count = size_attribute.values.count()
-    product_id = graphene.Node.to_global_id("Product", product.pk)
-    attribute_value = size_attribute.values.last()
-    sku = str(uuid4())[:12]
-    quantity = 1000
-    cost_price = 15.5
-    price_override = 9.5
-    weight = 2.5
-    track_inventory = True
-    variants = [
-        {
-            "sku": sku,
-            "quantity": quantity,
-            "costPrice": cost_price,
-            "priceOverride": price_override,
-            "weight": weight,
-            "trackInventory": track_inventory,
-            "attributes": [
-                {"slug": size_attribute.slug, "values": [attribute_value.name]}
-            ],
-        }
-    ]
-
-    variables = {"productId": product_id, "variants": variants}
-    staff_api_client.user.user_permissions.add(permission_manage_products)
-    response = staff_api_client.post_graphql(
-        PRODUCT_VARIANT_BULK_CREATE_MUTATION, variables
-    )
-    content = get_graphql_content(response)
-    data = content["data"]["productVariantBulkCreate"]
-    assert not data["bulkProductErrors"]
-    assert data["count"] == 1
-    assert product_variant_count + 1 == ProductVariant.objects.count()
-    assert attribute_value_count == size_attribute.values.count()
-    product_variant = ProductVariant.objects.get(sku=sku)
-    assert product_variant.quantity == quantity
-    assert product_variant.cost_price.amount == cost_price
-    assert product_variant.price_override.amount == Decimal(price_override)
-    assert product_variant.weight.value == weight
-    assert product_variant.track_inventory == track_inventory
-
-
 def test_product_variant_bulk_create_by_attribute_id(
     staff_api_client, product, size_attribute, permission_manage_products
 ):
@@ -755,18 +768,17 @@ def test_product_variant_bulk_create_with_new_attribute_value(
 ):
     product_variant_count = ProductVariant.objects.count()
     attribute_value_count = size_attribute.values.count()
+    size_attribute_id = graphene.Node.to_global_id("Attribute", size_attribute.pk)
     product_id = graphene.Node.to_global_id("Product", product.pk)
     attribute_value = size_attribute.values.last()
     variants = [
         {
             "sku": str(uuid4())[:12],
-            "attributes": [
-                {"slug": size_attribute.slug, "values": [attribute_value.name]}
-            ],
+            "attributes": [{"id": size_attribute_id, "values": [attribute_value.name]}],
         },
         {
             "sku": str(uuid4())[:12],
-            "attributes": [{"slug": size_attribute.slug, "values": ["Test-attribute"]}],
+            "attributes": [{"id": size_attribute_id, "values": ["Test-attribute"]}],
         },
     ]
 
@@ -788,16 +800,17 @@ def test_product_variant_bulk_create_negative_quantity(
 ):
     product_variant_count = ProductVariant.objects.count()
     product_id = graphene.Node.to_global_id("Product", product.pk)
+    size_attribute_id = graphene.Node.to_global_id("Attribute", size_attribute.pk)
     variants = [
         {
             "sku": str(uuid4())[:12],
             "quantity": -1000,
-            "attributes": [{"slug": size_attribute.slug, "values": ["Test-value"]}],
+            "attributes": [{"id": size_attribute_id, "values": ["Test-value"]}],
         },
         {
             "sku": str(uuid4())[:12],
             "quantity": 100,
-            "attributes": [{"slug": size_attribute.slug, "values": ["Test-value2"]}],
+            "attributes": [{"id": size_attribute_id, "values": ["Test-value2"]}],
         },
     ]
 
@@ -825,17 +838,18 @@ def test_product_variant_bulk_create_duplicated_sku(
 ):
     product_variant_count = ProductVariant.objects.count()
     product_id = graphene.Node.to_global_id("Product", product.pk)
+    size_attribute_id = graphene.Node.to_global_id("Attribute", size_attribute.pk)
     sku = product.variants.first().sku
     sku2 = product_with_default_variant.variants.first().sku
     assert not sku == sku2
     variants = [
         {
             "sku": sku,
-            "attributes": [{"slug": size_attribute.slug, "values": ["Test-value"]}],
+            "attributes": [{"id": size_attribute_id, "values": ["Test-value"]}],
         },
         {
             "sku": sku2,
-            "attributes": [{"slug": size_attribute.slug, "values": ["Test-valuee"]}],
+            "attributes": [{"id": size_attribute_id, "values": ["Test-valuee"]}],
         },
     ]
 
@@ -860,15 +874,16 @@ def test_product_variant_bulk_create_duplicated_sku_in_input(
 ):
     product_variant_count = ProductVariant.objects.count()
     product_id = graphene.Node.to_global_id("Product", product.pk)
+    size_attribute_id = graphene.Node.to_global_id("Attribute", size_attribute.pk)
     sku = str(uuid4())[:12]
     variants = [
         {
             "sku": sku,
-            "attributes": [{"slug": size_attribute.slug, "values": ["Test-value"]}],
+            "attributes": [{"id": size_attribute_id, "values": ["Test-value"]}],
         },
         {
             "sku": sku,
-            "attributes": [{"slug": size_attribute.slug, "values": ["Test-value2"]}],
+            "attributes": [{"id": size_attribute_id, "values": ["Test-value2"]}],
         },
     ]
 
@@ -887,69 +902,37 @@ def test_product_variant_bulk_create_duplicated_sku_in_input(
     assert product_variant_count == ProductVariant.objects.count()
 
 
-def test_product_variant_bulk_create_invalid_attribute_slug(
-    staff_api_client, product, permission_manage_products
-):
-    unasign_attribute = Attribute.objects.create(
-        slug="new_attribute", name="New attribute"
-    )
-    product_variant_count = ProductVariant.objects.count()
-    product_id = graphene.Node.to_global_id("Product", product.pk)
-    variants = [
-        {
-            "sku": str(uuid4())[:12],
-            "attributes": [{"slug": "invalid", "values": ["Test-value"]}],
-        },
-        {
-            "sku": str(uuid4())[:12],
-            "attributes": [{"slug": unasign_attribute.slug, "values": ["Test-value"]}],
-        },
-    ]
-
-    variables = {"productId": product_id, "variants": variants}
-    staff_api_client.user.user_permissions.add(permission_manage_products)
-    response = staff_api_client.post_graphql(
-        PRODUCT_VARIANT_BULK_CREATE_MUTATION, variables
-    )
-    content = get_graphql_content(response)
-    data = content["data"]["productVariantBulkCreate"]
-    assert len(data["bulkProductErrors"]) == 2
-    errors = data["bulkProductErrors"]
-    assert errors[0]["field"] == "attributes"
-    assert errors[0]["code"] == ProductErrorCode.NOT_FOUND.name
-    assert errors[0]["index"] == 0
-    assert errors[1]["field"] == "attributes"
-    assert errors[1]["code"] == ProductErrorCode.NOT_FOUND.name
-    assert errors[1]["index"] == 1
-    assert product_variant_count == ProductVariant.objects.count()
-
-
 def test_product_variant_bulk_create_many_errors(
     staff_api_client, product, size_attribute, permission_manage_products
 ):
     product_variant_count = ProductVariant.objects.count()
     product_id = graphene.Node.to_global_id("Product", product.pk)
+    size_attribute_id = graphene.Node.to_global_id("Attribute", size_attribute.pk)
+    non_existent_attribute_pk = 0
+    invalid_attribute_id = graphene.Node.to_global_id(
+        "Attribute", non_existent_attribute_pk
+    )
     sku = product.variants.first().sku
     variants = [
         {
             "sku": str(uuid4())[:12],
             "quantity": -1000,
-            "attributes": [{"slug": size_attribute.slug, "values": ["Test-value1"]}],
+            "attributes": [{"id": size_attribute_id, "values": ["Test-value1"]}],
         },
         {
             "sku": str(uuid4())[:12],
             "quantity": 100,
-            "attributes": [{"slug": size_attribute.slug, "values": ["Test-value4"]}],
+            "attributes": [{"id": size_attribute_id, "values": ["Test-value4"]}],
         },
         {
             "sku": sku,
             "quantity": 100,
-            "attributes": [{"slug": size_attribute.slug, "values": ["Test-value2"]}],
+            "attributes": [{"id": size_attribute_id, "values": ["Test-value2"]}],
         },
         {
             "sku": str(uuid4())[:12],
             "quantity": 100,
-            "attributes": [{"slug": "Invalid", "values": ["Test-value3"]}],
+            "attributes": [{"id": invalid_attribute_id, "values": ["Test-value3"]}],
         },
     ]
 
@@ -985,3 +968,107 @@ def test_product_variant_bulk_create_many_errors(
     for expected_error in expected_errors:
         assert expected_error in errors
     assert product_variant_count == ProductVariant.objects.count()
+
+
+def test_product_variant_bulk_create_two_variants_duplicated_attribute_value(
+    staff_api_client,
+    product_with_two_variants,
+    color_attribute,
+    size_attribute,
+    permission_manage_products,
+):
+    product = product_with_two_variants
+    product_variant_count = ProductVariant.objects.count()
+    product_id = graphene.Node.to_global_id("Product", product.pk)
+    color_attribute_id = graphene.Node.to_global_id("Attribute", color_attribute.id)
+    size_attribute_id = graphene.Node.to_global_id("Attribute", size_attribute.id)
+    variants = [
+        {
+            "sku": str(uuid4())[:12],
+            "attributes": [
+                {"id": color_attribute_id, "values": ["red"]},
+                {"id": size_attribute_id, "values": ["small"]},
+            ],
+        }
+    ]
+    variables = {"productId": product_id, "variants": variants}
+    staff_api_client.user.user_permissions.add(permission_manage_products)
+    response = staff_api_client.post_graphql(
+        PRODUCT_VARIANT_BULK_CREATE_MUTATION, variables
+    )
+    content = get_graphql_content(response)
+    data = content["data"]["productVariantBulkCreate"]
+    assert len(data["bulkProductErrors"]) == 1
+    error = data["bulkProductErrors"][0]
+    assert error["field"] == "attributes"
+    assert error["code"] == ProductErrorCode.UNIQUE.name
+    assert error["index"] == 0
+    assert product_variant_count == ProductVariant.objects.count()
+
+
+def test_product_variant_bulk_create_two_variants_duplicated_attribute_value_in_input(
+    staff_api_client,
+    product_with_two_variants,
+    permission_manage_products,
+    color_attribute,
+    size_attribute,
+):
+    product = product_with_two_variants
+    product_id = graphene.Node.to_global_id("Product", product.pk)
+    product_variant_count = ProductVariant.objects.count()
+    color_attribute_id = graphene.Node.to_global_id("Attribute", color_attribute.id)
+    size_attribute_id = graphene.Node.to_global_id("Attribute", size_attribute.id)
+    attributes = [
+        {"id": color_attribute_id, "values": [color_attribute.values.last().slug]},
+        {"id": size_attribute_id, "values": [size_attribute.values.last().slug]},
+    ]
+    variants = [
+        {"sku": str(uuid4())[:12], "attributes": attributes},
+        {"sku": str(uuid4())[:12], "attributes": attributes},
+    ]
+    variables = {"productId": product_id, "variants": variants}
+    staff_api_client.user.user_permissions.add(permission_manage_products)
+    response = staff_api_client.post_graphql(
+        PRODUCT_VARIANT_BULK_CREATE_MUTATION, variables
+    )
+    content = get_graphql_content(response)
+    data = content["data"]["productVariantBulkCreate"]
+    assert len(data["bulkProductErrors"]) == 1
+    error = data["bulkProductErrors"][0]
+    assert error["field"] == "attributes"
+    assert error["code"] == ProductErrorCode.UNIQUE.name
+    assert error["index"] == 1
+    assert product_variant_count == ProductVariant.objects.count()
+
+
+def test_product_variant_bulk_create_two_variants_duplicated_one_attribute_value(
+    staff_api_client,
+    product_with_two_variants,
+    color_attribute,
+    size_attribute,
+    permission_manage_products,
+):
+    product = product_with_two_variants
+    product_variant_count = ProductVariant.objects.count()
+    product_id = graphene.Node.to_global_id("Product", product.pk)
+    color_attribute_id = graphene.Node.to_global_id("Attribute", color_attribute.id)
+    size_attribute_id = graphene.Node.to_global_id("Attribute", size_attribute.id)
+    variants = [
+        {
+            "sku": str(uuid4())[:12],
+            "attributes": [
+                {"id": color_attribute_id, "values": ["red"]},
+                {"id": size_attribute_id, "values": ["big"]},
+            ],
+        }
+    ]
+    variables = {"productId": product_id, "variants": variants}
+    staff_api_client.user.user_permissions.add(permission_manage_products)
+    response = staff_api_client.post_graphql(
+        PRODUCT_VARIANT_BULK_CREATE_MUTATION, variables
+    )
+    content = get_graphql_content(response)
+    data = content["data"]["productVariantBulkCreate"]
+    assert not data["bulkProductErrors"]
+    assert data["count"] == 1
+    assert product_variant_count + 1 == ProductVariant.objects.count()
diff --git a/tests/conftest.py b/tests/conftest.py
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -487,6 +487,39 @@ def product(product_type, category):
     return product
 
 
+@pytest.fixture
+def product_with_two_variants(color_attribute, size_attribute, category):
+    product_type = ProductType.objects.create(
+        name="Type with two variants", has_variants=True, is_shipping_required=True
+    )
+    product_type.variant_attributes.add(color_attribute)
+    product_type.variant_attributes.add(size_attribute)
+
+    product = Product.objects.create(
+        name="Test product with two variants",
+        price=Money("10.00", "USD"),
+        product_type=product_type,
+        category=category,
+    )
+
+    variant = ProductVariant.objects.create(
+        product=product,
+        sku="prodVar1",
+        cost_price=Money("1.00", "USD"),
+        quantity=10,
+        quantity_allocated=1,
+    )
+
+    associate_attribute_values_to_instance(
+        variant, color_attribute, color_attribute.values.first()
+    )
+    associate_attribute_values_to_instance(
+        variant, size_attribute, size_attribute.values.first()
+    )
+
+    return product
+
+
 @pytest.fixture
 def product_with_multiple_values_attributes(product, product_type, category) -> Product: