Repository: cal-itp/benefits

Issue:
Move fine-grained API-specific logic out of eligibility view
> This is a good start.
>
> I think this could be a little cleaner in the view, e.g. keeping the `api.py` module to wrap all the `Client` creation and types expansion etc.
> 
> But we can revisit later or in #501.

_Originally posted by @thekaveman in https://github.com/cal-itp/benefits/pull/606#pullrequestreview-983660165_


Discussion:
@angela-tran do you mind if I take a look at this one? I don't want to trample on anything you're doing with the refactor though so LMK. 
> @angela-tran do you mind if I take a look at this one? I don't want to trample on anything you're doing with the refactor though so LMK.

I don't mind at all - it's all yours! I am focusing on the unit tests in https://github.com/cal-itp/eligibility-api/pull/32, which shouldn't conflict with this one.

Resolving patch:
diff --git a/benefits/eligibility/api.py b/benefits/eligibility/api.py
new file mode 100644
--- /dev/null
+++ b/benefits/eligibility/api.py
@@ -0,0 +1,47 @@
+"""
+The eligibility application: Eligibility Verification API helpers.
+"""
+
+from django.conf import settings
+
+from eligibility_api.client import Client
+
+from benefits.core import session
+
+
+def get_verified_types(request, form):
+    """
+    Helper calls the eligibility verification API with user input.
+    Returns None and updates form with user input error(s).
+    Returns a list of verified eligibility types, or an empty list when no types were verified.
+    """
+
+    sub, name = form.cleaned_data.get("sub"), form.cleaned_data.get("name")
+
+    agency = session.agency(request)
+    verifier = session.verifier(request)
+
+    client = Client(
+        verify_url=verifier.api_url,
+        headers={verifier.api_auth_header: verifier.api_auth_key},
+        issuer=settings.ALLOWED_HOSTS[0],
+        agency=agency.agency_id,
+        jws_signing_alg=agency.jws_signing_alg,
+        client_private_key=agency.private_key_data,
+        jwe_encryption_alg=verifier.jwe_encryption_alg,
+        jwe_cek_enc=verifier.jwe_cek_enc,
+        server_public_key=verifier.public_key_data,
+    )
+
+    # get the eligibility type names to check
+    types = list(map(lambda t: t.name, agency.types_to_verify(verifier)))
+
+    response = client.verify(sub, name, types)
+
+    if response.error and any(response.error):
+        form.add_api_errors(response.error)
+        return None
+    elif any(response.eligibility):
+        return list(response.eligibility)
+    else:
+        return []
diff --git a/benefits/eligibility/views.py b/benefits/eligibility/views.py
--- a/benefits/eligibility/views.py
+++ b/benefits/eligibility/views.py
@@ -9,13 +9,11 @@
 from django.utils.decorators import decorator_from_middleware
 from django.utils.translation import pgettext, gettext as _
 
-from eligibility_api.client import Client
-
 from benefits.core import recaptcha, session, viewmodels
 from benefits.core.middleware import AgencySessionRequired, LoginRequired, RateLimit, VerifierSessionRequired
 from benefits.core.models import EligibilityVerifier
 from benefits.core.views import PageTemplateResponse
-from . import analytics, forms
+from . import analytics, api, forms
 
 
 @decorator_from_middleware(AgencySessionRequired)
@@ -144,6 +142,7 @@ def start(request):
 def confirm(request):
     """View handler for the eligibility verification form."""
 
+    template = "eligibility/confirm.html"
     verifier = session.verifier(request)
 
     page = viewmodels.Page(
@@ -154,63 +153,42 @@ def confirm(request):
         classes="text-lg-center",
     )
 
+    # POST form submission, process form data
     if request.method == "POST":
         analytics.started_eligibility(request)
 
         form = forms.EligibilityVerificationForm(data=request.POST, verifier=verifier)
-        response = _verify(request, form)
-
-        if response is None:
-            # form was not valid, allow for correction/resubmission
-            analytics.returned_error(request, form.errors)
-            page.forms = [form]
-            response = TemplateResponse(request, "eligibility/confirm.html", page.context_dict())
-    elif session.eligible(request):
-        eligibility = session.eligibility(request)
-        response = verified(request, [eligibility.name])
-    else:
-        response = TemplateResponse(request, "eligibility/confirm.html", page.context_dict())
-
-    return response
-
-
-def _verify(request, form):
-    """Helper calls the eligibility verification API with user input."""
 
-    if not form.is_valid():
-        if recaptcha.has_error(form):
-            messages.error(request, "Recaptcha failed. Please try again.")
-        return None
+        # form was not valid, allow for correction/resubmission
+        if not form.is_valid():
+            if recaptcha.has_error(form):
+                messages.error(request, "Recaptcha failed. Please try again.")
 
-    sub, name = form.cleaned_data.get("sub"), form.cleaned_data.get("name")
-
-    agency = session.agency(request)
-    verifier = session.verifier(request)
-
-    client = Client(
-        verify_url=verifier.api_url,
-        headers={verifier.api_auth_header: verifier.api_auth_key},
-        issuer=settings.ALLOWED_HOSTS[0],
-        agency=agency.agency_id,
-        jws_signing_alg=agency.jws_signing_alg,
-        client_private_key=agency.private_key_data,
-        jwe_encryption_alg=verifier.jwe_encryption_alg,
-        jwe_cek_enc=verifier.jwe_cek_enc,
-        server_public_key=verifier.public_key_data,
-    )
+            page.forms = [form]
+            return TemplateResponse(request, template, page.context_dict())
 
-    # get the eligibility type names
-    types = list(map(lambda t: t.name, agency.types_to_verify(verifier)))
+        # form is valid, make Eligibility Verification request to get the verified types
+        verified_types = api.get_verified_types(request, form)
 
