Repository: cal-itp/benefits

Issue:
Add tests for enrollment API
Broken out from https://github.com/cal-itp/benefits/issues/413.


Discussion:

Resolving patch:
diff --git a/benefits/enrollment/api.py b/benefits/enrollment/api.py
--- a/benefits/enrollment/api.py
+++ b/benefits/enrollment/api.py
@@ -79,13 +79,14 @@ def __init__(self, response, requested_id, payload=None):
                 detail = error["detail"]
 
                 failure = (
-                    customer_id is None,
-                    detail is None,
-                    customer_id not in detail,
-                    customer_id in detail and not detail.startswith("Duplicate"),
+                    customer_id is None
+                    or detail is None
+                    or customer_id not in detail
+                    or customer_id in detail
+                    and not detail.startswith("Duplicate")
                 )
 
-                if any(failure):
+                if failure:
                     raise ApiError("Invalid response format")
             except (KeyError, ValueError):
                 raise ApiError("Invalid response format")
@@ -166,17 +167,17 @@ def _get_customer(self, token):
 
         try:
             r = self._get(url, payload)
-            if r.status_code == 200:
-                logger.debug("Customer record exists")
-                customer = CustomerResponse(r)
-                if customer.is_registered:
-                    logger.debug("Customer is registered, skip update")
-                    return customer
-                else:
-                    logger.debug("Customer is not registered, update")
-                    return self._update_customer(customer.id)
+            r.raise_for_status()
+
+            logger.debug("Customer record exists")
+            customer = CustomerResponse(r)
+            if customer.is_registered:
+                logger.debug("Customer is registered, skip update")
+                return customer
             else:
-                r.raise_for_status()
+                logger.debug("Customer is not registered, update")
+                return self._update_customer(customer.id)
+
         except requests.ConnectionError:
             raise ApiError("Connection to enrollment server failed")
         except requests.Timeout:


