Repository: saleor/saleor

Issue:
Bug: 'NoneType' object is not iterable when TransactionUpdateInput field is null
### What are you trying to achieve?

```json
{
  "query":"mutation TransactionUpdate($id: ID!, $transaction: TransactionUpdateInput) {
  transactionUpdate(id: $id, transaction: $transaction) {
    transaction {
      id
      actions
      externalUrl
      message
    }
    errors {
      field
      message
      code
    }
  }
}",
  "variables": {
    "id":"VHJhbnNhY3Rpb25JdGVtOjJiZmE5NDBhLWYzM2QtNGJkZC1hZmM3LWE1ZmM4MWZjM2Y0ZA==",
    "transaction": {
      "name":null,
      "message":"{\"payment_method\":\"cash\"}",
      "pspReference":null,
      "availableActions":null,
      "amountAuthorized":null,
      "amountCharged":null,
      "amountRefunded":null,
      "amountCanceled":null,
      "metadata":null,
      "privateMetadata":null,
      "externalUrl":null
    }
  },
  "operationName":"TransactionUpdate"
}
```

### Steps to reproduce the problem

Try to update a transaction, set some of the fields mentioned above to null. Not sure which, can't test that right now, but I know that's the issue like I had before #13694 


### What did you expect to happen?

It to not fail, those fields are described as optional in TransactionUpdateInput

### Logs

_No response_

### Environment

Saleor version: 3.19.0
OS and version: Linux, but saleor runs in docker containers



Discussion:
Thanks for reporting, we'll look into that.
@maarcingebala Can I work on this issue?


Resolving patch:
diff --git a/saleor/graphql/payment/mutations/transaction/transaction_create.py b/saleor/graphql/payment/mutations/transaction/transaction_create.py
--- a/saleor/graphql/payment/mutations/transaction/transaction_create.py
+++ b/saleor/graphql/payment/mutations/transaction/transaction_create.py
@@ -136,8 +136,10 @@ def validate_external_url(cls, external_url: Optional[str], error_code: str):
 
     @classmethod
     def validate_metadata_keys(  # type: ignore[override]
-        cls, metadata_list: list[dict], field_name, error_code
+        cls, metadata_list: Optional[list[dict]], field_name, error_code
     ):
