Skip to content

Commit

Permalink
Fully test error handling code paths. Bump to v0.0.14.
Browse files Browse the repository at this point in the history
  • Loading branch information
davepeck committed Sep 21, 2024
1 parent 0d9dd2e commit 14c35e1
Show file tree
Hide file tree
Showing 5 changed files with 174 additions and 54 deletions.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[project]
description = "Tools for online voter registration in the United States of America"
name = "voter-tools"
version = "0.0.12"
version = "0.0.14"
readme = "README.md"
requires-python = ">=3.11"
authors = [{ name = "Dave Peck", email = "dave@frontseat.org" }]
Expand Down
114 changes: 114 additions & 0 deletions tests/pa/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
import pathlib
from unittest import TestCase

import httpx
from PIL import Image

from voter_tools.pa import client as c
from voter_tools.pa.errors import APIValidationError, InvalidAccessKeyError


class PAResponseDateTestCase(TestCase):
Expand Down Expand Up @@ -752,3 +754,115 @@ def test_valid_mailin(self):
)
except ValueError:
self.fail()


class ClientTestCase(TestCase):
def _valid_application(self) -> c.VoterApplication:
record = c.VoterApplicationRecord(
first_name="Test",
last_name="Applicant1",
is_us_citizen=True,
will_be_18=True,
political_party=c.PoliticalPartyChoice.DEMOCRATIC,
gender=c.GenderChoice.MALE,
email="test.applicant1@example.com",
birth_date=datetime.date(1980, 1, 1),
registration_kind=c.RegistrationKind.NEW,
confirm_declaration=True,
address="123 Main St",
city="Philadelphia",
zip5="19127",
drivers_license="12345678",
)
return c.VoterApplication(record=record)

def _client(self, handler):
return c.PennsylvaniaAPIClient(
api_url="http://test",
api_key="test",
_transport=httpx.MockTransport(handler),
)

def test_mock_success(self):
"""Test submitting the simplest possible application."""

def handler(request: httpx.Request):
xml_response = (
"""<RESPONSE><APPLICATIONID>good</APPLICATIONID></RESPONSE>"""
)
# Yes, it's super weird to set json=xml_response (a string!)
# but, uh, that's what the PA API endpoint actually does.
return httpx.Response(200, json=xml_response)

application = self._valid_application()
client = self._client(handler)
response = client.set_application(application)
self.assertFalse(response.has_error())

def test_empty_failure(self):
"""Test behavior when API returns an empty response."""

# Unfortunately, this *does* happen. Inexplicably, at least in
# the sandbox.
def handler(request: httpx.Request):
xml_response = """<RESPONSE></RESPONSE>"""
return httpx.Response(200, json=xml_response)

application = self._valid_application()
client = self._client(handler)
with self.assertRaises(APIValidationError) as ctx:
_ = client.set_application(application)
self.assertEqual(ctx.exception.errors()[0].type, "unexpected")

def test_validation_error(self):
"""Test behavior when API returns a validation error response."""

def handler(request: httpx.Request):
xml_response = """
<RESPONSE>
<APPLICATIONID>123</APPLICATIONID>
<ERROR>VR_WAPI_InvalidOVRDL</ERROR>
</RESPONSE>
"""
return httpx.Response(200, json=xml_response)

application = self._valid_application()
client = self._client(handler)
with self.assertRaises(APIValidationError) as ctx:
_ = client.set_application(application)
self.assertEqual(ctx.exception.errors()[0].loc[0], "drivers_license")

def test_validation_errors(self):
"""Test behavior when API returns multiple validation errors."""

def handler(request: httpx.Request):
xml_response = """
<RESPONSE>
<ERROR>VR_WAPI_InvalidOVRDL</ERROR>
<ERROR>VR_WAPI_InvalidOVRDOB</ERROR>
</RESPONSE>
"""
return httpx.Response(200, json=xml_response)

application = self._valid_application()
client = self._client(handler)
with self.assertRaises(APIValidationError) as ctx:
_ = client.set_application(application)
self.assertEqual(ctx.exception.errors()[0].loc[0], "drivers_license")
self.assertEqual(ctx.exception.errors()[1].loc[0], "birth_date")

def test_invalid_access_key_error(self):
"""Test behavior when API returns an invalid API key error."""

def handler(request: httpx.Request):
xml_response = """
<RESPONSE>
<ERROR>VR_WAPI_InvalidAccessKey</ERROR>
</RESPONSE>
"""
return httpx.Response(200, json=xml_response)

application = self._valid_application()
client = self._client(handler)
with self.assertRaises(InvalidAccessKeyError):
_ = client.set_application(application)
99 changes: 51 additions & 48 deletions tests/pa/test_live.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import datetime
import os
import pathlib
import unittest

from PIL import Image

from voter_tools.pa import client as c

PA_API_LIVE_TESTS = os.getenv("PA_API_LIVE_TESTS") == "True"
Expand Down Expand Up @@ -42,55 +45,55 @@ def test_simple(self):
# print(response)
self.assertIsNone(response.error_code)

