Repository: Adyen/adyen-python-api-library

Issue:
`TerminalsTerminalLevelApi.reassign_terminal` throws JSONDecodeError
**Describe the bug**

All calls to `TerminalsTerminalLevelApi.reassign_terminal` throw a JSONDecodeError

**To Reproduce**

```python
from Adyen import AdyenClient
from Adyen.services.management import TerminalsTerminalLevelApi


API_KEY = '<redacted>'
STORE_ID = 'ST3224Z223225T5JQTRDD7CRZ'
TERMINAL_ID = 'AMS1-000168223606144'


client = AdyenClient(xapikey=API_KEY)
api = TerminalsTerminalLevelApi(client=client)
api.reassign_terminal({
    'storeId': STORE_ID,
    'inventory': False,
}, TERMINAL_ID)
```

Output:

```
Traceback (most recent call last):
  File "/Users/luhn/Code/revenue/sandbox/adyentest.py", line 12, in <module>
    api.reassign_terminal({
  File "/Users/luhn/.pyenv/versions/revenue/lib/python3.10/site-packages/Adyen/services/management/terminals_terminal_level_api.py", line 30, in reassign_terminal
    return self.client.call_adyen_api(request, self.service, method, endpoint, idempotency_key, **kwargs)
  File "/Users/luhn/.pyenv/versions/revenue/lib/python3.10/site-packages/Adyen/client.py", line 369, in call_adyen_api
    adyen_result = self._handle_response(url, raw_response, raw_request,
  File "/Users/luhn/.pyenv/versions/revenue/lib/python3.10/site-packages/Adyen/client.py", line 435, in _handle_response
    response = json_lib.loads(raw_response)
  File "/Users/luhn/.pyenv/versions/3.10.1/lib/python3.10/json/__init__.py", line 346, in loads
    return _default_decoder.decode(s)
  File "/Users/luhn/.pyenv/versions/3.10.1/lib/python3.10/json/decoder.py", line 337, in decode
    obj, end = self.raw_decode(s, idx=_w(s, 0).end())
  File "/Users/luhn/.pyenv/versions/3.10.1/lib/python3.10/json/decoder.py", line 355, in raw_decode
    raise JSONDecodeError("Expecting value", s, err.value) from None
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
```

**Expected behavior**

No exception should be thrown.

**Screenshots**

N/A

**Desktop (please complete the following information):**
 - OS: Mac OS, Python 3.10
 - Browser: N/A
 - Version: 10.0.0

**Additional context**