+        if not metadata_list:
+            return
         if metadata_contains_empty_key(metadata_list):
             raise ValidationError(
                 {


Test patch:
diff --git a/saleor/graphql/payment/tests/mutations/test_transaction_create.py b/saleor/graphql/payment/tests/mutations/test_transaction_create.py
--- a/saleor/graphql/payment/tests/mutations/test_transaction_create.py
+++ b/saleor/graphql/payment/tests/mutations/test_transaction_create.py
@@ -195,6 +195,63 @@ def test_transaction_create_for_order_by_app(
     assert transaction.external_url == external_url
 
 
+def test_transaction_create_for_order_by_app_metadata_null_value(
+    order_with_lines, permission_manage_payments, app_api_client
+):
+    # given
+    name = "Credit Card"
+    psp_reference = "PSP reference - 123"
+    available_actions = [
+        TransactionActionEnum.CHARGE.name,
+        TransactionActionEnum.CHARGE.name,
+    ]
+    authorized_value = Decimal("10")
+    external_url = f"http://{TEST_SERVER_DOMAIN}/external-url"
+
+    variables = {
+        "id": graphene.Node.to_global_id("Order", order_with_lines.pk),
+        "transaction": {
+            "name": name,
+            "pspReference": psp_reference,
+            "availableActions": available_actions,
+            "amountAuthorized": {
+                "amount": authorized_value,
+                "currency": "USD",
+            },
+            "metadata": None,
+            "privateMetadata": None,
+            "externalUrl": external_url,
+        },
+    }
+
+    # when
+    response = app_api_client.post_graphql(
+        MUTATION_TRANSACTION_CREATE, variables, permissions=[permission_manage_payments]
+    )
+
+    # then
+    available_actions = list(set(available_actions))
+
+    transaction = order_with_lines.payment_transactions.first()
+    content = get_graphql_content(response)
+    data = content["data"]["transactionCreate"]["transaction"]
+    assert data["actions"] == available_actions
+    assert data["pspReference"] == psp_reference
+    assert data["authorizedAmount"]["amount"] == authorized_value
+    assert data["externalUrl"] == external_url
+    assert data["createdBy"]["id"] == to_global_id_or_none(app_api_client.app)
+
+    assert available_actions == list(map(str.upper, transaction.available_actions))
+    assert psp_reference == transaction.psp_reference
+    assert authorized_value == transaction.authorized_value
+    assert transaction.metadata == {}
+    assert transaction.private_metadata == {}
+    assert transaction.app_identifier == app_api_client.app.identifier
+    assert transaction.app == app_api_client.app
+    assert transaction.user is None
+    assert transaction.external_url == external_url
+
+
 def test_transaction_create_for_order_updates_order_total_authorized_by_app(
     order_with_lines, permission_manage_payments, app_api_client
 ):
@@ -351,6 +408,67 @@ def test_transaction_create_for_checkout_by_app(
     assert transaction.user is None
 
 
+def test_transaction_create_for_checkout_by_app_metadata_null_value(
+    checkout_with_items, permission_manage_payments, app_api_client
+):
+    # given
+    name = "Credit Card"
+    psp_reference = "PSP reference - 123"
+    available_actions = [
+        TransactionActionEnum.CHARGE.name,
+        TransactionActionEnum.CHARGE.name,
+    ]
+    authorized_value = Decimal("10")
+    external_url = f"http://{TEST_SERVER_DOMAIN}/external-url"
+
+    variables = {
+        "id": graphene.Node.to_global_id("Checkout", checkout_with_items.pk),
+        "transaction": {
+            "name": name,
+            "pspReference": psp_reference,
+            "availableActions": available_actions,
+            "amountAuthorized": {
+                "amount": authorized_value,
+                "currency": "USD",
+            },
+            "metadata": None,
+            "privateMetadata": None,
+            "externalUrl": external_url,
+        },
+    }
+
+    # when
+    response = app_api_client.post_graphql(
+        MUTATION_TRANSACTION_CREATE, variables, permissions=[permission_manage_payments]
+    )
+
+    # then
+    checkout_with_items.refresh_from_db()
+    assert checkout_with_items.charge_status == CheckoutChargeStatus.NONE
+    assert checkout_with_items.authorize_status == CheckoutAuthorizeStatus.PARTIAL
+
+    available_actions = list(set(available_actions))
+
+    transaction = checkout_with_items.payment_transactions.first()
+    content = get_graphql_content(response)
+    data = content["data"]["transactionCreate"]["transaction"]
+    assert data["actions"] == available_actions
+    assert data["pspReference"] == psp_reference
+    assert data["authorizedAmount"]["amount"] == authorized_value
+    assert data["externalUrl"] == external_url
+    assert data["createdBy"]["id"] == to_global_id_or_none(app_api_client.app)
+
+    assert available_actions == list(map(str.upper, transaction.available_actions))
+    assert psp_reference == transaction.psp_reference
+    assert authorized_value == transaction.authorized_value
+    assert transaction.metadata == {}
+    assert transaction.private_metadata == {}
+    assert transaction.external_url == external_url
+    assert transaction.app_identifier == app_api_client.app.identifier
+    assert transaction.app == app_api_client.app
+    assert transaction.user is None
+
+
 @pytest.mark.parametrize(
     ("amount_field_name", "amount_db_field"),
     [
diff --git a/saleor/graphql/payment/tests/mutations/test_transaction_update.py b/saleor/graphql/payment/tests/mutations/test_transaction_update.py
--- a/saleor/graphql/payment/tests/mutations/test_transaction_update.py
+++ b/saleor/graphql/payment/tests/mutations/test_transaction_update.py
@@ -241,6 +241,31 @@ def test_transaction_update_metadata_by_app(
     assert transaction_item_created_by_app.metadata == {meta_key: meta_value}
 
 
+def test_transaction_update_metadata_by_app_null_value(
+    transaction_item_created_by_app, permission_manage_payments, app_api_client
+):
+    # given
+    transaction = transaction_item_created_by_app
+
+    variables = {
+        "id": graphene.Node.to_global_id("TransactionItem", transaction.token),
+        "transaction": {
+            "metadata": None,
+        },
+    }
+
+    # when
+    response = app_api_client.post_graphql(
+        MUTATION_TRANSACTION_UPDATE, variables, permissions=[permission_manage_payments]
+    )
+
+    # then
+    transaction.refresh_from_db()
+    content = get_graphql_content(response)
+    data = content["data"]["transactionUpdate"]["transaction"]
+    assert len(data["metadata"]) == 0
+
+
 def test_transaction_update_metadata_incorrect_key_by_app(
     transaction_item_created_by_app, permission_manage_payments, app_api_client
 ):
@@ -300,6 +325,33 @@ def test_transaction_update_private_metadata_by_app(
     assert transaction_item_created_by_app.private_metadata == {meta_key: meta_value}
 
 
+def test_transaction_update_private_metadata_by_app_null_value(
+    transaction_item_created_by_app, permission_manage_payments, app_api_client
+):
+    # given
+    transaction = transaction_item_created_by_app
+    transaction.private_metadata = {"key": "value"}
+    transaction.save(update_fields=["private_metadata"])
+
+    variables = {
+        "id": graphene.Node.to_global_id("TransactionItem", transaction.token),
+        "transaction": {
+            "privateMetadata": None,
+        },
+    }
+
+    # when
+    response = app_api_client.post_graphql(
+        MUTATION_TRANSACTION_UPDATE, variables, permissions=[permission_manage_payments]
+    )
+
+    # then
+    transaction.refresh_from_db()
+    content = get_graphql_content(response)
+    data = content["data"]["transactionUpdate"]["transaction"]
+    assert len(data["privateMetadata"]) == 1
+
+
 def test_transaction_update_private_metadata_incorrect_key_by_app(
     transaction_item_created_by_app, permission_manage_payments, app_api_client
 ):