Repository: saleor/saleor

Issue:
`productVariantBulkUpdate` error policy does not properly reject failed rows
Using the `productVariantBulkUpdate` mutation to create stocks does not allow for the error policy to gracefully handle failed errors. Instead, Saleor throws a 500 server error. This happens if you try to call create on the same variant warehouse stock more than once.
```
mutation updateChannel($id: ID!, $variants: [ProductVariantBulkUpdateInput!]!) {
        productVariantBulkUpdate(errorPolicy: REJECT_FAILED_ROWS, product: $id, variants: $variants) {
            errors {
                field
                message
            }
            results {
                errors {
                    message
                }
                productVariant {
                    id
                }
            }
        }
    }
```
This mutation with the following input
```
{
  "id": "UHJvZHVjdDo2MQ==",
  "variants": [
    {
      "id": "UHJvZHVjdFZhcmlhbnQ6MTcwNzk=",
      "stocks": {
        "create": [
          {
            "warehouse": "V2FyZWhvdXNlOjE1YzRmM2E1LTQwNGEtNDg0Zi1iMjY0LThjYjdhOTE1N2U3OA==",
            "quantity": 100
          },
          {
            "warehouse": "V2FyZWhvdXNlOjIyZTJlODQ3LTgzNWMtNDcwMi1hMDJjLTI1YjhiNWZlNWEzMQ==",
            "quantity": 100
          },
          {
            "warehouse": "V2FyZWhvdXNlOjdlZTE5Nzc0LWJiZTQtNDA5YS1iMjBiLTg4YWNlOTA2Zjg1NA==",
            "quantity": 100
          },
          {
            "warehouse": "V2FyZWhvdXNlOmQ0ZGNhZDc5LWI0MmEtNGRhZC04M2VhLTdkMDEyYzRjZjkwYQ==",
            "quantity": 100
          }
        ]
      }
    }
  ]
}
```
Will succeed if the stock has never been created, but when running it again it throws a server error.  There are instances where we do not know if the stock has been created, so we attempt it again as there is no 'upsert' function provided in the schema and would like to simply ignore the errors.


Discussion:

Resolving patch:
diff --git a/saleor/graphql/product/bulk_mutations/product_variant_bulk_update.py b/saleor/graphql/product/bulk_mutations/product_variant_bulk_update.py
--- a/saleor/graphql/product/bulk_mutations/product_variant_bulk_update.py
+++ b/saleor/graphql/product/bulk_mutations/product_variant_bulk_update.py
@@ -289,7 +289,28 @@ def clean_stocks(
         variant_index,
         index_error_map,
     ):
+        used_warehouses = defaultdict(list)
+        for stock in stock_global_id_to_instance_map.values():
+            warehouse_global_id = graphene.Node.to_global_id(
+                "Warehouse", stock.warehouse_id
+            )
+            used_warehouses[warehouse_global_id].append(stock.product_variant_id)
+
         if stocks_data := cleaned_input["stocks"].get("create"):