According to [the docs](https://docs.adyen.com/api-explorer/Management/3/post/terminals/_terminalId_/reassign), reassigning a terminal returns HTTP 200 with no content.  My own testing confirms this:

```
curl -i https://management-test.adyen.com/v3/terminals/AMS1-000168223606144/reassign -d '{"storeId": "ST3224Z223225T5JQTRDD7CRZ", "inventory": false}' -H 'Content-Type: application/json' -H 'x-API-key: <redacted>'
HTTP/1.1 200
traceparent: 00-36fb314f5ca8069a20974823e9986efd-9f224b0d4601a27c-01
Set-Cookie: <redacted>
pspReference: GVTHZQPNN8JSTC82
requestid: GVTHZQPNN8JSTC82
Content-Type: application/json;charset=utf-8
Transfer-Encoding: chunked
Date: Mon, 13 Nov 2023 23:45:44 GMT

```

The SDK expects the body to be valid JSON, except for HTTP 204.

https://github.com/Adyen/adyen-python-api-library/blob/d6253f98202f4ef136d9859895e75a4c599bb1af/Adyen/client.py#L434-L437

Personally I think the SDK is right and the API is wrong—Especially since the API declares the response is JSON (`Content-Type: application/json;charset=utf-8`) yet does not return valid JSON.

Discussion:

Patch:
diff --git a/Adyen/__init__.py b/Adyen/__init__.py
index 712155e..3e9a8a8 100644
--- a/Adyen/__init__.py
+++ b/Adyen/__init__.py
@@ -1,5 +1,3 @@
-#!/bin/python
-
 from __future__ import absolute_import, division, unicode_literals
 
 from . import util
diff --git a/Adyen/client.py b/Adyen/client.py
index cd45b98..2e40e97 100644
--- a/Adyen/client.py
+++ b/Adyen/client.py
@@ -1,5 +1,3 @@
-#!/bin/python
-
 from __future__ import absolute_import, division, unicode_literals
 
 import json as json_lib
@@ -266,18 +264,18 @@ class AdyenClient(object):
 
     def _set_url_version(self, service, endpoint):
         version_lookup = {"binlookup": self.api_bin_lookup_version,
-                   "checkout": self.api_checkout_version,
-                   "management": self.api_management_version,
-                   "payments": self.api_payment_version,
-                   "payouts": self.api_payout_version,
-                   "recurring": self.api_recurring_version,
-                   "terminal": self.api_terminal_version,
-                   "legalEntityManagement": self.api_legal_entity_management_version,
-                   "dataProtection": self.api_data_protection_version,
-                   "transfers": self.api_transfers_version,
-                   "storedValue": self.api_stored_value_version,
-                   "balancePlatform": self.api_balance_platform_version,
-                   "disputes": self.api_disputes_version
+                          "checkout": self.api_checkout_version,
+                          "management": self.api_management_version,
+                          "payments": self.api_payment_version,
+                          "payouts": self.api_payout_version,
+                          "recurring": self.api_recurring_version,
+                          "terminal": self.api_terminal_version,
+                          "legalEntityManagement": self.api_legal_entity_management_version,
+                          "dataProtection": self.api_data_protection_version,
+                          "transfers": self.api_transfers_version,
+                          "storedValue": self.api_stored_value_version,
+                          "balancePlatform": self.api_balance_platform_version,
+                          "disputes": self.api_disputes_version
                           }
 
         new_version = f"v{version_lookup[service]}"
@@ -383,7 +381,7 @@ class AdyenClient(object):
     def _handle_response(self, url, raw_response, raw_request,
                          status_code, headers):
         """This parses the content from raw communication, raising an error if
-        anything other than 200 was returned.
+        anything other than 2xx was returned.
 
         Args:
             url (str): URL where request was made
@@ -391,58 +389,31 @@ class AdyenClient(object):
             raw_request (str): The raw response returned by Adyen
             status_code (int): The HTTP status code
             headers (dict): Key/Value of the headers.
-            request_dict (dict): The original request dictionary that was given
-                to the HTTPClient.
 
         Returns:
             AdyenResult: Result object if successful.
         """
 
-        if status_code not in [200, 201, 204]:
+        try:
+            response = json_lib.loads(raw_response)
+        except json_lib.JSONDecodeError:
             response = {}
-            # If the result can't be parsed into json, most likely is raw html.
-            # Some response are neither json or raw html, handle them here:
-            if raw_response:
-                response = json_lib.loads(raw_response)
-            # Pass raised error to error handler.
-            self._handle_http_error(url, response, status_code,
-                                    headers.get('pspReference'),
-                                    raw_request, raw_response,
-                                    headers)
-
-            try:
-                if response['errorCode']:
-                    raise AdyenAPICommunicationError(
-                        "Unexpected error while communicating with Adyen."
-                        " Received the response data:'{}', HTTP Code:'{}'. "
-                        "Please reach out to support@adyen.com if the "
-                        "problem persists with the psp:{}".format(
-                            raw_response,
-                            status_code,
-                            headers.get('pspReference')),
-                        status_code=status_code,
-                        raw_request=raw_request,
-                        raw_response=raw_response,
-                        url=url,
-                        psp=headers.get('pspReference'),
-                        headers=headers,
-                        error_code=response['errorCode'])
-            except KeyError:
-                erstr = 'KeyError: errorCode'
-                raise AdyenAPICommunicationError(erstr)
+
+        if status_code not in [200, 201, 202, 204]:
+            self._raise_http_error(url, response, status_code,
+                                   headers.get('pspReference'),
+                                   raw_request, raw_response,
+                                   headers)
         else:
-            if status_code != 204:
-                response = json_lib.loads(raw_response)
-            else:
-                response = {}
             psp = self._get_psp(response, headers)
             return AdyenResult(message=response, status_code=status_code,
                                psp=psp, raw_request=raw_request,
                                raw_response=raw_response)
 
-    def _handle_http_error(self, url, response_obj, status_code, psp_ref,
-                           raw_request, raw_response, headers):
-        """This function handles the non 200 responses from Adyen, raising an
+    @staticmethod
+    def _raise_http_error(url, response_obj, status_code, psp_ref,
+                          raw_request, raw_response, headers):
+        """This function handles the non 2xx responses from Adyen, raising an
         error that should provide more information.
 
         Args:
@@ -456,7 +427,7 @@ class AdyenClient(object):
             headers(dict): headers of the response
 
         Returns:
-            None
+            None: It never returns
         """
 
         if response_obj == {}:
@@ -484,9 +455,9 @@ class AdyenClient(object):
         elif status_code == 500:
             raise AdyenAPICommunicationError(message, raw_request, raw_response, url, psp_ref, headers, status_code,
                                              error_code)
-        else:
-            raise AdyenAPIResponseError(message, raw_request, raw_response, url, psp_ref, headers, status_code,
-                                        error_code)
+
+        raise AdyenAPIResponseError(message, raw_request, raw_response, url, psp_ref, headers, status_code,
+                                    error_code)
 
     @staticmethod
     def _get_psp(response, headers):
diff --git a/Adyen/httpclient.py b/Adyen/httpclient.py
index 954aba5..4b8d310 100644
--- a/Adyen/httpclient.py
+++ b/Adyen/httpclient.py
@@ -1,5 +1,3 @@
-#!/bin/python
-
 from __future__ import absolute_import, division, unicode_literals
 
 try:
@@ -49,7 +47,6 @@ class HTTPClient(object):
 
         self.timeout = timeout
 
-
     def _pycurl_request(
             self,
             method,


Test patch:
diff --git a/test/ConfigurationTest.py b/test/BalancePlatformTest.py
similarity index 87%
rename from test/ConfigurationTest.py
rename to test/BalancePlatformTest.py
index 3bbb9f0..fe29b68 100644
--- a/test/ConfigurationTest.py
+++ b/test/BalancePlatformTest.py
@@ -1,5 +1,6 @@
-import Adyen
 import unittest
+
+import Adyen
 from Adyen import settings
 
 try:
@@ -8,7 +9,7 @@ except ImportError:
     from .BaseTest import BaseTest
 
 
-class TestManagement(unittest.TestCase):
+class TestBalancePlatform(unittest.TestCase):
     adyen = Adyen.Adyen()
 
     client = adyen.client
@@ -117,3 +118,22 @@ class TestManagement(unittest.TestCase):
             json=None,
             xapikey="YourXapikey"
         )
+
+    def test_update_network_token(self):
+        request = {
+            "status": "closed"
+        }
+        self.adyen.client = self.test.create_client_from_file(202, request)
+
+        result = self.adyen.balancePlatform.network_tokens_api.update_network_token(request, 'TK123ABC')
+
+        self.assertEqual(202, result.status_code)
+        self.assertEqual({}, result.message)
+        self.assertEqual("", result.raw_response)
+        self.adyen.client.http_client.request.assert_called_once_with(
+            'PATCH',
+            f'{self.balance_platform_url}/networkTokens/TK123ABC',
+            headers={'adyen-library-name': 'adyen-python-api-library', 'adyen-library-version': settings.LIB_VERSION},
+            json=request,
+            xapikey="YourXapikey"
+        )
diff --git a/test/BaseTest.py b/test/BaseTest.py
index 08a97bc..c910acb 100644
--- a/test/BaseTest.py
+++ b/test/BaseTest.py
@@ -7,7 +7,7 @@ import json
 from Adyen import httpclient
 
 
-class BaseTest():
+class BaseTest:
     def __init__(self, adyen):
         self.ady = adyen
 
diff --git a/test/ManagementTest.py b/test/ManagementTest.py
index a5914b1..7e34681 100644
--- a/test/ManagementTest.py
+++ b/test/ManagementTest.py
@@ -19,18 +19,19 @@ class TestManagement(unittest.TestCase):
 
     def test_get_company_account(self):
         request = None
-        id = "YOUR_COMPANY_ACCOUNT"
+        company_id = "YOUR_COMPANY_ACCOUNT"
         self.adyen.client = self.test.create_client_from_file(200, request,
                                                               "test/mocks/"
                                                               "management/"
                                                               "get_company_account"
                                                               ".json")
 
-        result = self.adyen.management.account_company_level_api.get_company_account(companyId=id)
-        self.assertEqual(id, result.message['id'])
+        result = self.adyen.management.account_company_level_api.get_company_account(companyId=company_id)
+
+        self.assertEqual(company_id, result.message['id'])
         self.adyen.client.http_client.request.assert_called_once_with(
             'GET',
-            f'{self.management_url}/companies/{id}',
+            f'{self.management_url}/companies/{company_id}',
             headers={'adyen-library-name': 'adyen-python-api-library', 'adyen-library-version': settings.LIB_VERSION},
             json=None,
             xapikey="YourXapikey"
@@ -43,23 +44,29 @@ class TestManagement(unittest.TestCase):
                                                               "management/"
                                                               "post_me_allowed"
                                                               "_origins.json")
+
         result = self.adyen.management.my_api_credential_api.add_allowed_origin(request)
-        originId = result.message['id']
+
         self.assertEqual("YOUR_DOMAIN", result.message['domain'])
+
+    def test_no_content(self):
         self.adyen.client = self.test.create_client_from_file(204, {},
                                                               "test/mocks/"
                                                               "management/"
                                                               "no_content.json")
-        result = self.adyen.management.my_api_credential_api.remove_allowed_origin(originId)
+        origin_id = 'YOUR_DOMAIN_ID'
+
+        self.adyen.management.my_api_credential_api.remove_allowed_origin(origin_id)
+
         self.adyen.client.http_client.request.assert_called_once_with(
             'DELETE',
-            f'{self.management_url}/me/allowedOrigins/{originId}',
+            f'{self.management_url}/me/allowedOrigins/{origin_id}',
             headers={'adyen-library-name': 'adyen-python-api-library', 'adyen-library-version': settings.LIB_VERSION},
             json=None,
             xapikey="YourXapikey"
         )
 
-    def test_update_a_store(self):
+    def test_update_store(self):
         request = {
             "address": {
                 "line1": "1776 West Pinewood Avenue",
@@ -73,19 +80,34 @@ class TestManagement(unittest.TestCase):
                                                               "management/"
                                                               "update_a_store"
                                                               ".json")
-        storeId = "YOUR_STORE_ID"
-        merchantId = "YOUR_MERCHANT_ACCOUNT_ID"
-        result = self.adyen.management.account_store_level_api.update_store(request, merchantId, storeId)
+        store_id = "YOUR_STORE_ID"
+        merchant_id = "YOUR_MERCHANT_ACCOUNT_ID"
+
+        result = self.adyen.management.account_store_level_api.update_store(request, merchant_id, store_id)
+
         self.adyen.client.http_client.request.assert_called_once_with(
             'PATCH',
-            f'{self.management_url}/merchants/{merchantId}/stores/{storeId}',
+            f'{self.management_url}/merchants/{merchant_id}/stores/{store_id}',
             headers={'adyen-library-name': 'adyen-python-api-library', 'adyen-library-version': settings.LIB_VERSION},
             json=request,
             xapikey="YourXapikey"
         )
-        self.assertEqual(storeId, result.message['id'])
+        self.assertEqual(store_id, result.message['id'])
         self.assertEqual("1776 West Pinewood Avenue", result.message['address']['line1'])
 
+    def test_reassign_terminal(self):
+        request = {
+            'storeId': 'ST123ABC',
+            'inventory': False,
+        }
+        self.adyen.client = self.test.create_client_from_file(200, request)
+
+        result = self.adyen.management.terminals_terminal_level_api.reassign_terminal(request, 'AMS1-2345')
+
+        self.assertEqual(200, result.status_code)
+        self.assertEqual({}, result.message)
+        self.assertEqual("", result.raw_response)
+
     def test_create_a_user(self):
         request = {
             "name": {
@@ -108,12 +130,14 @@ class TestManagement(unittest.TestCase):
                                                               "management/"
                                                               "create_a_user"
                                                               ".json")
-        companyId = "YOUR_COMPANY_ACCOUNT"
-        result = self.adyen.management.users_company_level_api.create_new_user(request, companyId)
+        company_id = "YOUR_COMPANY_ACCOUNT"
+
+        result = self.adyen.management.users_company_level_api.create_new_user(request, company_id)
+
         self.assertEqual(request['name']['firstName'], result.message['name']['firstName'])
         self.adyen.client.http_client.request.assert_called_once_with(
             'POST',
-            f'{self.management_url}/companies/{companyId}/users',
+            f'{self.management_url}/companies/{company_id}/users',
             json=request,
             headers={'adyen-library-name': 'adyen-python-api-library', 'adyen-library-version': settings.LIB_VERSION},
             xapikey="YourXapikey"
@@ -127,13 +151,15 @@ class TestManagement(unittest.TestCase):
                                                               "get_list_of"
                                                               "_android_apps"
                                                               ".json")
-        companyId = "YOUR_COMPANY_ACCOUNT"
-        result = self.adyen.management.android_files_company_level_api.list_android_apps(companyId)
+        company_id = "YOUR_COMPANY_ACCOUNT"
+
+        result = self.adyen.management.android_files_company_level_api.list_android_apps(company_id)
+
         self.assertEqual("ANDA422LZ223223K5F694GCCF732K8", result.message['androidApps'][0]['id'])
 
-    def test_query_paramaters(self):
+    def test_query_parameters(self):
         request = {}
-        companyId = "YOUR_COMPANY_ACCOUNT"
+        company_id = "YOUR_COMPANY_ACCOUNT"
         query_parameters = {
             'pageNumber': 1,
             'pageSize': 10
@@ -143,11 +169,13 @@ class TestManagement(unittest.TestCase):
                                                               "test/mocks/"
                                                               "management/"
                                                               "get_list_of_merchant_accounts.json")
-        result = self.adyen.management.account_company_level_api. \
-            list_merchant_accounts(companyId, query_parameters=query_parameters)
+
+        self.adyen.management.account_company_level_api. \
+            list_merchant_accounts(company_id, query_parameters=query_parameters)
+
         self.adyen.client.http_client.request.assert_called_once_with(
             'GET',
-            f'{self.management_url}/companies/{companyId}/merchants?pageNumber=1&pageSize=10',
+            f'{self.management_url}/companies/{company_id}/merchants?pageNumber=1&pageSize=10',
             headers={'adyen-library-name': 'adyen-python-api-library', 'adyen-library-version': settings.LIB_VERSION},
             json=None,
             xapikey="YourXapikey"