# def test_ssn4(self):
# """Test submitting an application with SSN4, not a driver's license."""
# record = c.VoterApplicationRecord(
# first_name="Test",
# last_name="Applicant1",
# is_us_citizen=True,
# will_be_18=True,
# political_party=c.PoliticalPartyChoice.DEMOCRATIC,
# gender=c.GenderChoice.MALE,
# email="test.applicant1@example.com",
# birth_date=datetime.date(1980, 1, 1),
# registration_kind=c.RegistrationKind.NEW,
# confirm_declaration=True,
# address="123 Main St",
# city="Philadelphia",
# zip5="19127",
# ssn4="1234",
# )
# application = c.VoterApplication(record=record)
def test_ssn4(self):
"""Test submitting an application with SSN4, not a driver's license."""
record = c.VoterApplicationRecord(
first_name="Test",
last_name="Applicant1",
is_us_citizen=True,
will_be_18=True,
political_party=c.PoliticalPartyChoice.DEMOCRATIC,
gender=c.GenderChoice.MALE,
email="test.applicant1@example.com",
birth_date=datetime.date(1980, 1, 1),
registration_kind=c.RegistrationKind.NEW,
confirm_declaration=True,
address="123 Main St",
city="Philadelphia",
zip5="19127",
ssn4="1234",
)
application = c.VoterApplication(record=record)

# client = c.PennsylvaniaAPIClient.staging(_pa_api_key(), timeout=100.0)
# response = client.set_application(application)
# self.assertIsNone(response.error_code)
client = c.PennsylvaniaAPIClient.staging(_pa_api_key(), timeout=100.0)
response = client.set_application(application)
self.assertIsNone(response.error_code)

# TEST_SIGNATURE_PATH = pathlib.Path(__file__).parent / "test_signature.png"
TEST_SIGNATURE_PATH = pathlib.Path(__file__).parent / "test_signature.png"

# def test_signature_image(self):
# """Test submitting an application with a signature image."""
# signature_img = Image.open(self.TEST_SIGNATURE_PATH)
# record = c.VoterApplicationRecord(
# first_name="Test",
# last_name="Applicant1",
# is_us_citizen=True,
# will_be_18=True,
# political_party=c.PoliticalPartyChoice.DEMOCRATIC,
# gender=c.GenderChoice.MALE,
# email="test.applicant1@example.com",
# birth_date=datetime.date(1980, 1, 1),
# registration_kind=c.RegistrationKind.NEW,
# confirm_declaration=True,
# address="123 Main St",
# city="Philadelphia",
# zip5="19127",
# signature=c.validate_signature_image(signature_img),
# )
# application = c.VoterApplication(record=record)
def test_signature_image(self):
"""Test submitting an application with a signature image."""
signature_img = Image.open(self.TEST_SIGNATURE_PATH)
record = c.VoterApplicationRecord(
first_name="Test",
last_name="Applicant1",
is_us_citizen=True,
will_be_18=True,
political_party=c.PoliticalPartyChoice.DEMOCRATIC,
gender=c.GenderChoice.MALE,
email="test.applicant1@example.com",
birth_date=datetime.date(1980, 1, 1),
registration_kind=c.RegistrationKind.NEW,
confirm_declaration=True,
address="123 Main St",
city="Philadelphia",
zip5="19127",
signature=c.validate_signature_image(signature_img),
)
application = c.VoterApplication(record=record)

# # According to folks @ PA SOS, it can take up to 80 seconds for the
# # staging endpoint to respond when a signature is uploaded.
# client = c.PennsylvaniaAPIClient.staging(_pa_api_key(), timeout=100.0)
# response = client.set_application(application)
# self.assertIsNone(response.error_code)
# According to folks @ PA SOS, it can take up to 80 seconds for the
# staging endpoint to respond when a signature is uploaded.
client = c.PennsylvaniaAPIClient.staging(_pa_api_key(), timeout=100.0)
response = client.set_application(application)
self.assertIsNone(response.error_code)
6 changes: 3 additions & 3 deletions voter_tools/pa/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -365,10 +365,10 @@ def has_error(self) -> bool:

def get_error(self) -> APIError | None:
"""Get the error object for the error code, or None."""
if not self.has_error():
if not self.error_code:
if self.application_id is None:
return APIValidationError.unexpected()
return None
if self.application_id is None:
return APIValidationError.unexpected()
return build_error_for_codes(self.error_codes or ())

def raise_for_error(self) -> None:
Expand Down
7 changes: 5 additions & 2 deletions voter_tools/pa/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,10 @@ def __init__(self, errors: t.Iterable[APIErrorDetails]) -> None:
"""Initialize the error with the given errors."""
self._errors = tuple(errors)
locs = ", ".join(str(error.loc) for error in errors)
super().__init__(f"Validation error on {locs}")
message = f"Validation errors on {locs}"
if len(self._errors) == 1:
message += f": {self._errors[0].msg}"
super().__init__(message)

def errors(self) -> tuple[APIErrorDetails, ...]:
"""Return the validation errors."""
Expand All @@ -90,7 +93,7 @@ def simple(cls, field: str, type: str, msg: str) -> "APIValidationError":
@classmethod
def unexpected(cls, code: str | None = None) -> "APIValidationError":
"""Create a generic validation error for unexpected error codes."""
code_f = f" ({code})" if code is not None else ""
code_f = f" ({code})" if code is not None else "(empty response)"
details = APIErrorDetails(
type="unexpected",
msg=f"Unexpected error. Please correct your form and try again. {code_f}",
Expand Down

0 comments on commit 14c35e1

Please sign in to comment.