+            variant = cleaned_input["id"]
+            for stock_index, stock in enumerate(stocks_data):
+                if variant.id in used_warehouses.get(stock["warehouse"], {}):
+                    index_error_map[variant_index].append(
+                        ProductVariantBulkError(
+                            field="warehouse",
+                            path=f"stocks.create.{stock_index}.warehouse",
+                            message="Stock for provided warehouse already exists.",
+                            code=ProductVariantBulkErrorCode.STOCK_ALREADY_EXISTS.value,
+                            warehouses=[stock["warehouse"]],
+                        )
+                    )
+                    continue
+
             cleaned_input["stocks"]["create"] = ProductVariantBulkCreate.clean_stocks(
                 stocks_data,
                 warehouse_global_id_to_instance_map,
@@ -410,6 +431,23 @@ def clean_variant(
 
         return cleaned_input if cleaned_input else None
 
+    @classmethod
+    def _get_input_warehouses_ids(cls, variants_data):
+        warehouses_from_input = []
+        stocks = (
+            stock
+            for v in variants_data
+            if v.get("stocks", {}).get("create")
+            for stock in v["stocks"]["create"]
+        )
+        for stock in stocks:
+            try:
+                warehouse_id = graphene.Node.from_global_id(stock["warehouse"])[1]
+                warehouses_from_input.append(warehouse_id)
+            except UnicodeDecodeError:
+                continue
+        return warehouses_from_input
+
     @classmethod
     def clean_variants(
         cls,
@@ -422,10 +460,11 @@ def clean_variants(
         cleaned_inputs_map: dict = {}
         product_type = product.product_type
 
-        # fetch existing data required to validate inputs
         warehouse_global_id_to_instance_map = {
             graphene.Node.to_global_id("Warehouse", warehouse.id): warehouse
-            for warehouse in warehouse_models.Warehouse.objects.all()
+            for warehouse in warehouse_models.Warehouse.objects.filter(
+                id__in=cls._get_input_warehouses_ids(variants)
+            )
         }
         stock_global_id_to_instance_map = {
             graphene.Node.to_global_id("Stock", stock.id): stock
diff --git a/saleor/product/error_codes.py b/saleor/product/error_codes.py
--- a/saleor/product/error_codes.py
+++ b/saleor/product/error_codes.py
@@ -47,6 +47,7 @@ class ProductVariantBulkErrorCode(Enum):
     REQUIRED = "required"
     UNIQUE = "unique"
     PRODUCT_NOT_ASSIGNED_TO_CHANNEL = "product_not_assigned_to_channel"
+    STOCK_ALREADY_EXISTS = "stock_already_exists"
 
 
 class ProductBulkCreateErrorCode(Enum):


Test patch:
diff --git a/saleor/graphql/product/tests/mutations/test_product_variant_bulk_update.py b/saleor/graphql/product/tests/mutations/test_product_variant_bulk_update.py
--- a/saleor/graphql/product/tests/mutations/test_product_variant_bulk_update.py
+++ b/saleor/graphql/product/tests/mutations/test_product_variant_bulk_update.py
@@ -151,7 +151,6 @@ def test_product_variant_bulk_update_stocks(
     staff_api_client,
     variant_with_many_stocks,
     warehouse,
-    size_attribute,
     permission_manage_products,
     any_webhook,
     settings,
@@ -217,6 +216,53 @@ def test_product_variant_bulk_update_stocks(
     )
 
 
+def test_product_variant_bulk_update_create_already_existing_stock(
+    staff_api_client,
+    variant_with_many_stocks,
+    permission_manage_products,
+):
+    # given
+    variant = variant_with_many_stocks
+    product_id = graphene.Node.to_global_id("Product", variant.product_id)
+    variant_id = graphene.Node.to_global_id("ProductVariant", variant.pk)
+    stocks = variant.stocks.all()
+    assert len(stocks) == 2
+    stock_to_update = stocks[0]
+
+    warehouse_global_id = graphene.Node.to_global_id(
+        "Warehouse", stock_to_update.warehouse_id
+    )
+    variants = [
+        {
+            "id": variant_id,
+            "stocks": {
+                "create": [
+                    {"quantity": 999, "warehouse": warehouse_global_id},
+                ]
+            },
+        }
+    ]
+
+    variables = {"productId": product_id, "variants": variants}
+
+    # when
+    staff_api_client.user.user_permissions.add(permission_manage_products)
+    response = staff_api_client.post_graphql(
+        PRODUCT_VARIANT_BULK_UPDATE_MUTATION, variables
+    )
+    content = get_graphql_content(response)
+
+    data = content["data"]["productVariantBulkUpdate"]
+
+    # then
+    errors = data["results"][0]["errors"]
+    assert errors
+    assert errors[0]["field"] == "warehouse"
+    assert errors[0]["path"] == "stocks.create.0.warehouse"
+    assert errors[0]["code"] == ProductVariantBulkErrorCode.STOCK_ALREADY_EXISTS.name
+    assert errors[0]["warehouses"] == [warehouse_global_id]
+
+
 def test_product_variant_bulk_update_and_remove_stock(
     staff_api_client,
     variant_with_many_stocks,