Test patch:
diff --git a/tests/pytest/enrollment/test_api_AccessTokenResponse.py b/tests/pytest/enrollment/test_api_AccessTokenResponse.py
new file mode 100644
--- /dev/null
+++ b/tests/pytest/enrollment/test_api_AccessTokenResponse.py
@@ -0,0 +1,40 @@
+import time
+
+import requests
+
+import pytest
+
+from benefits.enrollment.api import ApiError, AccessTokenResponse
+
+
+REQUESTS_ERRORS = [requests.ConnectionError, requests.Timeout, requests.TooManyRedirects, requests.HTTPError]
+
+
+def test_invalid_response(mocker):
+    mock_response = mocker.Mock()
+    mock_response.json.side_effect = ValueError
+
+    with pytest.raises(ApiError, match=r"response"):
+        AccessTokenResponse(mock_response)
+
+
+def test_valid_response(mocker):
+    mock_response = mocker.Mock()
+    mock_response.json.return_value = {"access_token": "access123", "token_type": "mock"}
+
+    response = AccessTokenResponse(mock_response)
+
+    assert response.access_token == "access123"
+    assert response.token_type == "mock"
+    assert response.expiry is None
+
+
+def test_valid_response_expires_in(mocker):
+    expires_in = 100
+    mock_response = mocker.Mock()
+    mock_response.json.return_value = {"expires_in": expires_in}
+
+    start = time.time()
+    response = AccessTokenResponse(mock_response)
+
+    assert response.expiry >= start + expires_in
diff --git a/tests/pytest/enrollment/test_api_Client.py b/tests/pytest/enrollment/test_api_Client.py
new file mode 100644
--- /dev/null
+++ b/tests/pytest/enrollment/test_api_Client.py
@@ -0,0 +1,232 @@
+import requests
+
+import pytest
+
+from benefits.enrollment.api import ApiError, Client, CustomerResponse, GroupResponse
+
+
+REQUESTS_ERRORS = [requests.ConnectionError, requests.Timeout, requests.TooManyRedirects, requests.HTTPError]
+
+
+@pytest.fixture
+def api_client(first_agency):
+    return Client(first_agency)
+
+
+@pytest.fixture
+def mocked_customer(mocker):
+    mock = mocker.Mock(spec=CustomerResponse)
+    mocker.patch("benefits.enrollment.api.CustomerResponse", return_value=mock)
+    return mock
+
+
+@pytest.fixture
+def mocked_group(mocker):
+    mock = mocker.Mock(spec=GroupResponse)
+    mocker.patch("benefits.enrollment.api.GroupResponse", return_value=mock)
+    return mock
+
+
+def test_init_no_agency():
+    with pytest.raises(ValueError, match=r"agency"):
+        Client(None)
+
+
+def test_init_no_payment_processor(mocker):
+    mock_agency = mocker.Mock()
+    mock_agency.payment_processor = None
+
+    with pytest.raises(ValueError, match=r"payment_processor"):
+        Client(mock_agency)
+
+
+def test_init(mocker):
+    mock_agency = mocker.Mock()
+    mock_agency.payment_processor = mocker.Mock()
+
+    client = Client(mock_agency)
+
+    assert client.agency == mock_agency
+    assert client.payment_processor == mock_agency.payment_processor
+    assert isinstance(client.headers, dict)
+
+
+@pytest.mark.django_db
+def test_headers_none(api_client):
+    headers = api_client._headers()
+
+    assert headers == api_client.headers
+
+
+@pytest.mark.django_db
+def test_headers(api_client):
+    headers = api_client._headers({"header": "value"})
+
+    assert "header" in headers
+    assert headers["header"] == "value"
+
+
+@pytest.mark.django_db
+def test_make_url(api_client):
+    part1, part2, part3 = "part1", "part2", "part3"
+    agency = api_client.agency
+
+    url = api_client._make_url(part1, part2, part3)
+
+    assert agency.payment_processor.api_base_url in url
+    assert agency.merchant_id in url
+    assert part1 in url
+    assert part2 in url
+    assert part3 in url
+
+
+@pytest.mark.django_db
+def test_cert_request(mocker, api_client):
+    temp_file = mocker.patch("benefits.enrollment.api.NamedTemporaryFile")
+    request_func = mocker.Mock()
+
+    api_client._cert_request(request_func)
+
+    temp_file.assert_called()
+    request_func.assert_called_once()
+    assert "verify" in request_func.call_args.kwargs
+    assert "cert" in request_func.call_args.kwargs
+
+
+@pytest.mark.django_db
+def test_get_customer_no_token(api_client):
+    with pytest.raises(ValueError, match=r"token"):
+        api_client._get_customer(None)
+
+
+@pytest.mark.django_db
+def test_get_customer_status_not_OK(mocker, api_client):
+    mock_response = mocker.Mock()
+    mock_response.raise_for_status.side_effect = requests.HTTPError()
+    mocker.patch.object(api_client, "_get", return_value=mock_response)
+
+    with pytest.raises(ApiError):
+        api_client._get_customer("token")
+
+
+@pytest.mark.django_db
+def test_get_customer_is_registered(mocker, api_client, mocked_customer):
+    mock_get_response = mocker.Mock()
+    mocker.patch.object(api_client, "_get", return_value=mock_get_response)
+    mocked_customer.is_registered = True
+
+    return_customer = api_client._get_customer("token")
+
+    assert return_customer == mocked_customer
+    assert return_customer.is_registered
+
+
+@pytest.mark.django_db
+def test_get_customer_is_not_registered(mocker, api_client, mocked_customer):
+    mock_get_response = mocker.Mock()
+    mocker.patch.object(api_client, "_get", return_value=mock_get_response)
+    mocked_customer.is_registered = False
+    mocked_customer.id = "id"
+
+    update_spy = mocker.patch("benefits.enrollment.api.Client._update_customer")
+
+    api_client._get_customer("token")
+
+    update_spy.assert_called_once_with(mocked_customer.id)
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize("exception", REQUESTS_ERRORS)
+def test_get_customer_exception(mocker, api_client, exception):
+    mocker.patch.object(api_client, "_get", side_effect=exception)
+
+    with pytest.raises(ApiError):
+        api_client._get_customer("token")
+
+
+@pytest.mark.django_db
+def test_update_customer_no_customer_id(api_client):
+    with pytest.raises(ValueError):
+        api_client._update_customer(None)
+
+
+@pytest.mark.django_db
+def test_update_customer(mocker, api_client, mocked_customer):
+    mock_response = mocker.Mock()
+    mocker.patch.object(api_client, "_patch", return_value=mock_response)
+
+    updated_customer = api_client._update_customer("id")
+
+    assert updated_customer == mocked_customer
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize("exception", REQUESTS_ERRORS)
+def test_access_token_exception(mocker, api_client, exception):
+    mock_response = mocker.Mock()
+    mock_response.raise_for_status.side_effect = exception
+    mocker.patch.object(api_client, "_post", return_value=mock_response)
+
+    with pytest.raises(ApiError):
+        api_client.access_token()
+
+
+@pytest.mark.django_db
+def test_access_token(mocker, api_client):
+    mock_response = mocker.Mock()
+    mocker.patch.object(api_client, "_post", return_value=mock_response)
+    mocker.patch("benefits.enrollment.api.AccessTokenResponse")
+
+    token = api_client.access_token()
+
+    assert token
+
+
+@pytest.mark.django_db
+def test_enroll_no_customer_token(api_client):
+    with pytest.raises(ValueError, match=r"customer_token"):
+        api_client.enroll(None, "group_id")
+
+
+@pytest.mark.django_db
+def test_enroll_no_group_id(api_client):
+    with pytest.raises(ValueError, match=r"group_id"):
+        api_client.enroll("customer_token", None)
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize("exception", REQUESTS_ERRORS)
+def test_enroll_exception(mocker, api_client, exception):
+    mock_response = mocker.Mock()
+    mock_response.raise_for_status.side_effect = exception
+    mocker.patch.object(api_client, "_patch", return_value=mock_response)
+    mocker.patch.object(api_client, "_get_customer", return_value=mocker.Mock(id="customer_id"))
+
+    with pytest.raises(ApiError):
+        api_client.enroll("token", "group")
+
+
+@pytest.mark.django_db
+@pytest.mark.usefixtures("mocked_group")
+@pytest.mark.parametrize("status_code", [200, 201])
+def test_enroll_customer_enrolled(mocker, api_client, status_code):
+    mock_response = mocker.Mock(status_code=status_code)
+    mocker.patch.object(api_client, "_patch", return_value=mock_response)
+    mocker.patch.object(api_client, "_get_customer", return_value=mocker.Mock(id="customer_id"))
+
+    response = api_client.enroll("customer_id", "group_id")
+
+    assert isinstance(response, GroupResponse)
+
+
+@pytest.mark.django_db
+@pytest.mark.usefixtures("mocked_group")
+def test_enroll_customer_exists(mocker, api_client):
+    # the enrollment API uses a 500 response code (!!!) to indicate the customer already exists
+    mock_response = mocker.Mock(status_code=500)
+    mocker.patch.object(api_client, "_patch", return_value=mock_response)
+    mocker.patch.object(api_client, "_get_customer", return_value=mocker.Mock(id="customer_id"))
+
+    response = api_client.enroll("customer_id", "group_id")
+
+    assert isinstance(response, GroupResponse)
diff --git a/tests/pytest/enrollment/test_api_CustomerResponse.py b/tests/pytest/enrollment/test_api_CustomerResponse.py
new file mode 100644
--- /dev/null
+++ b/tests/pytest/enrollment/test_api_CustomerResponse.py
@@ -0,0 +1,51 @@
+import pytest
+
+from benefits.enrollment.api import ApiError, CustomerResponse
+
+
+@pytest.mark.parametrize("exception", [KeyError, ValueError])
+def test_invalid_response(mocker, exception):
+    mock_response = mocker.Mock()
+    mock_response.json.side_effect = exception
+
+    with pytest.raises(ApiError, match=r"response"):
+        CustomerResponse(mock_response)
+
+
+def test_no_id(mocker):
+    mock_response = mocker.Mock()
+    mock_response.json.return_value = {"id": None}
+
+    with pytest.raises(ApiError, match=r"response"):
+        CustomerResponse(mock_response)
+
+
+def test_is_registered_default(mocker):
+    id = "12345"
+    mock_response = mocker.Mock()
+    mock_response.json.return_value = {"id": id}
+
+    response = CustomerResponse(mock_response)
+
+    assert response.id == id
+    assert not response.is_registered
+
+
+@pytest.mark.parametrize("is_registered", ["true", "True", "tRuE"])
+def test_is_registered(mocker, is_registered):
+    mock_response = mocker.Mock()
+    mock_response.json.return_value = {"id": "12345", "is_registered": is_registered}
+
+    response = CustomerResponse(mock_response)
+
+    assert response.is_registered
+
+
+@pytest.mark.parametrize("is_registered", ["false", "Frue", "fAlSe"])
+def test_is_not_registered(mocker, is_registered):
+    mock_response = mocker.Mock()
+    mock_response.json.return_value = {"id": "12345", "is_registered": is_registered}
+
+    response = CustomerResponse(mock_response)
+
+    assert not response.is_registered
diff --git a/tests/pytest/enrollment/test_api_GroupResponse.py b/tests/pytest/enrollment/test_api_GroupResponse.py
new file mode 100644
--- /dev/null
+++ b/tests/pytest/enrollment/test_api_GroupResponse.py
@@ -0,0 +1,89 @@
+import pytest
+
+from benefits.enrollment.api import ApiError, GroupResponse
+
+
+def test_no_payload_invalid_response(mocker):
+    mock_response = mocker.Mock()
+    mock_response.json.side_effect = ValueError
+
+    with pytest.raises(ApiError, match=r"response"):
+        GroupResponse(mock_response, 0)
+
+
+def test_no_payload_valid_response_single_matching_id(mocker):
+    mock_response = mocker.Mock()
+    mock_response.json.return_value = ["0"]
+
+    response = GroupResponse(mock_response, "0")
+
+    assert response.customer_ids == ["0"]
+    assert response.updated_customer_id == "0"
+    assert response.success
+    assert response.message == ""
+
+
+def test_no_payload_valid_response_single_unmatching_id(mocker):
+    mock_response = mocker.Mock()
+    mock_response.json.return_value = ["1"]
+
+    response = GroupResponse(mock_response, "0")
+
+    assert response.customer_ids == ["1"]
+    assert response.updated_customer_id == "1"
+    assert not response.success
+    assert "customer_id" in response.message
+
+
+def test_no_payload_valid_response_multiple_ids(mocker):
+    mock_response = mocker.Mock()
+    mock_response.json.return_value = ["0", "1"]
+
+    response = GroupResponse(mock_response, "0")
+
+    assert response.customer_ids == ["0", "1"]
+    assert not response.updated_customer_id
+    assert not response.success
+    assert "customer_id" in response.message
+
+
+@pytest.mark.parametrize("exception", [KeyError, ValueError])
+def test_payload_invalid_response(mocker, exception):
+    mock_response = mocker.Mock()
+    mock_response.json.side_effect = exception
+
+    with pytest.raises(ApiError, match=r"response"):
+        GroupResponse(mock_response, "0", [])
+
+
+def test_payload_valid_response(mocker):
+    mock_response = mocker.Mock()
+    mock_response.json.return_value = {"errors": [{"detail": "Duplicate id 0"}]}
+
+    response = GroupResponse(mock_response, "0", ["0"])
+
+    assert response.customer_ids == ["0"]
+    assert response.updated_customer_id == "0"
+    assert response.success
+    assert response.message == ""
+
+
+failure_conditions = [
+    # customer_id is None
+    ({"detail": "Duplicate"}, [None]),
+    # detail is None
+    ({"detail": None}, ["0"]),
+    # customer_id not in detail
+    ({"detail": "1"}, ["0"]),
+    # customer_id in detail, detail doesn't start with Duplicate
+    ({"detail": "0"}, ["0"]),
+]
+
+
+@pytest.mark.parametrize("error,payload", failure_conditions)
+def test_payload_failure_response(mocker, error, payload):
+    mock_response = mocker.Mock()
+    mock_response.json.return_value = {"errors": [error]}
+
+    with pytest.raises(ApiError, match=r"response"):
+        GroupResponse(mock_response, "0", payload)