-    response = client.verify(sub, name, types)
+        # form was not valid, allow for correction/resubmission
+        if verified_types is None:
+            analytics.returned_error(request, form.errors)
+            page.forms = [form]
+            return TemplateResponse(request, template, page.context_dict())
+        # no types were verified
+        elif len(verified_types) == 0:
+            return unverified(request)
+        # type(s) were verified
+        else:
+            return verified(request, verified_types)
 
-    if response.error and any(response.error):
-        form.add_api_errors(response.error)
-        return None
-    elif any(response.eligibility):
-        return verified(request, response.eligibility)
+    # GET from an already verified user, no need to verify again
+    elif session.eligible(request):
+        eligibility = session.eligibility(request)
+        return verified(request, [eligibility.name])
+    # GET from an unverified user, present the form
     else:
-        return unverified(request)
+        return TemplateResponse(request, template, page.context_dict())
 
 
 @decorator_from_middleware(AgencySessionRequired)


Test patch:
diff --git a/tests/pytest/conftest.py b/tests/pytest/conftest.py
--- a/tests/pytest/conftest.py
+++ b/tests/pytest/conftest.py
@@ -6,6 +6,7 @@
 from pytest_socket import disable_socket
 
 from benefits.core import session
+from benefits.core.models import TransitAgency
 
 
 def pytest_runtest_setup():
@@ -59,5 +60,23 @@ def initialize_request(request):
     session.reset(request)
 
 
+def set_agency(mocker):
+    agency = TransitAgency.objects.first()
+    assert agency
+    with_agency(mocker, agency)
+    return agency
+
+
+def set_verifier(mocker):
+    agency = set_agency(mocker)
+
+    mock = mocker.patch("benefits.core.session.verifier", autospec=True)
+    verifier = agency.eligibility_verifiers.first()
+    mocker.patch.object(verifier, "api_url", "http://localhost/verify")
+    assert verifier
+    mock.return_value = verifier
+    return (agency, verifier)
+
+
 def with_agency(mocker, agency):
     mocker.patch("benefits.core.session.agency", autospec=True, return_value=agency)
diff --git a/tests/pytest/eligibility/test_api.py b/tests/pytest/eligibility/test_api.py
new file mode 100644
--- /dev/null
+++ b/tests/pytest/eligibility/test_api.py
@@ -0,0 +1,60 @@
+import pytest
+
+from benefits.eligibility.api import get_verified_types
+from benefits.eligibility.forms import EligibilityVerificationForm
+
+from tests.pytest.conftest import set_verifier
+
+
+@pytest.fixture
+def form(mocker):
+    return mocker.Mock(spec=EligibilityVerificationForm, cleaned_data={"name": "Garcia", "sub": "A1234567"})
+
+
+def mock_api_client_verify(mocker, response):
+    return mocker.patch("benefits.eligibility.api.Client.verify", return_value=response)
+
+
+@pytest.mark.django_db
+def test_get_verified_types_error(mocker, app_request, form):
+    set_verifier(mocker)
+
+    api_errors = {"name": "Name error"}
+    api_response = mocker.Mock(error=api_errors)
+
+    mock_api_client_verify(mocker, api_response)
+
+    response = get_verified_types(app_request, form)
+
+    assert response is None
+    form.add_api_errors.assert_called_once_with(api_errors)
+
+
+@pytest.mark.django_db
+def test_get_verified_types_verified_types(mocker, app_request, form):
+    set_verifier(mocker)
+
+    verified_types = ["type1", "type2"]
+    api_response = mocker.Mock(eligibility=verified_types, error=None)
+
+    mock_api_client_verify(mocker, api_response)
+
+    response = get_verified_types(app_request, form)
+
+    assert response == verified_types
+    form.add_api_errors.assert_not_called()
+
+
+@pytest.mark.django_db
+def test_get_verified_types_no_verified_types(mocker, app_request, form):
+    set_verifier(mocker)
+
+    verified_types = []
+    api_response = mocker.Mock(eligibility=verified_types, error=None)
+
+    mock_api_client_verify(mocker, api_response)
+
+    response = get_verified_types(app_request, form)
+
+    assert response == verified_types
+    form.add_api_errors.assert_not_called()
diff --git a/tests/pytest/eligibility/test_views.py b/tests/pytest/eligibility/test_views.py
--- a/tests/pytest/eligibility/test_views.py
+++ b/tests/pytest/eligibility/test_views.py
@@ -1,38 +1,19 @@
-from django.urls import reverse
-import pytest
-import httpretty
-import requests
-
 import datetime
 import os
 import uuid
-
 from pathlib import Path
 
+from django.urls import reverse
+
+import pytest
+import httpretty
+import requests
+
 from benefits.core import session
-from benefits.core.models import TransitAgency
 from eligibility_api.client import ApiError, TokenError
 from eligibility_api.server import make_token
 from benefits.eligibility.views import confirm
-from tests.pytest.conftest import with_agency, initialize_request
-
-
-def set_agency(mocker):
-    agency = TransitAgency.objects.first()
-    assert agency
-    with_agency(mocker, agency)
-    return agency
-
-
-def set_verifier(mocker):
-    agency = set_agency(mocker)
-
-    mock = mocker.patch("benefits.core.session.verifier", autospec=True)
-    verifier = agency.eligibility_verifiers.first()
-    mocker.patch.object(verifier, "api_url", "http://localhost/verify")
-    assert verifier
-    mock.return_value = verifier
-    return (agency, verifier)
+from tests.pytest.conftest import initialize_request, set_agency, set_verifier, with_agency
 
 
 @pytest.mark.django_db